Added member and invitation endpoints

This commit is contained in:
Constantin Graf
2024-04-10 17:45:53 +02:00
parent 234fa06324
commit b67961cb07
48 changed files with 1186 additions and 106 deletions

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Membership;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -14,13 +15,20 @@ class CreateTeamTest extends TestCase
public function test_teams_can_be_created(): void
{
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$this->actingAs($user);
// Act
$response = $this->post('/teams', [
'name' => 'Test Organization',
]);
// Assert
$newOrganization = $user->fresh()->ownedTeams()->latest('id')->first();
$this->assertCount(2, $user->fresh()->ownedTeams);
$this->assertEquals('Test Organization', $user->fresh()->ownedTeams()->latest('id')->first()->name);
$this->assertEquals('Test Organization', $newOrganization->name);
$member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail();
$this->assertSame('owner', $member->role);
}
}

View File

@@ -118,7 +118,7 @@ class InviteTeamMemberTest extends TestCase
// Assert
$this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);
$user->refresh();
$this->assertCount(1, $user->organizations);
$this->assertCount(2, $user->organizations);
$this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id'));
}
@@ -126,9 +126,7 @@ class InviteTeamMemberTest extends TestCase
{
// Arrange
Mail::fake();
$placeholder = User::factory()->withPersonalOrganization()->create([
'is_placeholder' => true,
]);
$placeholder = User::factory()->withPersonalOrganization()->placeholder()->create();
$owner = User::factory()->withPersonalOrganization()->create();
$owner->currentTeam->users()->attach($placeholder, ['role' => 'employee']);
@@ -154,12 +152,11 @@ class InviteTeamMemberTest extends TestCase
// Assert
$user->refresh();
$placeholder->refresh();
$this->assertDatabaseMissing(User::class, ['id' => $placeholder->id]);
$this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);
$this->assertCount(1, $user->organizations);
$this->assertCount(2, $user->organizations);
$this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id'));
$this->assertCount(5, $user->timeEntries);
$this->assertCount(0, $placeholder->timeEntries);
}
public function test_team_member_accept_fails_if_user_with_that_email_does_not_exist(): void
@@ -185,6 +182,6 @@ class InviteTeamMemberTest extends TestCase
// Assert
$this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations);
$user->refresh();
$this->assertCount(0, $user->organizations);
$this->assertCount(1, $user->organizations);
}
}

View File

@@ -24,7 +24,7 @@ class LeaveTeamTest extends TestCase
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);
$this->assertCount(0, $user->currentTeam->fresh()->users);
$this->assertCount(1, $user->currentTeam->fresh()->users);
}
public function test_team_owners_cant_leave_their_own_team(): void

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Membership;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -52,7 +53,12 @@ class RegistrationTest extends TestCase
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$user = User::where('email', 'test@example.com')->firstOrFail();
$this->assertSame('Test User', $user->name);
$this->assertSame('UTC', $user->timezone);
$organization = $user->organizations()->firstOrFail();
$this->assertSame(true, $organization->personal_team);
$member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail();
$this->assertSame('owner', $member->role);
}
public function test_new_users_can_register_and_frontend_can_send_timezone_for_user(): void

View File

@@ -20,9 +20,9 @@ class RemoveTeamMemberTest extends TestCase
$otherUser = User::factory()->create(), ['role' => 'admin']
);
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);
$response = $this->withoutExceptionHandling()->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);
$this->assertCount(0, $user->currentTeam->fresh()->users);
$this->assertCount(1, $user->currentTeam->fresh()->users);
}
public function test_only_team_owner_can_remove_team_members(): void

View File

