diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 06a5a94e..fca5b024 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -77,30 +77,31 @@ 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; - $organization->currency = $currency ?? 'EUR'; - $organization->owner()->associate($user); - $organization->save(); + ]); - $organization->users()->attach( - $user, [ - 'role' => Role::Owner->value, - ] - ); + $organization = new Organization(); + $organization->name = explode(' ', $user->name, 2)[0]."'s Organization"; + $organization->personal_team = true; + $organization->currency = $currency ?? 'EUR'; + $organization->owner()->associate($user); + $organization->save(); - $user->ownedTeams()->save($organization); - }); + $organization->users()->attach( + $user, [ + 'role' => Role::Owner->value, + ] + ); + + $user->ownedTeams()->save($organization); }); $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; diff --git a/app/Actions/Jetstream/CreateOrganization.php b/app/Actions/Jetstream/CreateOrganization.php index 151cfec3..49878277 100644 --- a/app/Actions/Jetstream/CreateOrganization.php +++ b/app/Actions/Jetstream/CreateOrganization.php @@ -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; } } diff --git a/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php b/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php index b5a91eab..e46d5943 100644 --- a/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php +++ b/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php @@ -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 $timeEntries */ diff --git a/app/Events/AfterCreateOrganization.php b/app/Events/AfterCreateOrganization.php new file mode 100644 index 00000000..0d869b71 --- /dev/null +++ b/app/Events/AfterCreateOrganization.php @@ -0,0 +1,24 @@ +organization = $organization; + } +} diff --git a/app/Events/MemberMadeToPlaceholder.php b/app/Events/MemberMadeToPlaceholder.php new file mode 100644 index 00000000..a872b29e --- /dev/null +++ b/app/Events/MemberMadeToPlaceholder.php @@ -0,0 +1,24 @@ +member = $member; + $this->organization = $organization; + } +} diff --git a/app/Events/MemberRemoved.php b/app/Events/MemberRemoved.php new file mode 100644 index 00000000..20d9152e --- /dev/null +++ b/app/Events/MemberRemoved.php @@ -0,0 +1,24 @@ +member = $member; + $this->organization = $organization; + } +} diff --git a/app/Exceptions/Api/OrganizationHasNoSubscriptionButMultipleMembersException.php b/app/Exceptions/Api/OrganizationHasNoSubscriptionButMultipleMembersException.php new file mode 100644 index 00000000..58cf371a --- /dev/null +++ b/app/Exceptions/Api/OrganizationHasNoSubscriptionButMultipleMembersException.php @@ -0,0 +1,10 @@ +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 * diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 783f5125..8bba3b60 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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, ]; } diff --git a/app/Http/Middleware/CheckOrganizationBlocked.php b/app/Http/Middleware/CheckOrganizationBlocked.php new file mode 100644 index 00000000..41fd2fa1 --- /dev/null +++ b/app/Http/Middleware/CheckOrganizationBlocked.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c7bd5640..9968b6e3 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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); - } + + /** @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'), diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 7897fdc6..34ac1431 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -120,6 +120,14 @@ class Organization extends JetstreamTeam implements AuditableContract ->as('membership'); } + /** + * @return HasMany + */ + public function members(): HasMany + { + return $this->hasMany(Member::class); + } + /** * @return BelongsToMany */ diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 3ea25ed1..4baa6847 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -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.'); diff --git a/app/Service/BillingContract.php b/app/Service/BillingContract.php index ae9df7d5..2a43a39f 100644 --- a/app/Service/BillingContract.php +++ b/app/Service/BillingContract.php @@ -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; + } } diff --git a/app/Service/UserService.php b/app/Service/UserService.php index 5b1d58d6..a919d90b 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -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 diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 8f754f40..b2506647 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -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.', ]; diff --git a/routes/api.php b/routes/api.php index 0dbae777..f5ac543e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 diff --git a/tests/Feature/ApiTokenPermissionsTest.php b/tests/Feature/ApiTokenPermissionsTest.php deleted file mode 100644 index 910d3e8e..00000000 --- a/tests/Feature/ApiTokenPermissionsTest.php +++ /dev/null @@ -1,43 +0,0 @@ -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')); - } -} diff --git a/tests/Feature/CreateApiTokenTest.php b/tests/Feature/CreateApiTokenTest.php deleted file mode 100644 index f12578d5..00000000 --- a/tests/Feature/CreateApiTokenTest.php +++ /dev/null @@ -1,37 +0,0 @@ -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')); - } -} diff --git a/tests/Feature/CreateTeamTest.php b/tests/Feature/CreateOrganizationTest.php similarity index 67% rename from tests/Feature/CreateTeamTest.php rename to tests/Feature/CreateOrganizationTest.php index f2cbacd7..31b1241c 100644 --- a/tests/Feature/CreateTeamTest.php +++ b/tests/Feature/CreateOrganizationTest.php @@ -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); + }); } } diff --git a/tests/Feature/DeleteApiTokenTest.php b/tests/Feature/DeleteApiTokenTest.php deleted file mode 100644 index 10f499ac..00000000 --- a/tests/Feature/DeleteApiTokenTest.php +++ /dev/null @@ -1,35 +0,0 @@ -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); - } -} diff --git a/tests/Feature/DeleteTeamTest.php b/tests/Feature/DeleteOrganizationTest.php similarity index 88% rename from tests/Feature/DeleteTeamTest.php rename to tests/Feature/DeleteOrganizationTest.php index e0f3becd..e36aff78 100644 --- a/tests/Feature/DeleteTeamTest.php +++ b/tests/Feature/DeleteOrganizationTest.php @@ -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(); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php index e1303b80..57f89f5f 100644 --- a/tests/Feature/EmailVerificationTest.php +++ b/tests/Feature/EmailVerificationTest.php @@ -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(); diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 9a2abedd..86efdd1e 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -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 diff --git a/tests/TestCase.php b/tests/TestCase.php index 6679f627..73d48ba8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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 diff --git a/tests/TestCaseWithDatabase.php b/tests/TestCaseWithDatabase.php index 50317408..5dbff1d6 100644 --- a/tests/TestCaseWithDatabase.php +++ b/tests/TestCaseWithDatabase.php @@ -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, diff --git a/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php b/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php index 21d6f33c..958d508f 100644 --- a/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php +++ b/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php @@ -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); + } } diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index 2dff2624..f4c1eebc 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -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 diff --git a/tests/Unit/Endpoint/Web/EndpointTestAbstract.php b/tests/Unit/Endpoint/Web/EndpointTestAbstract.php index 2fd0d488..318b2ab2 100644 --- a/tests/Unit/Endpoint/Web/EndpointTestAbstract.php +++ b/tests/Unit/Endpoint/Web/EndpointTestAbstract.php @@ -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; } diff --git a/tests/Unit/Middleware/CheckOrganizationBlockedMiddlewareTest.php b/tests/Unit/Middleware/CheckOrganizationBlockedMiddlewareTest.php new file mode 100644 index 00000000..64635222 --- /dev/null +++ b/tests/Unit/Middleware/CheckOrganizationBlockedMiddlewareTest.php @@ -0,0 +1,109 @@ +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()]); + } +} diff --git a/tests/Unit/Middleware/HandleInertiaRequestsMiddlewareTest.php b/tests/Unit/Middleware/HandleInertiaRequestsMiddlewareTest.php new file mode 100644 index 00000000..59de2c57 --- /dev/null +++ b/tests/Unit/Middleware/HandleInertiaRequestsMiddlewareTest.php @@ -0,0 +1,51 @@ +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) + ); + } +} diff --git a/tests/Unit/Middleware/MiddlewareTestAbstract.php b/tests/Unit/Middleware/MiddlewareTestAbstract.php index a24e94ee..12719da4 100644 --- a/tests/Unit/Middleware/MiddlewareTestAbstract.php +++ b/tests/Unit/Middleware/MiddlewareTestAbstract.php @@ -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; } diff --git a/tests/Unit/Model/OrganizationModelTest.php b/tests/Unit/Model/OrganizationModelTest.php new file mode 100644 index 00000000..75c8c80a --- /dev/null +++ b/tests/Unit/Model/OrganizationModelTest.php @@ -0,0 +1,31 @@ +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())); + } +}