mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Added trial and blocking to billing contract, fixed bug in running time tracker command
This commit is contained in:
committed by
Constantin Graf
parent
7c593f8f87
commit
5b7df869ad
@@ -77,15 +77,17 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
|
||||
return tap(User::create([
|
||||
$user = null;
|
||||
$organization = null;
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone ?? 'UTC',
|
||||
'week_start' => $startOfWeek,
|
||||
]), function (User $user) use ($currency): void {
|
||||
]);
|
||||
|
||||
$organization = new Organization();
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
@@ -101,7 +103,6 @@ class CreateNewUser implements CreatesNewUsers
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
});
|
||||
});
|
||||
|
||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||
if ($newsletterConsent) {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -12,7 +13,6 @@ use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\CreatesTeams;
|
||||
use Laravel\Jetstream\Events\AddingTeam;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateOrganization implements CreatesTeams
|
||||
@@ -33,8 +33,6 @@ class CreateOrganization implements CreatesTeams
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
AddingTeam::dispatch($user);
|
||||
|
||||
$organization = new Organization();
|
||||
$organization->name = $input['name'];
|
||||
$organization->personal_team = false;
|
||||
@@ -51,6 +49,8 @@ class CreateOrganization implements CreatesTeams
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Console\Commands\TimeEntry;
|
||||
use App\Mail\TimeEntryStillRunningMail;
|
||||
use App\Models\TimeEntry;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -47,6 +48,9 @@ class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
->with([
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query) {
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
|
||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||
|
||||
24
app/Events/AfterCreateOrganization.php
Normal file
24
app/Events/AfterCreateOrganization.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* This event is fired after an organization has been created.
|
||||
* This event does NOT fire when an organization is created as part of a registration.
|
||||
*/
|
||||
class AfterCreateOrganization
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public function __construct(Organization $organization)
|
||||
{
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberMadeToPlaceholder.php
Normal file
24
app/Events/MemberMadeToPlaceholder.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberMadeToPlaceholder
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public function __construct(Member $member, Organization $organization)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberRemoved.php
Normal file
24
app/Events/MemberRemoved.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberRemoved
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public function __construct(Member $member, Organization $organization)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberMadeToPlaceholder;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
@@ -122,11 +124,30 @@ class MemberController extends Controller
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
MemberRemoved::dispatch($member, $organization);
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||
*/
|
||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:make-placeholder', $member);
|
||||
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization();
|
||||
}
|
||||
|
||||
$memberService->makeMemberToPlaceholder($member);
|
||||
|
||||
MemberMadeToPlaceholder::dispatch($member, $organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a placeholder member to become a real member of the organization
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Http\Middleware\CheckOrganizationBlocked;
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
|
||||
@@ -71,5 +72,6 @@ class Kernel extends HttpKernel
|
||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
|
||||
'check-organization-blocked' => CheckOrganizationBlocked::class,
|
||||
];
|
||||
}
|
||||
|
||||
40
app/Http/Middleware/CheckOrganizationBlocked.php
Normal file
40
app/Http/Middleware/CheckOrganizationBlocked.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CheckOrganizationBlocked
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*
|
||||
* @throws OrganizationHasNoSubscriptionButMultipleMembersException
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$organization = $request->route('organization');
|
||||
|
||||
if (! ($organization instanceof Organization)) {
|
||||
throw new \LogicException('The organization must be loaded before this middleware.');
|
||||
}
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
|
||||
if ($billing->isBlocked($organization)) {
|
||||
throw new OrganizationHasNoSubscriptionButMultipleMembersException();
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -40,18 +40,18 @@ class HandleInertiaRequests extends Middleware
|
||||
public function share(Request $request): array
|
||||
{
|
||||
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
|
||||
$billing = null;
|
||||
if ($hasBilling) {
|
||||
|
||||
/** @var BillingContract $billing */
|
||||
$billing = app(BillingContract::class);
|
||||
}
|
||||
|
||||
$currentOrganization = $request->user()?->currentTeam;
|
||||
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'billing' => $billing !== null ? [
|
||||
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null,
|
||||
'billing' => $billing !== null && $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
'is_blocked' => $billing->isBlocked($currentOrganization),
|
||||
] : null,
|
||||
'flash' => [
|
||||
'message' => fn () => $request->session()->get('message'),
|
||||
|
||||
@@ -120,6 +120,14 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
->as('membership');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Member>
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(Member::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<User>
|
||||
*/
|
||||
|
||||
@@ -122,6 +122,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-ownership',
|
||||
'members:make-placeholder',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
||||
|
||||
@@ -6,10 +6,42 @@ namespace App\Service;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
/**
|
||||
* This class is a contract for the billing system
|
||||
* The billing system is responsible for managing the subscriptions of organizations
|
||||
* The concrete implementation of this contract for the cloud version of solidtime is implemented in an extension
|
||||
*/
|
||||
class BillingContract
|
||||
{
|
||||
/**
|
||||
* Check if the organization has a Professional subscription
|
||||
* A Professional subscription is a paid subscription that allows the organization to:
|
||||
* - Have more than 1 non-placeholder member
|
||||
* - Access features that are not available to free organizations
|
||||
*/
|
||||
public function hasSubscription(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization has a trial subscription
|
||||
* A trial subscription gives the organization the same benefits as a Professional subscription, but for a limited time
|
||||
*/
|
||||
public function hasTrial(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the organization is blocked
|
||||
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
|
||||
* This can happen if:
|
||||
* - The organization's trial has expired and during the trial the organization added non-placeholder members
|
||||
* - The organization's subscription has expired and the organization has more than 1 non-placeholder member
|
||||
*/
|
||||
public function isBlocked(Organization $organization): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
@@ -71,6 +72,8 @@ class UserService
|
||||
// Set the organization as the user's current organization
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
}
|
||||
|
||||
public function makeSureUserHasCurrentOrganization(User $user): void
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
@@ -31,6 +32,7 @@ return [
|
||||
OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner',
|
||||
ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed',
|
||||
ExportException::KEY => 'Export failed, please try again later or contact support',
|
||||
OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members',
|
||||
],
|
||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||
];
|
||||
|
||||
@@ -37,7 +37,7 @@ Route::middleware([
|
||||
// Organization routes
|
||||
Route::name('organizations.')->group(static function () {
|
||||
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
|
||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
});
|
||||
|
||||
// Member routes
|
||||
@@ -46,6 +46,7 @@ Route::middleware([
|
||||
Route::put('/organizations/{organization}/members/{member}', [MemberController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/organizations/{organization}/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
|
||||
Route::post('/organizations/{organization}/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
|
||||
});
|
||||
|
||||
// User routes
|
||||
@@ -61,36 +62,36 @@ Route::middleware([
|
||||
// Invitation routes
|
||||
Route::name('invitations.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store');
|
||||
Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend');
|
||||
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::post('/organizations/{organization}/invitations/{invitation}/resend', [InvitationController::class, 'resend'])->name('resend')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/invitations/{invitation}', [InvitationController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
|
||||
});
|
||||
|
||||
// Project routes
|
||||
Route::name('projects.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index');
|
||||
Route::get('/organizations/{organization}/projects/{project}', [ProjectController::class, 'show'])->name('show');
|
||||
Route::post('/organizations/{organization}/projects', [ProjectController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/organizations/{organization}/projects', [ProjectController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::put('/organizations/{organization}/projects/{project}', [ProjectController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/projects/{project}', [ProjectController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
|
||||
});
|
||||
|
||||
// Project member routes
|
||||
Route::name('project-members.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/organizations/{organization}/projects/{project}/project-members', [ProjectMemberController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::put('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
|
||||
});
|
||||
|
||||
// Time entry routes
|
||||
Route::name('time-entries.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index');
|
||||
Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate');
|
||||
Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update');
|
||||
Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple');
|
||||
Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy')->middleware('check-organization-blocked');
|
||||
});
|
||||
|
||||
Route::name('users.time-entries.')->group(static function () {
|
||||
@@ -100,31 +101,31 @@ Route::middleware([
|
||||
// Tag routes
|
||||
Route::name('tags.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/tags', [TagController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/tags', [TagController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update');
|
||||
Route::post('/organizations/{organization}/tags', [TagController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::put('/organizations/{organization}/tags/{tag}', [TagController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/tags/{tag}', [TagController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Client routes
|
||||
Route::name('clients.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/clients', [ClientController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/clients', [ClientController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update');
|
||||
Route::post('/organizations/{organization}/clients', [ClientController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Task routes
|
||||
Route::name('tasks.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/tasks', [TaskController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/tasks', [TaskController::class, 'store'])->name('store');
|
||||
Route::put('/organizations/{organization}/tasks/{task}', [TaskController::class, 'update'])->name('update');
|
||||
Route::post('/organizations/{organization}/tasks', [TaskController::class, 'store'])->name('store')->middleware('check-organization-blocked');
|
||||
Route::put('/organizations/{organization}/tasks/{task}', [TaskController::class, 'update'])->name('update')->middleware('check-organization-blocked');
|
||||
Route::delete('/organizations/{organization}/tasks/{task}', [TaskController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Import routes
|
||||
Route::name('import.')->group(static function () {
|
||||
Route::get('/organizations/{organization}/importers', [ImportController::class, 'index'])->name('index');
|
||||
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import');
|
||||
Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import')->middleware('check-organization-blocked');
|
||||
});
|
||||
|
||||
// Export routes
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiTokenPermissionsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_token_permissions_can_be_updated(): void
|
||||
{
|
||||
if (! Features::hasApiFeatures()) {
|
||||
$this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
||||
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
'token' => Str::random(40),
|
||||
'abilities' => ['create', 'read'],
|
||||
]);
|
||||
|
||||
$response = $this->put('/user/api-tokens/'.$token->id, [
|
||||
'name' => $token->name,
|
||||
'permissions' => [
|
||||
'delete',
|
||||
'missing-permission',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertTrue($user->fresh()->tokens->first()->can('delete'));
|
||||
$this->assertFalse($user->fresh()->tokens->first()->can('read'));
|
||||
$this->assertFalse($user->fresh()->tokens->first()->can('missing-permission'));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateApiTokenTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_tokens_can_be_created(): void
|
||||
{
|
||||
if (! Features::hasApiFeatures()) {
|
||||
$this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
||||
|
||||
$response = $this->post('/user/api-tokens', [
|
||||
'name' => 'Test Token',
|
||||
'permissions' => [
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $user->fresh()->tokens);
|
||||
$this->assertEquals('Test Token', $user->fresh()->tokens->first()->name);
|
||||
$this->assertTrue($user->fresh()->tokens->first()->can('read'));
|
||||
$this->assertFalse($user->fresh()->tokens->first()->can('delete'));
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,26 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateTeamTest extends TestCase
|
||||
class CreateOrganizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_teams_can_be_created(): void
|
||||
public function test_organizations_can_be_created(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
sleep(1);
|
||||
Event::fake([
|
||||
AfterCreateOrganization::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/teams', [
|
||||
@@ -28,6 +32,7 @@ class CreateTeamTest extends TestCase
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(302);
|
||||
/** @var Organization|null $newOrganization */
|
||||
$ownedTeams = $user->fresh()->ownedTeams;
|
||||
$this->assertCount(2, $ownedTeams);
|
||||
@@ -36,5 +41,8 @@ class CreateTeamTest extends TestCase
|
||||
/** @var Member $member */
|
||||
$member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail();
|
||||
$this->assertSame(Role::Owner->value, $member->role);
|
||||
Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool {
|
||||
return $event->organization->is($newOrganization);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeleteApiTokenTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_api_tokens_can_be_deleted(): void
|
||||
{
|
||||
if (! Features::hasApiFeatures()) {
|
||||
$this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
||||
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
'token' => Str::random(40),
|
||||
'abilities' => ['create', 'read'],
|
||||
]);
|
||||
|
||||
$response = $this->delete('/user/api-tokens/'.$token->id);
|
||||
|
||||
$this->assertCount(0, $user->fresh()->tokens);
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeleteTeamTest extends TestCase
|
||||
class DeleteOrganizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_teams_can_be_deleted_and_users_of_the_organization_that_have_no_organization_get_a_new_one(): void
|
||||
public function test_organizations_can_be_deleted_and_users_of_the_organization_that_have_no_organization_get_a_new_one(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
@@ -40,7 +40,7 @@ class DeleteTeamTest extends TestCase
|
||||
$this->assertFalse($otherUser->fresh()->teams->first()->is($organization));
|
||||
}
|
||||
|
||||
public function test_personal_teams_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void
|
||||
public function test_personal_organizations_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
@@ -36,7 +36,9 @@ class EmailVerificationTest extends TestCase
|
||||
$this->markTestSkipped('Email verification not enabled.');
|
||||
}
|
||||
|
||||
Event::fake();
|
||||
Event::fake([
|
||||
Verified::class,
|
||||
]);
|
||||
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
|
||||
@@ -33,17 +33,6 @@ class RegistrationTest extends TestCase
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_registration_screen_cannot_be_rendered_if_support_is_disabled(): void
|
||||
{
|
||||
if (Features::enabled(Features::registration())) {
|
||||
$this->markTestSkipped('Registration support is enabled.');
|
||||
}
|
||||
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests;
|
||||
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\PermissionStore;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -24,6 +25,11 @@ abstract class TestCase extends BaseTestCase
|
||||
parent::setUp();
|
||||
Mail::fake();
|
||||
LogFake::bind();
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('hasSubscription')->andReturn(false);
|
||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected function mockPrivateStorage(): void
|
||||
|
||||
@@ -35,10 +35,14 @@ abstract class TestCaseWithDatabase extends TestCase
|
||||
$ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->create([
|
||||
'role' => Role::Owner->value,
|
||||
]);
|
||||
$owner->currentOrganization()->associate($organization);
|
||||
$owner->save();
|
||||
}
|
||||
$member = Member::factory()->forUser($user)->forOrganization($organization)->create([
|
||||
'role' => $roleName,
|
||||
]);
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
|
||||
return (object) [
|
||||
'user' => $user,
|
||||
|
||||
@@ -137,4 +137,28 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
|
||||
'Start sending email to user "'.$user->user->email.'" ('.$user->user->getKey().') for time entry '.$timeEntryRunningLongerThanThreshold->getKey()."\n".
|
||||
"Finished sending 1 still running time entry emails...\n", $output);
|
||||
}
|
||||
|
||||
public function test_does_not_send_emails_for_placeholder_users(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$user->user->is_placeholder = true;
|
||||
$user->user->save();
|
||||
$timeEntryRunningLongerThanThreshold = TimeEntry::factory()->forMember($user->member)->create([
|
||||
'start' => Carbon::now()->subHours(8)->subSecond(),
|
||||
'end' => null,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails');
|
||||
|
||||
// Assert
|
||||
Mail::assertNothingOutgoing();
|
||||
$timeEntryRunningLongerThanThreshold->refresh();
|
||||
$this->assertNull($timeEntryRunningLongerThanThreshold->still_active_email_sent_at);
|
||||
$this->assertSame(Command::SUCCESS, $exitCode);
|
||||
$output = Artisan::output();
|
||||
$this->assertSame("Sending still running time entry emails...\n".
|
||||
"Finished sending 0 still running time entry emails...\n", $output);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberMadeToPlaceholder;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Http\Controllers\Api\V1\MemberController;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
@@ -13,6 +15,7 @@ use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Passport\Passport;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
@@ -303,12 +306,16 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
Event::assertNotDispatched(MemberRemoved::class);
|
||||
}
|
||||
|
||||
public function test_destroy_member_fails_if_member_is_owner(): void
|
||||
@@ -319,6 +326,9 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
$memberToDelete = Member::factory()->forOrganization($data->organization)->role(Role::Owner)->create();
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $memberToDelete->getKey()]));
|
||||
@@ -326,6 +336,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'Can not remove owner from organization');
|
||||
Event::assertNotDispatched(MemberRemoved::class);
|
||||
}
|
||||
|
||||
public function test_destroy_member_fails_if_member_is_not_part_of_org(): void
|
||||
@@ -338,12 +349,16 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
'members:delete',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $otherData->member->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
Event::assertNotDispatched(MemberRemoved::class);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_time_entry(): void
|
||||
@@ -354,6 +369,9 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create();
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
@@ -364,6 +382,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
Event::assertNotDispatched(MemberRemoved::class);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_project_member(): void
|
||||
@@ -375,6 +394,9 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
ProjectMember::factory()->forProject($project)->forMember($data->member)->create();
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
@@ -385,6 +407,7 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
Event::assertNotDispatched(MemberRemoved::class);
|
||||
}
|
||||
|
||||
public function test_destroy_member_succeeds_if_data_is_valid(): void
|
||||
@@ -394,6 +417,9 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
'members:delete',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
Event::fake([
|
||||
MemberRemoved::class,
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
|
||||
@@ -403,6 +429,118 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'id' => $data->member->getKey(),
|
||||
]);
|
||||
Event::assertDispatched(function (MemberRemoved $event) use ($data): bool {
|
||||
return $event->organization->is($data->organization) &&
|
||||
$event->member->is($data->member);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public function test_make_placeholder_fails_if_user_has_no_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
Event::fake([
|
||||
MemberMadeToPlaceholder::class,
|
||||
]);
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.members.make-placeholder', [
|
||||
'organization' => $data->organization->getKey(),
|
||||
'member' => $data->member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
Event::assertNotDispatched(MemberMadeToPlaceholder::class);
|
||||
}
|
||||
|
||||
public function test_make_placeholder_fails_if_member_is_owner(): void
|
||||
{
|
||||
// Arrange
|
||||
Event::fake([
|
||||
MemberMadeToPlaceholder::class,
|
||||
]);
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:make-placeholder',
|
||||
]);
|
||||
$member = Member::factory()->forOrganization($data->organization)->role(Role::Owner)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.members.make-placeholder', [
|
||||
'organization' => $data->organization->getKey(),
|
||||
'member' => $member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'Can not remove owner from organization');
|
||||
Event::assertNotDispatched(MemberMadeToPlaceholder::class);
|
||||
}
|
||||
|
||||
public function test_make_placeholder_fails_if_member_is_not_part_of_org(): void
|
||||
{
|
||||
// Arrange
|
||||
Event::fake([
|
||||
MemberMadeToPlaceholder::class,
|
||||
]);
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:make-placeholder',
|
||||
]);
|
||||
$otherData = $this->createUserWithPermission([
|
||||
'members:make-placeholder',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.members.make-placeholder', [
|
||||
'organization' => $data->organization->getKey(),
|
||||
'member' => $otherData->member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_make_placeholder_creates_placeholder_and_attaches_resources_to_the_new_user(): void
|
||||
{
|
||||
// Arrange
|
||||
Event::fake([
|
||||
MemberMadeToPlaceholder::class,
|
||||
]);
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:make-placeholder',
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Admin)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($data->organization)->create();
|
||||
$project = Project::factory()->forOrganization($data->organization)->create();
|
||||
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.members.make-placeholder', [
|
||||
'organization' => $data->organization->getKey(),
|
||||
'member' => $member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$member->refresh();
|
||||
$this->assertSame(Role::Placeholder->value, $member->role);
|
||||
$this->assertTrue($member->user->is_placeholder);
|
||||
$this->assertCount(1, $user->organizations);
|
||||
$this->assertCount(1, $member->user->organizations);
|
||||
$this->assertNotEquals($user->getKey(), $member->user->getKey());
|
||||
$timeEntry->refresh();
|
||||
$this->assertSame($member->user_id, $timeEntry->user_id);
|
||||
$projectMember->refresh();
|
||||
$this->assertSame($member->user_id, $projectMember->user_id);
|
||||
Event::assertDispatched(function (MemberMadeToPlaceholder $event) use ($data, $member): bool {
|
||||
return $event->organization->is($data->organization) &&
|
||||
$event->member->is($member);
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void
|
||||
|
||||
@@ -4,10 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Web;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
abstract class EndpointTestAbstract extends TestCase
|
||||
abstract class EndpointTestAbstract extends TestCaseWithDatabase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
}
|
||||
|
||||
109
tests/Unit/Middleware/CheckOrganizationBlockedMiddlewareTest.php
Normal file
109
tests/Unit/Middleware/CheckOrganizationBlockedMiddlewareTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Middleware;
|
||||
|
||||
use App\Http\Middleware\CheckOrganizationBlocked;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillingContract;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Passport;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[CoversClass(CheckOrganizationBlocked::class)]
|
||||
#[UsesClass(CheckOrganizationBlocked::class)]
|
||||
class CheckOrganizationBlockedMiddlewareTest extends MiddlewareTestAbstract
|
||||
{
|
||||
private function createTestRoute(): void
|
||||
{
|
||||
Route::get('/test-route/{organization}', function (Organization $organization) {
|
||||
return response()->json(['message' => 'Test route', 'id' => $organization->getKey()]);
|
||||
})->middleware([StartSession::class, SubstituteBindings::class, CheckOrganizationBlocked::class]);
|
||||
|
||||
}
|
||||
|
||||
private function createTestRouteNoModelBinding(): string
|
||||
{
|
||||
$route = Route::get('/test-route', function () {
|
||||
return response()->json(['message' => 'Test route']);
|
||||
})->middleware([StartSession::class, SubstituteBindings::class, CheckOrganizationBlocked::class]);
|
||||
|
||||
return $route->uri;
|
||||
}
|
||||
|
||||
public function test_request_fails_if_organization_is_blocked_by_the_billing_system(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$this->createTestRoute();
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('isBlocked')->andReturn(true)->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->get('/test-route/'.$user->organization->getKey());
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson(['message' => 'Organization has no subscription but multiple members']);
|
||||
}
|
||||
|
||||
public function test_request_fails_if_organization_is_not_found(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$this->createTestRoute();
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('isBlocked')->never();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->get('/test-route/'.Str::uuid());
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_request_fails_on_route_without_organization_model_binding(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$route = $this->createTestRouteNoModelBinding();
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('isBlocked')->never();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->get($route);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(500);
|
||||
}
|
||||
|
||||
public function test_request_succeeds_if_organization_is_not_blocked_by_the_billing_system(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$this->createTestRoute();
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false)->once();
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->get('/test-route/'.$user->organization->getKey());
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'Test route', 'id' => $user->organization->getKey()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Middleware;
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Service\BillingContract;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Passport\Passport;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[CoversClass(HandleInertiaRequests::class)]
|
||||
#[UsesClass(HandleInertiaRequests::class)]
|
||||
class HandleInertiaRequestsMiddlewareTest extends MiddlewareTestAbstract
|
||||
{
|
||||
private function createTestRoute(): string
|
||||
{
|
||||
return Route::get('/test-route', function () {
|
||||
return Inertia::render('Welcome');
|
||||
})->middleware([StartSession::class, HandleInertiaRequests::class])->uri;
|
||||
}
|
||||
|
||||
public function test_adds_billing_information_to_shared_data_of_inertia_requests(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$route = $this->createTestRoute();
|
||||
$this->mock(BillingContract::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('hasSubscription')->andReturn(false);
|
||||
$mock->shouldReceive('hasTrial')->andReturn(false);
|
||||
$mock->shouldReceive('isBlocked')->andReturn(false);
|
||||
});
|
||||
Passport::actingAs($user->user);
|
||||
|
||||
// Act
|
||||
$response = $this->get($route);
|
||||
|
||||
// Assert
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->where('billing.has_subscription', false)
|
||||
->where('billing.has_trial', false)
|
||||
->where('billing.is_blocked', false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
abstract class MiddlewareTestAbstract extends TestCase
|
||||
abstract class MiddlewareTestAbstract extends TestCaseWithDatabase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
}
|
||||
|
||||
31
tests/Unit/Model/OrganizationModelTest.php
Normal file
31
tests/Unit/Model/OrganizationModelTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Model;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
#[CoversClass(Organization::class)]
|
||||
#[UsesClass(Organization::class)]
|
||||
class OrganizationModelTest extends ModelTestAbstract
|
||||
{
|
||||
public function test_it_has_many_members(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$members = Member::factory()->forOrganization($organization)->createMany(3);
|
||||
|
||||
// Act
|
||||
$organization->refresh();
|
||||
$membersRel = $organization->members;
|
||||
|
||||
// Assert
|
||||
$this->assertNotNull($membersRel);
|
||||
$this->assertCount(3, $membersRel);
|
||||
$this->assertTrue($membersRel->first()->is($members->first()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user