@@ -14,23 +14,69 @@ class UpdateTeamMemberRoleTest extends TestCase
public function test_team_member_roles_can_be_updated(): void
{
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
// 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' => 'employee',
]);
// Assert
$this->assertTrue($otherUser->fresh()->hasTeamRole(
$user->currentTeam->fresh(), 'employee'
));
}
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' => 'owner',
]);
// Assert
$this->assertTrue($otherUser->fresh()->hasTeamRole(
$user->currentTeam->fresh(), 'owner'
));
$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(
@@ -39,10 +85,13 @@ class UpdateTeamMemberRoleTest extends TestCase
$this->actingAs($otherUser);
// Act
$response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [
'role' => 'employee',
]);
// Assert
$response->assertStatus(403);
$this->assertTrue($otherUser->fresh()->hasTeamRole(
$user->currentTeam->fresh(), 'admin'
));

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -16,7 +17,7 @@ class ApiEndpointTestAbstract extends TestCase
/**
* @param array<string> $permissions
* @return object{user: User, organization: Organization}
* @return object{user: User, organization: Organization, member: Membership}
*/
protected function createUserWithPermission(array $permissions, bool $isOwner = false): object
{
@@ -28,13 +29,14 @@ class ApiEndpointTestAbstract extends TestCase
} else {
$organization = Organization::factory()->create();
}
$organization->users()->attach($user, [
$membership = Membership::factory()->forUser($user)->forOrganization($organization)->create([
'role' => 'custom-test',
]);
return (object) [
'user' => $user,
'organization' => $organization,
'member' => $membership,
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\OrganizationInvitation;
use Laravel\Passport\Passport;
class InvitationEndpointTest extends ApiEndpointTestAbstract
{
public function test_index_fails_if_user_has_no_permission_to_view_invitations(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.invitations.index', $data->organization->id));
// Assert
$response->assertStatus(403);
}
public function test_index_returns_invitations_of_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:view',
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey()));
// Assert
$response->assertStatus(200);
}
public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
'email' => 'test@mail.test',
'role' => 'employee',
]);
// Assert
$response->assertStatus(403);
}
public function test_store_invites_user_to_organization(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
'email' => 'test@asdf.at',
'role' => 'employee',
]);
// Assert
$response->assertStatus(204);
$invitation = OrganizationInvitation::first();
$this->assertNotNull($invitation);
$this->assertEquals('test@asdf.at', $invitation->email);
$this->assertEquals('employee', $invitation->role);
}
}

View File

@@ -4,12 +4,27 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\User;
use Laravel\Passport\Passport;
class MemberEndpointTest extends ApiEndpointTestAbstract
{
public function test_index_fails_if_user_has_no_permission_to_view_members(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.members.index', $data->organization->id));
// Assert
$response->assertStatus(403);
}
public function test_index_returns_members_of_organization(): void
{
// Arrange
@@ -19,12 +34,72 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.members.index', $data->organization->id));
$response = $this->getJson(route('api.v1.members.index', $data->organization->getKey()));
// Assert
$response->assertStatus(200);
}
public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->member->getKey()]), [
'billable_rate' => 10001,
'role' => 'employee',
]);
// Assert
$response->assertStatus(403);
}
public function test_update_member_fails_if_member_is_not_part_of_org(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
$otherData = $this->createUserWithPermission([
'members:update',
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $otherData->member->getKey()]), [
'billable_rate' => 10001,
'role' => 'employee',
]);
// Assert
$response->assertStatus(403);
}
public function test_update_member_succeeds_if_data_is_valid(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:update',
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.members.update', [$data->organization->id, $data->member]), [
'billable_rate' => 10001,
'role' => 'employee',
]);
// Assert
$response->assertStatus(200);
$member = $data->member;
$member->refresh();
$this->assertSame(10001, $member->billable_rate);
$this->assertSame('employee', $member->role);
}
public function test_invite_placeholder_succeeds_if_data_is_valid(): void
{
$data = $this->createUserWithPermission([
@@ -33,15 +108,13 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$user = User::factory()->create([
'is_placeholder' => true,
]);
$data->organization->users()->attach($user, [
'role' => 'placeholder',
]);
$member = Membership::factory()->forUser($user)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->id,
'user' => $user->id,
'organization' => $data->organization->getKey(),
'member' => $member->getKey(),
]));
// Assert
@@ -49,6 +122,56 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(204);
}
public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void
{
// Arrange
$data = $this->createUserWithPermission([
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(403);
}
public function test_destroy_member_fails_if_member_is_not_part_of_org(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
$otherData = $this->createUserWithPermission([
'members:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $otherData->member->getKey()]));
// Assert
$response->assertStatus(403);
}
public function test_destroy_member_succeeds_if_data_is_valid(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:delete',
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()]));
// Assert
$response->assertStatus(204);
$this->assertDatabaseMissing(Membership::class, [
'id' => $data->member->getKey(),
]);
}
public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void
{
// Arrange
@@ -57,11 +180,14 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$user = User::factory()->create([
'is_placeholder' => true,
]);
$data->organization->users()->attach($user);
$member = Membership::factory()->forUser($user)->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->id,
'member' => $member->id,
]));
// Assert
$response->assertForbidden();
@@ -77,11 +203,14 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$user = User::factory()->create([
'is_placeholder' => true,
]);
$otherOrganization->users()->attach($user);
$member = Membership::factory()->forUser($user)->forOrganization($otherOrganization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id]));
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->id,
'member' => $member->id,
]));
// Assert
$response->assertForbidden();
@@ -96,7 +225,10 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id]));
$response = $this->postJson(route('api.v1.members.invite-placeholder', [
'organization' => $data->organization->id,
'member' => $data->member->id,
]));
// Assert
$response->assertStatus(400);

