From fd8d596e9ba7477a57a8887f365caee7e73fa400 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Mon, 15 Jul 2024 17:17:56 +0200 Subject: [PATCH] Moved invitation from jetstream to API; Deactived moved jetstream features --- .../Jetstream/AddOrganizationMember.php | 6 -- app/Actions/Jetstream/DeleteOrganization.php | 1 + app/Actions/Jetstream/DeleteUser.php | 2 + .../Jetstream/InviteOrganizationMember.php | 90 +------------------ .../Jetstream/RemoveOrganizationMember.php | 39 ++------ app/Actions/Jetstream/UpdateMemberRole.php | 50 +---------- ...lreadyMemberOfOrganizationApiException.php | 10 +++ app/Exceptions/MovedToApiException.php | 15 ++++ .../Api/V1/InvitationController.php | 21 ++--- .../Controllers/Api/V1/MemberController.php | 12 +-- .../V1/Invitation/InvitationStoreRequest.php | 12 +++ app/Mail/OrganizationInvitationMail.php | 40 +++++++++ app/Policies/OrganizationPolicy.php | 8 +- app/Providers/JetstreamServiceProvider.php | 4 + app/Service/InvitationService.php | 43 +++++++++ lang/en/exceptions.php | 2 + lang/en/validation.php | 1 + ....php => organization-invitation.blade.php} | 4 +- tests/Feature/InviteTeamMemberTest.php | 57 ++---------- tests/Feature/LeaveTeamTest.php | 19 ++-- tests/Feature/RemoveTeamMemberTest.php | 25 ++---- tests/Feature/UpdateTeamMemberRoleTest.php | 69 +------------- .../Api/V1/InvitationEndpointTest.php | 78 +++++++++++++++- .../Endpoint/Api/V1/MemberEndpointTest.php | 3 +- .../Mail/OrganizationInvitationMailTest.php | 31 +++++++ 25 files changed, 294 insertions(+), 348 deletions(-) create mode 100644 app/Exceptions/Api/UserIsAlreadyMemberOfOrganizationApiException.php create mode 100644 app/Exceptions/MovedToApiException.php create mode 100644 app/Mail/OrganizationInvitationMail.php create mode 100644 app/Service/InvitationService.php rename resources/views/emails/{team-invitation.blade.php => organization-invitation.blade.php} (75%) create mode 100644 tests/Unit/Mail/OrganizationInvitationMailTest.php diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index de444e77..e444f5dc 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -7,7 +7,6 @@ use App\Enums\Role; use App\Models\Organization; use App\Models\User; -use App\Service\UserService; use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; @@ -43,10 +42,6 @@ public function add(User $owner, Organization $organization, string $email, ?str $organization->users()->attach( $newOrganizationMember, ['role' => $role] ); - - if ($role === Role::Owner->value) { - app(UserService::class)->changeOwnership($organization, $newOrganizationMember); - } }); TeamMemberAdded::dispatch($organization, $newOrganizationMember); @@ -84,7 +79,6 @@ protected function rules(): array 'required', 'string', Rule::in([ - Role::Owner->value, Role::Admin->value, Role::Manager->value, Role::Employee->value, diff --git a/app/Actions/Jetstream/DeleteOrganization.php b/app/Actions/Jetstream/DeleteOrganization.php index f2694d58..a33e62d1 100644 --- a/app/Actions/Jetstream/DeleteOrganization.php +++ b/app/Actions/Jetstream/DeleteOrganization.php @@ -15,6 +15,7 @@ class DeleteOrganization implements DeletesTeams */ public function delete(Organization $organization): void { + /** @see ValidateOrganizationDeletion */ app(DeletionService::class)->deleteOrganization($organization); } } diff --git a/app/Actions/Jetstream/DeleteUser.php b/app/Actions/Jetstream/DeleteUser.php index ab118ea1..4385b3f5 100644 --- a/app/Actions/Jetstream/DeleteUser.php +++ b/app/Actions/Jetstream/DeleteUser.php @@ -14,6 +14,8 @@ class DeleteUser implements DeletesUsers { /** * Delete the given user. + * + * @throws ValidationException */ public function delete(User $user): void { diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index 129aa3cb..429b2b0b 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -4,103 +4,21 @@ namespace App\Actions\Jetstream; -use App\Enums\Role; +use App\Exceptions\MovedToApiException; use App\Models\Organization; -use App\Models\OrganizationInvitation; use App\Models\User; -use App\Service\PermissionStore; -use Closure; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\In; -use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; +use Exception; use Laravel\Jetstream\Contracts\InvitesTeamMembers; -use Laravel\Jetstream\Events\InvitingTeamMember; -use Laravel\Jetstream\Mail\TeamInvitation; class InviteOrganizationMember implements InvitesTeamMembers { /** * Invite a new team member to the given team. * - * @throws AuthorizationException + * @throws Exception */ public function invite(User $user, Organization $organization, string $email, ?string $role = null): void { - if (! app(PermissionStore::class)->has($organization, 'invitations:create')) { - throw new AuthorizationException(); - } - - $this->validate($organization, $email, $role); - - InvitingTeamMember::dispatch($organization, $email, $role); - - /** @var OrganizationInvitation $invitation */ - $invitation = $organization->teamInvitations()->create([ - 'email' => $email, - 'role' => $role, - ]); - - Mail::to($email)->send(new TeamInvitation($invitation)); - } - - /** - * Validate the invite member operation. - */ - protected function validate(Organization $organization, string $email, ?string $role): void - { - Validator::make([ - 'email' => $email, - 'role' => $role, - ], $this->rules($organization))->after( - $this->ensureUserIsNotAlreadyOnTeam($organization, $email) - )->validateWithBag('addTeamMember'); - } - - /** - * Get the validation rules for inviting a team member. - * - * @return array> - */ - protected function rules(Organization $organization): array - { - return array_filter([ - 'email' => [ - 'required', - 'email', - (new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { - /** @var Builder $builder */ - return $builder->whereBelongsTo($organization, 'organization'); - }))->withMessage(__('This user has already been invited to the team.')), - ], - 'role' => [ - 'required', - 'string', - Rule::in([ - Role::Owner->value, - Role::Admin->value, - Role::Manager->value, - Role::Employee->value, - ]), - ], - ]); - } - - /** - * Ensure that the user is not already on the team. - */ - protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, string $email): Closure - { - return function ($validator) use ($organization, $email) { - $validator->errors()->addIf( - $organization->hasRealUserWithEmail($email), - 'email', - __('This user already belongs to the team.') - ); - }; + throw new MovedToApiException(); } } diff --git a/app/Actions/Jetstream/RemoveOrganizationMember.php b/app/Actions/Jetstream/RemoveOrganizationMember.php index d03a094a..afdb4b49 100644 --- a/app/Actions/Jetstream/RemoveOrganizationMember.php +++ b/app/Actions/Jetstream/RemoveOrganizationMember.php @@ -4,50 +4,21 @@ namespace App\Actions\Jetstream; +use App\Exceptions\MovedToApiException; use App\Models\Organization; use App\Models\User; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Support\Facades\Gate; -use Illuminate\Validation\ValidationException; +use Exception; use Laravel\Jetstream\Contracts\RemovesTeamMembers; -use Laravel\Jetstream\Events\TeamMemberRemoved; class RemoveOrganizationMember implements RemovesTeamMembers { /** * Remove the team member from the given team. + * + * @throws Exception */ public function remove(User $user, Organization $organization, User $teamMember): void { - $this->authorize($user, $organization, $teamMember); - - $this->ensureUserDoesNotOwnTeam($teamMember, $organization); - - $organization->removeUser($teamMember); - - TeamMemberRemoved::dispatch($organization, $teamMember); - } - - /** - * Authorize that the user can remove the team member. - */ - protected function authorize(User $user, Organization $organization, User $teamMember): void - { - if (! Gate::forUser($user)->check('removeTeamMember', $organization) && - $user->id !== $teamMember->id) { - throw new AuthorizationException; - } - } - - /** - * Ensure that the currently authenticated user does not own the team. - */ - protected function ensureUserDoesNotOwnTeam(User $teamMember, Organization $organization): void - { - if ($teamMember->id === $organization->owner->id) { - throw ValidationException::withMessages([ - 'team' => [__('You may not leave a team that you created.')], - ])->errorBag('removeTeamMember'); - } + throw new MovedToApiException(); } } diff --git a/app/Actions/Jetstream/UpdateMemberRole.php b/app/Actions/Jetstream/UpdateMemberRole.php index 706361c9..af04a9c4 100644 --- a/app/Actions/Jetstream/UpdateMemberRole.php +++ b/app/Actions/Jetstream/UpdateMemberRole.php @@ -5,63 +5,21 @@ namespace App\Actions\Jetstream; use App\Enums\Role; +use App\Exceptions\MovedToApiException; use App\Models\Member; use App\Models\Organization; use App\Models\User; -use App\Service\PermissionStore; -use App\Service\UserService; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; -use Illuminate\Validation\ValidationException; -use Laravel\Jetstream\Events\TeamMemberUpdated; +use Exception; class UpdateMemberRole { /** * Update the role for the given team member. * - * @throws AuthorizationException - * @throws ValidationException + * @throws Exception */ public function update(User $actingUser, Organization $organization, string $userId, string $role): void { - if (! app(PermissionStore::class)->has($organization, 'members:change-ownership')) { - throw new AuthorizationException(); - } - - $user = User::where('id', '=', $userId)->firstOrFail(); - $member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); - if ($member->role === Role::Placeholder->value) { - abort(403, 'Cannot update the role of a placeholder member.'); - } - - Validator::make([ - 'role' => $role, - ], [ - 'role' => [ - 'required', - 'string', - Rule::in([ - Role::Owner->value, - Role::Admin->value, - Role::Manager->value, - Role::Employee->value, - ]), - ], - ])->validate(); - - DB::transaction(function () use ($organization, $userId, $role, $user) { - $organization->users()->updateExistingPivot($userId, [ - 'role' => $role, - ]); - - if ($role === Role::Owner->value) { - app(UserService::class)->changeOwnership($organization, $user); - } - }); - - TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId)); + throw new MovedToApiException(); } } diff --git a/app/Exceptions/Api/UserIsAlreadyMemberOfOrganizationApiException.php b/app/Exceptions/Api/UserIsAlreadyMemberOfOrganizationApiException.php new file mode 100644 index 00000000..fa925919 --- /dev/null +++ b/app/Exceptions/Api/UserIsAlreadyMemberOfOrganizationApiException.php @@ -0,0 +1,10 @@ +checkPermission($organization, 'invitations:create'); - app(InvitesTeamMembers::class)->invite( - $this->user(), - $organization, - $request->input('email'), - $request->getRole()->value - ); + $email = $request->getEmail(); + $role = $request->getRole(); + + $invitationService->inviteUser($organization, $email, $role); return response()->json(null, 204); } @@ -77,7 +77,8 @@ public function resend(Organization $organization, OrganizationInvitation $invit { $this->checkPermission($organization, 'invitations:resend', $invitation); - Mail::to($invitation->email)->send(new TeamInvitation($invitation)); + Mail::to($invitation->email) + ->queue(new OrganizationInvitationMail($invitation)); return response()->json(null, 204); } diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index cde0bc71..1d05c48b 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -21,12 +21,11 @@ use App\Models\ProjectMember; use App\Models\TimeEntry; use App\Service\BillableRateService; +use App\Service\InvitationService; use App\Service\MemberService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; -use Laravel\Jetstream\Contracts\InvitesTeamMembers; class MemberController extends Controller { @@ -134,7 +133,7 @@ public function destroy(Organization $organization, Member $member): JsonRespons * * @operationId invitePlaceholder */ - public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse + public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse { $this->checkPermission($organization, 'members:invite-placeholder', $member); $user = $member->user; @@ -143,12 +142,7 @@ public function invitePlaceholder(Organization $organization, Member $member, Re throw new UserNotPlaceholderApiException(); } - app(InvitesTeamMembers::class)->invite( - $this->user(), - $organization, - $user->email, - Role::Employee->value, - ); + $invitationService->inviteUser($organization, $user->email, Role::Employee); return response()->json(null, 204); } diff --git a/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php b/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php index 9f479bc8..de7de748 100644 --- a/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php +++ b/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php @@ -6,9 +6,12 @@ use App\Enums\Role; use App\Models\Organization; +use App\Models\OrganizationInvitation; use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; /** * @property Organization $organization @@ -26,6 +29,10 @@ public function rules(): array 'email' => [ 'required', 'email', + (new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }))->withCustomTranslation('validation.invitation_already_exists'), ], 'role' => [ 'required', @@ -40,4 +47,9 @@ public function getRole(): Role { return Role::from($this->input('role')); } + + public function getEmail(): string + { + return $this->input('email'); + } } diff --git a/app/Mail/OrganizationInvitationMail.php b/app/Mail/OrganizationInvitationMail.php new file mode 100644 index 00000000..8a8dfa8d --- /dev/null +++ b/app/Mail/OrganizationInvitationMail.php @@ -0,0 +1,40 @@ +invitation = $invitation; + } + + /** + * Build the message. + */ + public function build(): self + { + return $this->markdown('emails.organization-invitation', [ + 'acceptUrl' => URL::signedRoute('team-invitations.accept', [ + 'invitation' => $this->invitation, + ]), + ])->subject(__('Organization Invitation')); + } +} diff --git a/app/Policies/OrganizationPolicy.php b/app/Policies/OrganizationPolicy.php index e6ece6b9..520a2135 100644 --- a/app/Policies/OrganizationPolicy.php +++ b/app/Policies/OrganizationPolicy.php @@ -70,7 +70,7 @@ public function addTeamMember(User $user, Organization $organization): bool return true; } - return $user->ownsTeam($organization); + return true; } /** @@ -82,7 +82,8 @@ public function updateTeamMember(User $user, Organization $organization): bool return true; } - return $user->ownsTeam($organization); + // Note: since this policy is only used for jetstream endpoints, we can return false here + return false; } /** @@ -94,7 +95,8 @@ public function removeTeamMember(User $user, Organization $organization): bool return true; } - return $user->ownsTeam($organization); + // Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here + return false; } /** diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 9d643fef..abdebd94 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -23,6 +23,7 @@ use Brick\Money\Currency; use Brick\Money\ISOCurrencyProvider; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Inertia\Inertia; use Laravel\Fortify\Fortify; @@ -66,6 +67,9 @@ public function boot(): void 'newsletter_consent' => config('auth.newsletter_consent'), ]); }); + Gate::define('removeTeamMember', function (User $user, Organization $team) { + return false; + }); } /** diff --git a/app/Service/InvitationService.php b/app/Service/InvitationService.php new file mode 100644 index 00000000..6d8fa4c8 --- /dev/null +++ b/app/Service/InvitationService.php @@ -0,0 +1,43 @@ +whereBelongsTo($organization, 'organization') + ->whereRelation('user', 'email', '=', $email) + ->where('role', '!=', Role::Placeholder->value) + ->exists()) { + throw new UserIsAlreadyMemberOfOrganizationApiException(); + } + + InvitingTeamMember::dispatch($organization, $email, $role->value); + + $invitation = new OrganizationInvitation(); + $invitation->email = $email; + $invitation->role = $role->value; + $invitation->organization()->associate($organization); + $invitation->save(); + + Mail::to($email)->queue(new OrganizationInvitationMail($invitation)); + + return $invitation; + } +} diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 387cf745..dff4681b 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -11,6 +11,7 @@ use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; +use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserNotPlaceholderApiException; @@ -20,6 +21,7 @@ UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted', InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used', + UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization', UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project', EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.', CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization', diff --git a/lang/en/validation.php b/lang/en/validation.php index d2aad0bb..03a9deb0 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -206,6 +206,7 @@ 'tag_name_already_exists' => 'A tag with the same name already exists in the organization.', 'client_name_already_exists' => 'A client with the same name already exists in the organization.', 'task_name_already_exists' => 'A task with the same name already exists in the project.', + 'invitation_already_exists' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.', 'entities' => [ 'organization' => 'organization', diff --git a/resources/views/emails/team-invitation.blade.php b/resources/views/emails/organization-invitation.blade.php similarity index 75% rename from resources/views/emails/team-invitation.blade.php rename to resources/views/emails/organization-invitation.blade.php index e098258c..8ff09eb1 100644 --- a/resources/views/emails/team-invitation.blade.php +++ b/resources/views/emails/organization-invitation.blade.php @@ -1,5 +1,5 @@ @component('mail::message') -{{ __('You have been invited to join the :team team!', ['team' => $invitation->organization->name]) }} +{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }} @if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration())) {{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }} @@ -19,5 +19,5 @@ {{ __('Accept Invitation') }} @endcomponent -{{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }} +{{ __('If you did not expect to receive an invitation to this organization, you may discard this email.') }} @endcomponent diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index 7939bd8d..3c622445 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -11,14 +11,13 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\URL; -use Laravel\Jetstream\Mail\TeamInvitation; use Tests\TestCase; class InviteTeamMemberTest extends TestCase { use RefreshDatabase; - public function test_team_members_can_be_invited_to_team(): void + public function test_team_members_can_no_longer_be_invited_to_team_over_jetstream(): void { // Arrange Mail::fake(); @@ -31,54 +30,12 @@ public function test_team_members_can_be_invited_to_team(): void ]); // Assert - Mail::assertSent(TeamInvitation::class); - $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); - } - - public function test_team_member_can_not_be_invited_to_team_if_already_on_team(): void - { - // Arrange - Mail::fake(); - $user = User::factory()->withPersonalOrganization()->create(); - $existingUser = User::factory()->create(); - $user->currentTeam->users()->attach($existingUser, ['role' => 'admin']); - $this->actingAs($user); - - // Act - $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ - 'email' => $existingUser->email, - 'role' => 'admin', - ]); - - // Assert - $response->assertInvalid(['email'], 'addTeamMember'); - Mail::assertNotSent(TeamInvitation::class); - $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); - } - - public function test_team_member_can_be_invited_to_team_if_already_on_team_as_placeholder(): void - { - // Arrange - Mail::fake(); - $user = User::factory()->withPersonalOrganization()->create(); - $existingUser = User::factory()->create([ - 'is_placeholder' => true, - ]); - $user->currentTeam->users()->attach($existingUser, ['role' => Role::Employee->value]); - $this->actingAs($user); - - // Act - $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ - 'email' => $existingUser->email, - 'role' => Role::Employee->value, - ]); - - // Assert - Mail::assertSent(TeamInvitation::class); - $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); + $response->assertStatus(403); + $response->assertSee('Moved to API'); + Mail::assertNothingSent(); } - public function test_team_member_invitations_can_be_cancelled(): void + public function test_team_member_invitations_can_no_longer_be_cancelled_over_jetstream(): void { // Arrange Mail::fake(); @@ -94,7 +51,8 @@ public function test_team_member_invitations_can_be_cancelled(): void $response = $this->delete('/team-invitations/'.$invitation->id); // Assert - $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); + $response->assertStatus(403); + $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); } public function test_team_member_invitations_can_be_accepted(): void @@ -153,6 +111,7 @@ public function test_team_member_invitations_of_placeholder_can_be_accepted_and_ $response = $this->get($acceptUrl); // Assert + $response->assertRedirect(); $user->refresh(); $this->assertDatabaseMissing(User::class, ['id' => $placeholder->id]); $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); diff --git a/tests/Feature/LeaveTeamTest.php b/tests/Feature/LeaveTeamTest.php index 598a3151..07741446 100644 --- a/tests/Feature/LeaveTeamTest.php +++ b/tests/Feature/LeaveTeamTest.php @@ -12,8 +12,9 @@ class LeaveTeamTest extends TestCase { use RefreshDatabase; - public function test_users_can_leave_teams(): void + public function test_users_can_no_longer_leave_team_over_jetstream(): void { + // Arrange $user = User::factory()->withPersonalOrganization()->create(); $user->currentTeam->users()->attach( @@ -22,19 +23,11 @@ public function test_users_can_leave_teams(): void $this->actingAs($otherUser); + // Act $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); - $this->assertCount(1, $user->currentTeam->fresh()->users); - } - - public function test_team_owners_cant_leave_their_own_team(): void - { - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); - - $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$user->id); - - $response->assertSessionHasErrorsIn('removeTeamMember', ['team']); - - $this->assertNotNull($user->currentTeam->fresh()); + // Assert + $response->assertStatus(403); + $this->assertCount(2, $user->currentTeam->fresh()->users); } } diff --git a/tests/Feature/RemoveTeamMemberTest.php b/tests/Feature/RemoveTeamMemberTest.php index d2611334..49ecbeb4 100644 --- a/tests/Feature/RemoveTeamMemberTest.php +++ b/tests/Feature/RemoveTeamMemberTest.php @@ -12,31 +12,20 @@ class RemoveTeamMemberTest extends TestCase { use RefreshDatabase; - public function test_team_members_can_be_removed_from_teams(): void + public function test_team_members_can_no_longer_be_removed_from_teams_over_jetstream_endpoints(): void { + // Arrange $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); $user->currentTeam->users()->attach( $otherUser = User::factory()->create(), ['role' => 'admin'] ); - $response = $this->withoutExceptionHandling()->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); + // Act + $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); - $this->assertCount(1, $user->currentTeam->fresh()->users); - } - - public function test_only_team_owner_can_remove_team_members(): void - { - $user = User::factory()->withPersonalOrganization()->create(); - - $user->currentTeam->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'admin'] - ); - - $this->actingAs($otherUser); - - $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$user->id); - - $response->assertForbidden(); + // Assert + $response->assertStatus(403); + $response->assertSee('Moved to API'); } } diff --git a/tests/Feature/UpdateTeamMemberRoleTest.php b/tests/Feature/UpdateTeamMemberRoleTest.php index d6c574f6..7d3b6634 100644 --- a/tests/Feature/UpdateTeamMemberRoleTest.php +++ b/tests/Feature/UpdateTeamMemberRoleTest.php @@ -13,7 +13,7 @@ class UpdateTeamMemberRoleTest extends TestCase { use RefreshDatabase; - public function test_team_member_roles_can_be_updated(): void + public function test_team_member_roles_can_no_longer_be_updated_over_jetstream(): void { // Arrange $user = User::factory()->withPersonalOrganization()->create(); @@ -28,73 +28,8 @@ public function test_team_member_roles_can_be_updated(): void 'role' => Role::Employee->value, ]); - // Assert - $this->assertTrue($otherUser->fresh()->hasTeamRole( - $user->currentTeam->fresh(), Role::Employee->value, - )); - } - - public function test_team_member_roles_can_not_be_updated_to_placeholder(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - $this->actingAs($user); - - $user->currentTeam->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'admin'] - ); - - // Act - $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ - 'role' => 'placeholder', - ]); - - // Assert - $this->assertTrue($otherUser->fresh()->hasTeamRole( - $user->currentTeam->fresh(), 'admin' - )); - } - - public function test_team_member_roles_can_be_updated_to_owner_which_changes_ownership(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - $this->actingAs($user); - $otherUser = User::factory()->create(); - $user->currentTeam->users()->attach($otherUser, ['role' => 'admin']); - - // Act - $response = $this->withoutExceptionHandling()->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->getKey(), [ - 'role' => Role::Owner->value, - ]); - - // Assert - $this->assertTrue($otherUser->fresh()->hasTeamRole( - $user->currentTeam->fresh(), Role::Owner->value - )); - $this->assertSame($user->currentTeam->fresh()->user_id, $otherUser->getKey()); - } - - public function test_only_team_owner_can_update_team_member_roles(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - - $user->currentTeam->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'admin'] - ); - - $this->actingAs($otherUser); - - // Act - $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ - 'role' => Role::Employee->value, - ]); - // Assert $response->assertStatus(403); - $this->assertTrue($otherUser->fresh()->hasTeamRole( - $user->currentTeam->fresh(), 'admin' - )); + $response->assertSee('Moved to API'); } } diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index a58b8cd9..7bf229f7 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -6,9 +6,11 @@ use App\Enums\Role; use App\Http\Controllers\Api\V1\InvitationController; +use App\Mail\OrganizationInvitationMail; +use App\Models\Member; use App\Models\OrganizationInvitation; +use App\Models\User; use Illuminate\Support\Facades\Mail; -use Laravel\Jetstream\Mail\TeamInvitation; use Laravel\Passport\Passport; use PHPUnit\Framework\Attributes\UsesClass; @@ -107,6 +109,74 @@ public function test_store_fails_if_user_invites_with_role_placeholder(): void $response->assertJsonPath('message', 'The selected role is invalid.'); } + public function test_store_fails_if_user_invites_user_who_is_already_member_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:create', + ]); + Passport::actingAs($data->user); + $member = Member::factory()->forOrganization($data->organization)->create(); + + // Act + $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ + 'email' => $member->user->email, + 'role' => Role::Employee->value, + ]); + + // Assert + $response->assertStatus(400); + $response->assertJsonPath('message', 'User is already a member of the organization'); + } + + public function test_store_fails_if_user_invites_user_who_is_already_invited_to_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:create', + ]); + Passport::actingAs($data->user); + $invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); + + // Act + $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ + 'email' => $invitation->email, + 'role' => Role::Employee->value, + ]); + + // Assert + $response->assertInvalid([ + 'email' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.', + ]); + $response->assertStatus(422); + } + + public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:create', + ]); + $user = User::factory()->placeholder()->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ + 'email' => $user->email, + 'role' => Role::Employee->value, + ]); + + // Assert + $response->assertStatus(204); + $invitation = OrganizationInvitation::first(); + $this->assertNotNull($invitation); + $this->assertEquals($user->email, $invitation->email); + $this->assertEquals(Role::Employee->value, $invitation->role); + Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation)); + Mail::assertNothingSent(); + } + public function test_store_invites_user_to_organization(): void { // Arrange @@ -127,6 +197,8 @@ public function test_store_invites_user_to_organization(): void $this->assertNotNull($invitation); $this->assertEquals('test@asdf.at', $invitation->email); $this->assertEquals(Role::Employee->value, $invitation->role); + Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation)); + Mail::assertNothingSent(); } public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void @@ -182,9 +254,9 @@ public function test_resend_resends_invitation_email(): void ])); // Assert - Mail::assertSent(fn (TeamInvitation $mail): bool => $mail->invitation->is($invitation)); - Mail::assertNothingQueued(); $response->assertStatus(204); + Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation)); + Mail::assertNothingSent(); } public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index d25bd79b..2dff2624 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -280,12 +280,11 @@ public function test_invite_placeholder_succeeds_if_data_is_valid(): void { $data = $this->createUserWithPermission([ 'members:invite-placeholder', - 'invitations:create', ]); $user = User::factory()->create([ 'is_placeholder' => true, ]); - $member = Member::factory()->forUser($user)->forOrganization($data->organization)->create(); + $member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create(); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Mail/OrganizationInvitationMailTest.php b/tests/Unit/Mail/OrganizationInvitationMailTest.php new file mode 100644 index 00000000..27d5091e --- /dev/null +++ b/tests/Unit/Mail/OrganizationInvitationMailTest.php @@ -0,0 +1,31 @@ +create(); + $invitation = OrganizationInvitation::factory()->forOrganization($organization)->create(); + $mail = new OrganizationInvitationMail($invitation); + + // Act + $rendered = $mail->render(); + + // Assert + $this->assertStringContainsString('You have been invited to join the '.$invitation->organization->name.' organization', $rendered); + } +}