diff --git a/app/Enums/ProjectMemberRole.php b/app/Enums/ProjectMemberRole.php new file mode 100644 index 00000000..b858646c --- /dev/null +++ b/app/Enums/ProjectMemberRole.php @@ -0,0 +1,11 @@ +visibleByEmployee($user); + $projectsQuery->with([ + 'members' => function (HasMany $query): void { + /** @var Builder $query */ + $query->whereBelongsTo($this->user(), 'user'); + }, + ]); } $filterArchived = $request->getFilterArchived(); if ($filterArchived === 'true') { @@ -60,6 +69,14 @@ public function index(Organization $organization, ProjectIndexRequest $request): $projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); + foreach ($projects->items() as $project) { + if ($canViewAllProjects) { + $project->setAttribute('limited_visibility', false); + } else { + $project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager); + } + } + return new ProjectCollection($projects); } @@ -73,6 +90,26 @@ public function index(Organization $organization, ProjectIndexRequest $request): public function show(Organization $organization, Project $project): JsonResource { $this->checkPermission($organization, 'projects:view', $project); + $canViewAllProjects = $this->hasPermission($organization, 'projects:view:all'); + + $project->load([ + 'members' => function (HasMany $query): void { + /** @var Builder $query */ + $query->whereBelongsTo($this->user(), 'user'); + }, + ]); + + if (! $canViewAllProjects) { + if (! $project->is_public && $project->members->firstWhere('user_id', '=', $this->user()->id) === null) { + throw new AuthorizationException('No access to project'); + } + } + + if ($canViewAllProjects) { + $project->setAttribute('limited_visibility', false); + } else { + $project->setAttribute('limited_visibility', $project->members->firstWhere('user_id', $this->user()->id)?->role !== ProjectMemberRole::Manager); + } $project->load('organization'); @@ -101,6 +138,8 @@ public function store(Organization $organization, ProjectStoreRequest $request): $project->organization()->associate($organization); $project->save(); + $project->setAttribute('limited_visibility', false); + return new ProjectResource($project); } @@ -132,6 +171,8 @@ public function update(Organization $organization, Project $project, ProjectUpda $billableRateService->updateTimeEntriesBillableRateForProject($project); } + $project->setAttribute('limited_visibility', false); + return new ProjectResource($project); } diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index dfad9137..3ca92cdf 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -72,6 +72,7 @@ public function store(Organization $organization, Project $project, ProjectMembe } $projectMember = new ProjectMember; + $projectMember->role = $request->getRole(); $projectMember->billable_rate = $request->getBillableRate(); $projectMember->member()->associate($member); $projectMember->user()->associate($member->user); @@ -95,11 +96,17 @@ public function store(Organization $organization, Project $project, ProjectMembe public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource { $this->checkPermission($organization, 'project-members:update', projectMember: $projectMember); - $oldBillableRate = $projectMember->billable_rate; - $projectMember->billable_rate = $request->getBillableRate(); + $hasBillableRate = $request->has('billable_rate'); + if ($hasBillableRate) { + $oldBillableRate = $projectMember->billable_rate; + $projectMember->billable_rate = $request->getBillableRate(); + } + if ($request->getRole() !== null) { + $projectMember->role = $request->getRole(); + } $projectMember->save(); - if ($oldBillableRate !== $request->getBillableRate()) { + if ($hasBillableRate && $oldBillableRate !== $request->getBillableRate()) { $billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember); } diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php index de3e8cf4..bde79cea 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php @@ -4,11 +4,13 @@ namespace App\Http\Requests\V1\ProjectMember; +use App\Enums\ProjectMemberRole; use App\Models\Member; use App\Models\Organization; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -19,7 +21,7 @@ class ProjectMemberStoreRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -37,6 +39,11 @@ public function rules(): array 'integer', 'min:0', ], + 'role' => [ + 'required', + 'string', + Rule::enum(ProjectMemberRole::class), + ], ]; } @@ -46,4 +53,9 @@ public function getBillableRate(): ?int return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getRole(): ProjectMemberRole + { + return ProjectMemberRole::from($this->validated('role')); + } } diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php index 664a2d56..8ca9d748 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberUpdateRequest.php @@ -4,9 +4,11 @@ namespace App\Http\Requests\V1\ProjectMember; +use App\Enums\ProjectMemberRole; use App\Models\Organization; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; /** * @property Organization $organization Organization from model binding @@ -16,7 +18,7 @@ class ProjectMemberUpdateRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -26,6 +28,10 @@ public function rules(): array 'integer', 'min:0', ], + 'role' => [ + 'string', + Rule::enum(ProjectMemberRole::class), + ], ]; } @@ -33,6 +39,11 @@ public function getBillableRate(): ?int { $input = $this->input('billable_rate'); - return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; + return $input !== null && ((int) $input) !== 0 ? (int) $this->validated('billable_rate') : null; + } + + public function getRole(): ?ProjectMemberRole + { + return $this->has('role') ? ProjectMemberRole::from($this->validated('role')) : null; } } diff --git a/app/Http/Resources/V1/Project/ProjectResource.php b/app/Http/Resources/V1/Project/ProjectResource.php index e96f2f04..33e98696 100644 --- a/app/Http/Resources/V1/Project/ProjectResource.php +++ b/app/Http/Resources/V1/Project/ProjectResource.php @@ -20,6 +20,8 @@ class ProjectResource extends BaseResource */ public function toArray(Request $request): array { + $limitedVisibility = is_bool($this->resource->getAttributeValue('limited_visibility')) ? $this->resource->getAttributeValue('limited_visibility') : true; + return [ /** @var string $id ID of project */ 'id' => $this->resource->id, @@ -32,13 +34,15 @@ public function toArray(Request $request): array /** @var bool $is_archived Whether the client is archived */ 'is_archived' => $this->resource->is_archived, /** @var int|null $billable_rate Billable rate in cents per hour */ - 'billable_rate' => $this->resource->billable_rate, + 'billable_rate' => $limitedVisibility ? null : $this->resource->billable_rate, /** @var bool $is_billable Project time entries billable default */ 'is_billable' => $this->resource->is_billable, /** @var int|null $estimated_time Estimated time in seconds */ - 'estimated_time' => $this->resource->estimated_time, + 'estimated_time' => $limitedVisibility ? null : $this->resource->estimated_time, /** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ - 'spent_time' => $this->resource->spent_time, + 'spent_time' => $limitedVisibility ? null : $this->resource->spent_time, + /** @var bool $limited_visibility */ + 'limited_visibility' => $limitedVisibility, ]; } } diff --git a/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php b/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php index 8e986b25..c4352d39 100644 --- a/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php +++ b/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php @@ -29,6 +29,8 @@ public function toArray(Request $request): array 'member_id' => $this->resource->member_id, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, + /** @var string $role Role of the project member */ + 'role' => $this->resource->role->value, ]; } } diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index d5c61be1..323150f4 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\ProjectMemberRole; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use Database\Factories\ProjectMemberFactory; @@ -22,6 +23,7 @@ * @property string $user_id User ID (legacy) * @property Carbon|null $created_at * @property Carbon|null $updated_at + * @property ProjectMemberRole $role * @property-read Project $project * @property-read Member $member * @property-read User $user @@ -45,6 +47,7 @@ class ProjectMember extends Model implements AuditableContract */ protected $casts = [ 'billable_rate' => 'int', + 'role' => ProjectMemberRole::class, ]; /** diff --git a/database/factories/ProjectMemberFactory.php b/database/factories/ProjectMemberFactory.php index 1ab38ef5..bba85126 100644 --- a/database/factories/ProjectMemberFactory.php +++ b/database/factories/ProjectMemberFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\ProjectMemberRole; use App\Models\Member; use App\Models\Project; use App\Models\ProjectMember; @@ -24,12 +25,22 @@ public function definition(): array { return [ 'billable_rate' => $this->faker->numberBetween(10, 10000) * 100, + 'role' => ProjectMemberRole::Normal, 'project_id' => Project::factory(), 'user_id' => User::factory(), 'member_id' => Member::factory(), ]; } + public function role(ProjectMemberRole $role): self + { + return $this->state(function (array $attributes) use ($role) { + return [ + 'role' => $role, + ]; + }); + } + /** * @deprecated Use forMember instead */ diff --git a/database/migrations/2024_09_30_155934_add_role_to_project_members_table.php b/database/migrations/2024_09_30_155934_add_role_to_project_members_table.php new file mode 100644 index 00000000..5960df52 --- /dev/null +++ b/database/migrations/2024_09_30_155934_add_role_to_project_members_table.php @@ -0,0 +1,30 @@ +string('role')->default('normal'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('project_members', function (Blueprint $table): void { + $table->dropColumn('role'); + }); + } +}; diff --git a/resources/js/Components/Common/ProjectMember/ProjectMemberCreateModal.vue b/resources/js/Components/Common/ProjectMember/ProjectMemberCreateModal.vue index 962def03..d77f428b 100644 --- a/resources/js/Components/Common/ProjectMember/ProjectMemberCreateModal.vue +++ b/resources/js/Components/Common/ProjectMember/ProjectMemberCreateModal.vue @@ -12,6 +12,8 @@ import { useProjectMembersStore } from '@/utils/useProjectMembers'; import MemberCombobox from '@/Components/Common/Member/MemberCombobox.vue'; import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue'; import { getOrganizationCurrencyString } from '@/utils/money'; +import { InputLabel } from '@/packages/ui/src'; +import ProjectMemberRoleSelect from '@/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue'; const { createProjectMember } = useProjectMembersStore(); const show = defineModel('show', { default: false }); const saving = ref(false); @@ -24,6 +26,7 @@ const props = defineProps<{ const projectMember = ref({ member_id: '', billable_rate: null, + role: 'normal', }); async function submit() { @@ -32,6 +35,7 @@ async function submit() { projectMember.value = { member_id: '', billable_rate: null, + role: 'normal', }; } @@ -49,13 +53,17 @@ useFocus(projectNameInput, { initialValue: true }); diff --git a/resources/js/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue b/resources/js/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue new file mode 100644 index 00000000..f1c41f81 --- /dev/null +++ b/resources/js/Components/Common/ProjectMember/ProjectMemberRoleSelect.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/resources/js/Components/Common/ProjectMember/ProjectMemberTableRow.vue b/resources/js/Components/Common/ProjectMember/ProjectMemberTableRow.vue index c07a26c3..18f1e87d 100644 --- a/resources/js/Components/Common/ProjectMember/ProjectMemberTableRow.vue +++ b/resources/js/Components/Common/ProjectMember/ProjectMemberTableRow.vue @@ -57,7 +57,7 @@ const showEditModal = ref(false); }}
- {{ capitalizeFirstLetter(member?.role ?? '') }} + {{ capitalizeFirstLetter(projectMember?.role ?? '') }}
diff --git a/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue b/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue index ff3d2182..27912eea 100644 --- a/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue +++ b/resources/js/Components/Dashboard/RecentlyTrackedTasksCardEntry.vue @@ -29,6 +29,7 @@ async function startTaskTimer() { currentTimeEntry.value.project_id = props.project_id; currentTimeEntry.value.task_id = props.task_id; currentTimeEntry.value.start = getDayJsInstance().utc().format(); + currentTimeEntry.value.billable = project.value?.is_billable ?? false; await setActiveState(true); useCurrentTimeEntryStore().fetchCurrentTimeEntry(); } diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts index ea962f36..26c51a4a 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -70,6 +70,7 @@ const ProjectResource = z is_billable: z.boolean(), estimated_time: z.union([z.number(), z.null()]), spent_time: z.number().int(), + limited_visibility: z.boolean(), }) .passthrough(); const ProjectStoreRequest = z @@ -99,16 +100,22 @@ const ProjectMemberResource = z billable_rate: z.union([z.number(), z.null()]), member_id: z.string(), project_id: z.string(), + role: z.string(), }) .passthrough(); +const ProjectMemberRole = z.enum(['manager', 'normal']); const ProjectMemberStoreRequest = z .object({ member_id: z.string().uuid(), billable_rate: z.union([z.number(), z.null()]).optional(), + role: ProjectMemberRole, }) .passthrough(); const ProjectMemberUpdateRequest = z - .object({ billable_rate: z.union([z.number(), z.null()]) }) + .object({ + billable_rate: z.union([z.number(), z.null()]), + role: ProjectMemberRole, + }) .partial() .passthrough(); const TagResource = z @@ -257,6 +264,7 @@ export const schemas = { ProjectStoreRequest, ProjectUpdateRequest, ProjectMemberResource, + ProjectMemberRole, ProjectMemberStoreRequest, ProjectMemberUpdateRequest, TagResource, diff --git a/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue b/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue index 91d640c8..d41b39a8 100644 --- a/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue +++ b/resources/js/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue @@ -167,6 +167,7 @@ watchEffect(() => { tasks: [], estimated_time: null, spent_time: 0, + limited_visibility: false, }, ], }); diff --git a/resources/js/utils/useProjectMembers.ts b/resources/js/utils/useProjectMembers.ts index ac4614bf..39921313 100644 --- a/resources/js/utils/useProjectMembers.ts +++ b/resources/js/utils/useProjectMembers.ts @@ -9,6 +9,7 @@ import type { } from '@/packages/api/src'; import { getCurrentOrganizationId } from '@/utils/useUser'; import { useNotificationsStore } from '@/utils/notification'; +export type ProjectMemberRole = 'normal' | 'manager'; export const useProjectMembersStore = defineStore('project-members', () => { const projectMemberResponse = ref(null); diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 49c7f204..75322add 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\ProjectMemberRole; use App\Http\Controllers\Api\V1\ProjectController; use App\Models\Client; use App\Models\Organization; @@ -159,6 +160,54 @@ public function test_index_endpoint_returns_list_of_projects_of_organization_whi $response->assertJsonCount(4, 'data'); } + public function test_index_endpoint_returns_limited_visibility_flag_for_projects(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + 'projects:view:all', + ]); + Project::factory()->forOrganization($data->organization)->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.0.limited_visibility', false); + $response->assertJsonPath('data.1.limited_visibility', false); + } + + public function test_index_endpoint_returns_limit_visibility_flag_for_projects_for_user_with_restricted_permission(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + ]); + $project1 = Project::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(4), + ]); + ProjectMember::factory()->forProject($project1)->forMember($data->member)->role(ProjectMemberRole::Normal)->create(); + $project2 = Project::factory()->forOrganization($data->organization)->create([ + 'created_at' => now()->subDays(3), + ]); + ProjectMember::factory()->forProject($project2)->forMember($data->member)->role(ProjectMemberRole::Manager)->create(); + $project3 = Project::factory()->forOrganization($data->organization)->isPublic()->create([ + 'created_at' => now()->subDays(2), + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.0.limited_visibility', true); + $response->assertJsonPath('data.1.limited_visibility', false); + $response->assertJsonPath('data.2.limited_visibility', true); + } + public function test_show_endpoint_fails_if_user_is_not_part_of_project_organization(): void { // Arrange @@ -190,13 +239,51 @@ public function test_show_endpoint_fails_if_user_has_no_permission_to_view_proje $response->assertForbidden(); } - public function test_show_endpoint_returns_project(): void + public function test_show_endpoint_returns_project_if_user_has_access_to_all_projects_in_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + 'projects:view:all', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.id', $project->getKey()); + $response->assertJsonPath('data.limited_visibility', false); + } + + public function test_show_endpoint_returns_project_if_user_can_view_projects_and_project_is_public(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + ]); + $project = Project::factory()->forOrganization($data->organization)->isPublic()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.id', $project->getKey()); + $response->assertJsonPath('data.limited_visibility', true); + } + + public function test_show_endpoint_returns_project_if_user_can_view_projects_and_user_is_member_of_project(): void { // Arrange $data = $this->createUserWithPermission([ 'projects:view', ]); $project = Project::factory()->forOrganization($data->organization)->create(); + ProjectMember::factory()->forProject($project)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act @@ -205,6 +292,42 @@ public function test_show_endpoint_returns_project(): void // Assert $response->assertStatus(200); $response->assertJsonPath('data.id', $project->getKey()); + $response->assertJsonPath('data.limited_visibility', true); + } + + public function test_show_endpoint_returns_project_with_no_limited_visibility_is_user_is_project_manager(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + ProjectMember::factory()->forProject($project)->forMember($data->member)->role(ProjectMemberRole::Manager)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.id', $project->getKey()); + $response->assertJsonPath('data.limited_visibility', false); + } + + public function test_show_endpoint_fails_for_user_with_access_to_not_all_projects_for_project_that_is_private_and_the_user_is_not_a_member_of(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:view', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $project->getKey()])); + + // Assert + $response->assertStatus(403); } public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index 6f503fcc..55fa3dd3 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\ProjectMemberRole; use App\Http\Controllers\Api\V1\ProjectMemberController; use App\Models\Member; use App\Models\Organization; @@ -93,6 +94,7 @@ public function test_store_endpoint_fails_if_user_has_no_permission_to_add_membe // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, + 'role' => $projectMemberFake->role->value, 'member_id' => $member->getKey(), ]); @@ -118,6 +120,7 @@ public function test_store_endpoint_fails_if_given_project_does_not_belong_to_or // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, + 'role' => $projectMemberFake->role->value, 'member_id' => $member->getKey(), ]); @@ -165,6 +168,7 @@ public function test_store_endpoint_fails_if_user_is_a_placeholder(): void // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, + 'role' => $projectMemberFake->role->value, 'member_id' => $member->getKey(), ]); @@ -179,6 +183,7 @@ public function test_store_endpoint_fails_if_user_is_a_placeholder(): void 'billable_rate' => $projectMemberFake->billable_rate, 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), + 'role' => $projectMemberFake->role->value, ]); } @@ -197,6 +202,7 @@ public function test_store_endpoint_fails_if_user_is_already_member_of_project() // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, + 'role' => $projectMemberFake->role->value, 'member_id' => $member->getKey(), ]); @@ -211,6 +217,7 @@ public function test_store_endpoint_fails_if_user_is_already_member_of_project() 'billable_rate' => $projectMemberFake->billable_rate, 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), + 'role' => $projectMemberFake->role->value, ]); } @@ -236,6 +243,7 @@ public function test_store_endpoint_creates_new_project_member_and_updates_billa // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, + 'role' => $projectMemberFake->role->value, 'member_id' => $member->getKey(), ]); @@ -245,6 +253,36 @@ public function test_store_endpoint_creates_new_project_member_and_updates_billa 'billable_rate' => $projectMemberFake->billable_rate, 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), + 'role' => $projectMemberFake->role->value, + ]); + } + + public function test_store_endpoint_can_create_a_new_project_member_with_role_manager(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'project-members:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create(); + $this->assertBillableRateServiceIsUnused(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ + 'billable_rate' => null, + 'role' => ProjectMemberRole::Manager->value, + 'member_id' => $member->getKey(), + ]); + + // Assert + $response->assertStatus(201); + $this->assertDatabaseHas(ProjectMember::class, [ + 'billable_rate' => null, + 'member_id' => $member->getKey(), + 'project_id' => $project->getKey(), + 'role' => ProjectMemberRole::Manager->value, ]); } @@ -266,6 +304,7 @@ public function test_store_endpoint_creates_new_project_member_and_does_not_upda // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, + 'role' => $projectMemberFake->role->value, 'member_id' => $member->getKey(), ]); @@ -275,6 +314,7 @@ public function test_store_endpoint_creates_new_project_member_and_does_not_upda 'billable_rate' => null, 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), + 'role' => $projectMemberFake->role->value, ]); } @@ -374,6 +414,32 @@ public function test_update_endpoints_can_update_billable_rate_and_update_time_e ]); } + public function test_update_endpoint_can_update_role(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'project-members:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->role(ProjectMemberRole::Normal)->create(); + $this->assertBillableRateServiceIsUnused(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.project-members.update', [$data->organization->getKey(), $projectMember->getKey()]), [ + 'role' => ProjectMemberRole::Manager->value, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(ProjectMember::class, [ + 'id' => $projectMember->getKey(), + 'billable_rate' => $projectMember->billable_rate, + 'member_id' => $projectMember->member_id, + 'role' => ProjectMemberRole::Manager->value, + ]); + } + public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_members_organization(): void { // Arrange diff --git a/tests/Unit/Model/ClientModelTest.php b/tests/Unit/Model/ClientModelTest.php index b4b85f7e..e37170d0 100644 --- a/tests/Unit/Model/ClientModelTest.php +++ b/tests/Unit/Model/ClientModelTest.php @@ -44,7 +44,7 @@ public function test_it_has_many_projects(): void // Assert $this->assertNotNull($projectsRel); $this->assertCount(4, $projectsRel); - $this->assertTrue($projectsRel->first()->is($projects->first())); + $this->assertNotEquals($projectsOtherClient->pluck('id'), $projectsRel->pluck('id')); } public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void diff --git a/tests/Unit/Model/ProjectModelTest.php b/tests/Unit/Model/ProjectModelTest.php index c9056514..835c3b63 100644 --- a/tests/Unit/Model/ProjectModelTest.php +++ b/tests/Unit/Model/ProjectModelTest.php @@ -75,7 +75,7 @@ public function test_it_has_many_tasks(): void // Assert $this->assertNotNull($tasksRel); $this->assertCount(3, $tasksRel); - $this->assertTrue($tasksRel->first()->is($tasks->first())); + $this->assertEqualsIdsOfEloquentCollection($tasks->pluck('id')->toArray(), $tasksRel); } public function test_it_has_many_members(): void @@ -91,7 +91,7 @@ public function test_it_has_many_members(): void // Assert $this->assertNotNull($membersRel); $this->assertCount(3, $membersRel); - $this->assertTrue($membersRel->first()->is($members->first())); + $this->assertEqualsIdsOfEloquentCollection($members->pluck('id')->toArray(), $membersRel); } public function test_scope_visible_by_user_filters_so_that_only_public_projects_or_projects_where_the_user_is_member_are_shown(): void