View File

@@ -142,6 +142,69 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
$response->assertInvalid(['user_id']);
}
public function test_store_endpoint_fails_if_user_is_a_placeholder(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$projectMemberFake = ProjectMember::factory()->make();
$user = User::factory()->attachToOrganization($data->organization)->placeholder()->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
'billable_rate' => $projectMemberFake->billable_rate,
'user_id' => $user->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'inactive_user_can_not_be_used',
'message' => 'Inactive user can not be used',
]);
$this->assertDatabaseMissing(ProjectMember::class, [
'billable_rate' => $projectMemberFake->billable_rate,
'user_id' => $user->getKey(),
'project_id' => $project->getKey(),
]);
}
public function test_store_endpoint_fails_if_user_is_already_member_of_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:create',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$projectMemberFake = ProjectMember::factory()->make();
$user = User::factory()->attachToOrganization($data->organization)->create();
ProjectMember::factory()->forProject($project)->forUser($user)->create();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [
'billable_rate' => $projectMemberFake->billable_rate,
'user_id' => $user->getKey(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'user_is_already_member_of_project',
'message' => 'User is already a member of the project',
]);
$this->assertDatabaseMissing(ProjectMember::class, [
'billable_rate' => $projectMemberFake->billable_rate,
'user_id' => $user->getKey(),
'project_id' => $project->getKey(),
]);
}
public function test_store_endpoint_creates_new_project_member(): void
{
// Arrange

View File

@@ -11,6 +11,21 @@ use Laravel\Passport\Passport;
class TaskEndpointTest extends ApiEndpointTestAbstract
{
public function test_non_valid_uuid_for_organization_id_fails(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:view',
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.tasks.index', ['invalid-uuid']));
// Assert
$response->assertStatus(404);
}
public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tasks(): void
{
// Arrange

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\User;
@@ -40,4 +41,22 @@ class ProjectMemberModelTest extends ModelTestAbstract
$this->assertNotNull($userRel);
$this->assertTrue($userRel->is($user));
}
public function test_scope_where_belongs_to_organization_filters_project_members_to_only_retrieve_project_members_that_belong_to_a_project_of_the_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$projectNotBelongingToOrganization = Project::factory()->forOrganization($otherOrganization)->create();
$projectMember = ProjectMember::factory()->forProject($project)->create();
$projectMemberNotBelongingToOrganization = ProjectMember::factory()->for($projectNotBelongingToOrganization)->create();
// Act
$projectMembers = ProjectMember::whereBelongsToOrganization($organization)->get();
// Assert
$this->assertCount(1, $projectMembers);
$this->assertTrue($projectMembers->first()->is($projectMember));
}
}

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\Role;
use App\Models\Membership;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\UserService;
@@ -19,13 +23,17 @@ class UserServiceTest extends TestCase
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$otherUser = User::factory()->create();
$fromUser = User::factory()->create();
$toUser = User::factory()->create();
TimeEntry::factory()->forOrganization($organization)->forUser($otherUser)->createMany(3);
TimeEntry::factory()->forOrganization($organization)->forUser($fromUser)->createMany(3);
ProjectMember::factory()->forProject($project)->forUser($otherUser)->create();
ProjectMember::factory()->forProject($project)->forUser($fromUser)->create();
// Act
/** @var UserService $userService */
$userService = app(UserService::class);
$userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
@@ -33,5 +41,32 @@ class UserServiceTest extends TestCase
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
}
public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void
{
// Arrange
$organization = Organization::factory()->create();
$newOwner = User::factory()->create();
$oldOwner = User::factory()->create();
$organization->users()->attach($oldOwner->getKey(), [
'role' => Role::Owner->value,
]);
$organization->users()->attach($newOwner->getKey(), [
'role' => Role::Admin->value,
]);
// Act
/** @var UserService $userService */
$userService = app(UserService::class);
$userService->changeOwnership($organization, $newOwner);
// Assert
$this->assertSame($newOwner->id, $organization->refresh()->user_id);
$this->assertSame(Role::Owner->value, Membership::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role);
$this->assertSame(Role::Admin->value, Membership::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role);
}
}