'Super Admin', 'guard_name' => 'web']); $this->admin = User::factory()->create([ 'user_type' => 'admin', 'status' => 'active', ]); $this->admin->assignRole('Super Admin'); $permission = \Spatie\Permission\Models\Permission::firstOrCreate([ 'name' => 'projects.access', 'guard_name' => 'web', ]); $this->admin->givePermissionTo($permission); } private function validProjectPayload(array $overrides = []): array { return array_merge([ 'name' => 'Default Test Project', 'client_name' => 'Acme Corporation', 'location' => 'Makati City, Philippines', 'description' => 'Default project description and scope.', 'contract_value' => 5000000, 'start_date' => now()->addDays(5)->format('Y-m-d'), 'target_end_date' => now()->addMonths(6)->format('Y-m-d'), 'contract_duration' => 180, 'project_type' => 'standard', 'classifications' => ['Building and Industrial Plant'], 'pm_id' => $this->admin->id, 'is_unprofitable' => false, ], $overrides); } // ─── STEP 1: Project Create/Store ──────────────────────────────────────── #[Test] public function guest_cannot_access_project_create(): void { $this->get(route('projects.create'))->assertRedirect(route('login')); } #[Test] public function admin_can_view_project_create_page(): void { $this->actingAs($this->admin) ->get(route('projects.create')) ->assertOk() ->assertInertia(fn ($page) => $page->component('ProjectManagement::Projects/Create', false)); } #[Test] public function project_store_fails_without_name(): void { $this->actingAs($this->admin) ->post(route('projects.store'), ['name' => '', 'project_type' => 'standard']) ->assertSessionHasErrors('name'); } #[Test] public function project_store_fails_without_project_type(): void { $this->actingAs($this->admin) ->post(route('projects.store'), ['name' => 'Test', 'project_type' => '']) ->assertSessionHasErrors('project_type'); } #[Test] public function project_store_fails_with_invalid_project_type(): void { $this->actingAs($this->admin) ->post(route('projects.store'), ['name' => 'Test', 'project_type' => 'invalid']) ->assertSessionHasErrors('project_type'); } #[Test] public function project_store_fails_when_end_date_before_start_date(): void { $this->actingAs($this->admin) ->post(route('projects.store'), $this->validProjectPayload([ 'start_date' => '2025-12-31', 'target_end_date' => '2025-01-01', ])) ->assertSessionHasErrors('target_end_date'); } #[Test] public function project_store_succeeds_with_minimum_fields(): void { $this->actingAs($this->admin) ->post(route('projects.store'), $this->validProjectPayload([ 'name' => 'Minimal Project', ])); $project = Project::where('name', 'Minimal Project')->first(); $this->assertNotNull($project); $this->assertEquals(2, $project->current_wizard_step); } #[Test] public function project_store_auto_generates_code(): void { $this->actingAs($this->admin) ->post(route('projects.store'), $this->validProjectPayload([ 'name' => 'Code Project', ])); $project = Project::where('name', 'Code Project')->first(); $this->assertStringStartsWith('PRJ-' . now()->year . '-', $project->code); } #[Test] public function project_codes_are_unique_and_sequential(): void { $this->actingAs($this->admin)->post(route('projects.store'), $this->validProjectPayload(['name' => 'P1'])); $this->actingAs($this->admin)->post(route('projects.store'), $this->validProjectPayload(['name' => 'P2'])); $c1 = Project::where('name', 'P1')->value('code'); $c2 = Project::where('name', 'P2')->value('code'); $this->assertNotEquals($c1, $c2); $this->assertStringStartsWith('PRJ-', $c1); } #[Test] public function project_store_saves_all_optional_fields(): void { $this->actingAs($this->admin)->post(route('projects.store'), $this->validProjectPayload([ 'name' => 'Full Project', 'client_name' => 'Acme Corp', 'description' => 'Detailed Project Scope', 'location' => 'Manila', 'contract_value' => 5000000, 'contract_duration' => 365, 'start_date' => '2025-01-01', 'target_end_date' => '2025-12-31', 'project_type' => 'standard', 'is_unprofitable' => false, ])); $project = Project::where('name', 'Full Project')->first(); $this->assertEquals('Acme Corp', $project->client_name); $this->assertEquals(5000000, $project->contract_value); } // ─── STEP 2: Milestones & Tasks ────────────────────────────────────────── #[Test] public function save_tasks_fails_with_empty_milestone_name(): void { $project = Project::factory()->create(['current_wizard_step' => 2]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), [ 'milestones' => [['name' => '', 'weight_percentage' => 50]], ]) ->assertSessionHasErrors('milestones.0.name'); } #[Test] public function save_tasks_fails_with_milestone_weight_over_100(): void { $project = Project::factory()->create(['current_wizard_step' => 2]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), [ 'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 150]], ]) ->assertSessionHasErrors('milestones.0.weight_percentage'); } #[Test] public function save_tasks_fails_with_empty_task_name(): void { $project = Project::factory()->create(['current_wizard_step' => 2]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), [ 'tasks' => [['name' => '']], ]) ->assertSessionHasErrors('tasks.0.name'); } #[Test] public function save_tasks_with_empty_arrays_advances_step(): void { $project = Project::factory()->create(['current_wizard_step' => 2]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]) ->assertRedirect(route('projects.wizard', [$project, 'step' => 3])); $this->assertEquals(3, $project->fresh()->current_wizard_step); } #[Test] public function save_tasks_does_not_downgrade_already_advanced_step(): void { $project = Project::factory()->create(['current_wizard_step' => 5]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]); $this->assertEquals(5, $project->fresh()->current_wizard_step); } #[Test] public function save_tasks_prepopulates_8_default_milestones(): void { $project = Project::factory()->create([ 'current_wizard_step' => 2, 'start_date' => '2025-01-01', 'contract_duration' => 365, ]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), ['prepopulate_milestones' => true]); $this->assertEquals(8, $project->milestones()->count()); } #[Test] public function save_tasks_creates_milestones_and_tasks(): void { $project = Project::factory()->create(['current_wizard_step' => 2]); $this->actingAs($this->admin)->post(route('projects.wizard.tasks', $project), [ 'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 100, 'target_date' => now()->addMonths(2)->format('Y-m-d')]], 'tasks' => [['name' => 'Task A', 'description' => 'First task description', 'milestone_ulid' => 'temp-1']], ]); $this->assertEquals(1, $project->milestones()->count()); $this->assertEquals(1, $project->tasks()->count()); } // ─── STEP 3: Material Estimates ────────────────────────────────────────── #[Test] public function save_estimates_fails_without_material_ulid(): void { $project = Project::factory()->create(['current_wizard_step' => 3]); $this->actingAs($this->admin) ->post(route('projects.wizard.estimates', $project), [ 'estimates' => [['material_ulid' => '', 'estimated_qty' => 10, 'unit_cost' => 100]], ]) ->assertSessionHasErrors('estimates.0.material_ulid'); } #[Test] public function save_estimates_fails_with_negative_quantity(): void { $project = Project::factory()->create(['current_wizard_step' => 3]); $material = Material::factory()->create(['type' => 'single', 'status' => 'active']); $this->actingAs($this->admin) ->post(route('projects.wizard.estimates', $project), [ 'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => -5, 'unit_cost' => 100]], ]) ->assertSessionHasErrors('estimates.0.estimated_qty'); } #[Test] public function save_estimates_with_empty_array_advances_step(): void { $project = Project::factory()->create(['current_wizard_step' => 3]); $this->actingAs($this->admin) ->post(route('projects.wizard.estimates', $project), ['estimates' => []]) ->assertRedirect(route('projects.wizard', [$project, 'step' => 4])); $this->assertEquals(4, $project->fresh()->current_wizard_step); } #[Test] public function save_estimates_creates_material_estimate_record(): void { $project = Project::factory()->create(['current_wizard_step' => 3]); $material = Material::factory()->create(['type' => 'single', 'status' => 'active']); $this->actingAs($this->admin)->post(route('projects.wizard.estimates', $project), [ 'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => 10, 'unit_cost' => 250.00]], ]); $this->assertEquals(1, $project->materialsEstimates()->count()); $est = $project->materialsEstimates()->first(); $this->assertEquals(10, $est->estimated_qty); $this->assertEquals(250.00, $est->unit_cost); } // ─── STEP 4: Labor Allocation ──────────────────────────────────────────── #[Test] public function save_labor_fails_without_task_ulid(): void { $project = Project::factory()->create(['current_wizard_step' => 4]); $this->actingAs($this->admin) ->post(route('projects.wizard.labor', $project), [ 'labor' => [['task_ulid' => '', 'labor_ulid' => 'x', 'estimated_hours' => 8]], ]) ->assertSessionHasErrors('labor.0.task_ulid'); } #[Test] public function save_labor_fails_with_negative_hours(): void { $project = Project::factory()->create(['current_wizard_step' => 4]); $task = Task::factory()->create(['project_id' => $project->id]); $labor = Labor::factory()->create(['status' => 'active']); $this->actingAs($this->admin) ->post(route('projects.wizard.labor', $project), [ 'labor' => [['task_ulid' => $task->ulid, 'labor_ulid' => $labor->ulid, 'estimated_hours' => -2]], ]) ->assertSessionHasErrors('labor.0.estimated_hours'); } #[Test] public function save_labor_with_empty_array_advances_step(): void { $project = Project::factory()->create(['current_wizard_step' => 4]); $this->actingAs($this->admin) ->post(route('projects.wizard.labor', $project), ['labor' => []]) ->assertRedirect(route('projects.wizard', [$project, 'step' => 5])); $this->assertEquals(5, $project->fresh()->current_wizard_step); } // ─── STEP 5: Equipment Allocation ──────────────────────────────────────── #[Test] public function save_equipment_fails_without_equipment_ulid(): void { $project = Project::factory()->create(['current_wizard_step' => 5]); $this->actingAs($this->admin) ->post(route('projects.wizard.equipment', $project), [ 'equipment' => [['task_ulid' => 'x', 'equipment_ulid' => '', 'estimated_hours' => 4]], ]) ->assertSessionHasErrors('equipment.0.equipment_ulid'); } #[Test] public function save_equipment_with_empty_array_advances_step(): void { $project = Project::factory()->create(['current_wizard_step' => 5]); $this->actingAs($this->admin) ->post(route('projects.wizard.equipment', $project), ['equipment' => []]) ->assertRedirect(route('projects.wizard', [$project, 'step' => 6])); $this->assertEquals(6, $project->fresh()->current_wizard_step); } // ─── STEP 6: Submit for Approval ───────────────────────────────────────── #[Test] public function submit_without_explicit_approvers_autoselects_approver(): void { $project = Project::factory()->create(['current_wizard_step' => 6]); $this->actingAs($this->admin) ->post(route('projects.wizard.submit', $project), ['approver_ids' => []]) ->assertRedirect(route('projects.show', $project)); } #[Test] public function submit_advances_step_to_7(): void { $project = Project::factory()->create(['current_wizard_step' => 6]); $approver = User::factory()->create(['user_type' => 'admin', 'status' => 'active']); $this->actingAs($this->admin) ->post(route('projects.wizard.submit', $project), ['approver_ids' => [$approver->ulid]]); $this->assertEquals(7, $project->fresh()->current_wizard_step); } // ─── Locked Project Guards (step >= 8) ─────────────────────────────────── #[Test] public function locked_project_blocks_task_save(): void { $project = Project::factory()->create(['current_wizard_step' => 8]); $this->actingAs($this->admin) ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]) ->assertSessionHas('error'); } #[Test] public function locked_project_blocks_estimate_save(): void { $project = Project::factory()->create(['current_wizard_step' => 8]); $this->actingAs($this->admin) ->post(route('projects.wizard.estimates', $project), ['estimates' => []]) ->assertSessionHas('error'); } #[Test] public function locked_project_blocks_labor_save(): void { $project = Project::factory()->create(['current_wizard_step' => 8]); $this->actingAs($this->admin) ->post(route('projects.wizard.labor', $project), ['labor' => []]) ->assertSessionHas('error'); } #[Test] public function locked_project_blocks_equipment_save(): void { $project = Project::factory()->create(['current_wizard_step' => 8]); $this->actingAs($this->admin) ->post(route('projects.wizard.equipment', $project), ['equipment' => []]) ->assertSessionHas('error'); } #[Test] public function locked_project_blocks_update(): void { $project = Project::factory()->create(['current_wizard_step' => 8]); $this->actingAs($this->admin) ->put(route('projects.update', $project), ['name' => 'Changed', 'project_type' => 'standard']) ->assertSessionHas('error'); $this->assertNotEquals('Changed', $project->fresh()->name); } // ─── Project Index Visibility ───────────────────────────────────────────── #[Test] public function index_only_shows_completed_wizard_projects(): void { $active = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); Project::factory()->create(['current_wizard_step' => 3]); $this->actingAs($this->admin) ->get(route('projects.index')) ->assertOk() ->assertInertia(fn ($page) => $page ->has('projects.data', 1) ->where('projects.data.0.id', $active->id) ); } #[Test] public function drafts_shows_incomplete_wizard_projects(): void { $draft = Project::factory()->create(['current_wizard_step' => 3]); $this->actingAs($this->admin) ->get(route('projects.index')) ->assertOk() ->assertInertia(fn ($page) => $page ->has('drafts', 1) ->where('drafts.0.id', $draft->id) ); } #[Test] public function admin_can_discard_a_draft_project(): void { $draft = Project::factory()->create(['current_wizard_step' => 3]); $this->actingAs($this->admin) ->delete(route('projects.discard', $draft)) ->assertRedirect(route('projects.index')) ->assertSessionHas('success'); $this->assertSoftDeleted('projects', ['id' => $draft->id]); } #[Test] public function completed_wizard_projects_cannot_be_discarded(): void { $project = Project::factory()->create(['current_wizard_step' => 7]); $this->actingAs($this->admin) ->delete(route('projects.discard', $project)) ->assertSessionHas('error'); $this->assertDatabaseHas('projects', ['id' => $project->id, 'deleted_at' => null]); } // ─── Status Transitions ────────────────────────────────────────────────── #[Test] public function cannot_start_project_without_pm(): void { $project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']); $this->actingAs($this->admin) ->patch(route('projects.transition', $project), ['status' => 'in_progress']) ->assertSessionHas('error'); } #[Test] public function can_start_project_with_pm_assigned(): void { $project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']); $pm = User::factory()->create(['user_type' => 'admin']); $project->personnel()->attach($pm->id, ['role' => 'pm']); $this->actingAs($this->admin) ->patch(route('projects.transition', $project), ['status' => 'in_progress']); $this->assertEquals('in_progress', $project->fresh()->status->value); } #[Test] public function cannot_complete_parent_with_active_children(): void { $parent = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'in_progress']); $pm = User::factory()->create(); $parent->personnel()->attach($pm->id, ['role' => 'pm']); Project::factory()->create([ 'parent_project_id' => $parent->id, 'current_wizard_step' => 8, 'status' => 'in_progress', ]); $this->actingAs($this->admin) ->patch(route('projects.transition', $parent), ['status' => 'completed']) ->assertSessionHas('error'); $this->assertEquals('in_progress', $parent->fresh()->status->value); } // ─── Update Project ─────────────────────────────────────────────────────── #[Test] public function update_succeeds_on_unlocked_project(): void { $project = Project::factory()->create(['current_wizard_step' => 5, 'status' => 'planning']); $this->actingAs($this->admin) ->put(route('projects.update', $project), $this->validProjectPayload(['name' => 'New Name'])); $this->assertEquals('New Name', $project->fresh()->name); } // ─── Personnel Management ───────────────────────────────────────────────── #[Test] public function add_personnel_fails_with_nonexistent_user(): void { $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); $this->actingAs($this->admin) ->post(route('projects.personnel.add', $project), ['user_id' => 'bad-ulid', 'role' => 'pm']) ->assertSessionHas('error'); } #[Test] public function add_personnel_fails_with_invalid_role(): void { $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); $user = User::factory()->create(); $this->actingAs($this->admin) ->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'bad_role']) ->assertSessionHasErrors('role'); } #[Test] public function add_personnel_fails_on_duplicate(): void { $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); $user = User::factory()->create(); $project->personnel()->attach($user->id, ['role' => 'member']); $this->actingAs($this->admin) ->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'member']) ->assertSessionHas('error'); } #[Test] public function can_remove_personnel(): void { $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']); $user = User::factory()->create(); $project->personnel()->attach($user->id, ['role' => 'member']); $this->actingAs($this->admin) ->delete(route('projects.personnel.remove', [$project, $user])); $this->assertFalse($project->personnel()->where('user_id', $user->id)->exists()); } #[Test] public function cannot_skip_ahead_wizard_steps(): void { $project = Project::factory()->create(['current_wizard_step' => 2]); $this->actingAs($this->admin) ->get(route('projects.wizard', [$project, 'step' => 5])) ->assertOk() ->assertInertia(fn ($page) => $page->where('step', 2)); } #[Test] public function contractor_project_manager_appears_in_project_wizard_roster_and_can_be_assigned(): void { $cpmRole = Role::firstOrCreate(['name' => 'Contractor Project Manager', 'guard_name' => 'web']); $contractor = \Modules\ContractorManagement\Models\Contractor::create([ 'company_name' => 'Titan Prime Subcontractor Corp', 'contact_person' => 'Juan Dela Cruz', 'email' => 'juan@titanprime.test', 'phone' => '09171234567', 'status' => 'active', ]); $contractorPM = User::factory()->create([ 'name' => 'Juan Contractor PM', 'email' => 'juan.cpm@titanprime.test', 'user_type' => 'contractor', 'contractor_id' => $contractor->id, 'status' => 'active', ]); $contractorPM->assignRole($cpmRole); // 1. Verify Contractor PM appears in projects.create employees list $this->actingAs($this->admin) ->get(route('projects.create')) ->assertOk() ->assertInertia(fn ($page) => $page->where('employees', fn ($emps) => collect($emps)->contains('email', 'juan.cpm@titanprime.test') ) ); // 2. Verify Contractor PM can be assigned as project PM on project store $storeResponse = $this->actingAs($this->admin) ->post(route('projects.store'), [ 'name' => 'Titan Construction Tower A', 'client_name' => 'Titan Prime Holdings', 'location' => 'Bonifacio Global City, Taguig', 'description' => 'Titan tower high-rise development', 'project_type' => 'standard', 'contract_value' => 50000000, 'start_date' => now()->addDays(5)->format('Y-m-d'), 'target_end_date' => now()->addMonths(6)->format('Y-m-d'), 'contract_duration' => 180, 'classifications' => ['Building and Industrial Plant'], 'pm_id' => $contractorPM->ulid, ]); $storeResponse->assertRedirect(); $newProject = Project::where('name', 'Titan Construction Tower A')->firstOrFail(); $this->assertTrue($newProject->personnel()->where('user_id', $contractorPM->id)->wherePivot('role', 'pm')->exists()); // 3. Verify Contractor PM appears in Step 4 wizard roster and can be assigned as team member $wizardStep4Resp = $this->actingAs($this->admin) ->get(route('projects.wizard', [$newProject, 'step' => 4])); $wizardStep4Resp->assertOk() ->assertInertia(fn ($page) => $page->where('employees', fn ($emps) => collect($emps)->contains('email', 'juan.cpm@titanprime.test') ) ); // 4. Save Step 4 manpower with Contractor PM in team roster $saveLaborResp = $this->actingAs($this->admin) ->post(route('projects.wizard.labor', $newProject), [ 'user_ulids' => [$contractorPM->ulid], 'labor' => [], ]); $saveLaborResp->assertRedirect(route('projects.wizard', [$newProject, 'step' => 5])); $this->assertTrue($newProject->personnel()->where('user_id', $contractorPM->id)->exists()); // Verify Contractor was automatically attached to project_contractor $this->assertTrue($newProject->contractors()->where('contractors.id', $contractor->id)->exists()); } #[Test] public function test_wizard_roster_auto_links_contractors_and_enforces_single_project_manager_with_multiple_site_operations(): void { $pmRole = Role::firstOrCreate(['name' => 'Project Manager', 'guard_name' => 'web']); $siteTechRole = Role::firstOrCreate(['name' => 'Site Technical', 'guard_name' => 'web']); $supervisorRole = Role::firstOrCreate(['name' => 'Construction Supervisor', 'guard_name' => 'web']); $mainContractor = \Modules\ContractorManagement\Models\Contractor::create([ 'company_name' => 'Main Structural Corp', 'contact_person' => 'Main Person', 'email' => 'main@structural.test', 'phone' => '09181111111', 'address' => 'Pasig City, Metro Manila', 'tax_id' => '001-234-567-000', 'payment_terms' => 'net_30', 'status' => 'active', ]); $subcontractor = \Modules\ContractorManagement\Models\Contractor::create([ 'company_name' => 'Subcon Electrical Services', 'contact_person' => 'Sub Person', 'email' => 'sub@electrical.test', 'phone' => '09182222222', 'address' => 'Quezon City, Metro Manila', 'tax_id' => '002-345-678-000', 'payment_terms' => 'net_30', 'status' => 'active', ]); $firstPM = User::factory()->create([ 'name' => 'Alice First PM', 'user_type' => 'contractor', 'contractor_id' => $mainContractor->id, 'status' => 'active', ]); $firstPM->assignRole($pmRole); $secondPM = User::factory()->create([ 'name' => 'Bob Second PM', 'user_type' => 'employee', 'status' => 'active', ]); $secondPM->assignRole($pmRole); $siteTechUser = User::factory()->create([ 'name' => 'Charlie Site Tech', 'user_type' => 'contractor', 'contractor_id' => $subcontractor->id, 'status' => 'active', ]); $siteTechUser->assignRole($siteTechRole); $supervisorUser = User::factory()->create([ 'name' => 'David Supervisor', 'user_type' => 'contractor', 'contractor_id' => $subcontractor->id, 'status' => 'active', ]); $supervisorUser->assignRole($supervisorRole); // 1. Create project with First PM $this->actingAs($this->admin)->post(route('projects.store'), [ 'name' => 'Skyline Heights Commercial Center', 'client_name' => 'Skyline Development Group', 'location' => 'Makati City, Metro Manila', 'description' => 'Commercial center high-rise structure', 'project_type' => 'standard', 'contract_value' => 35000000, 'start_date' => now()->addDays(5)->format('Y-m-d'), 'target_end_date' => now()->addMonths(6)->format('Y-m-d'), 'contract_duration' => 180, 'classifications' => ['Building and Industrial Plant'], 'pm_id' => $firstPM->ulid, ]); $project = Project::where('name', 'Skyline Heights Commercial Center')->firstOrFail(); $this->assertEquals(1, $project->personnel()->wherePivot('role', 'pm')->count()); $this->assertTrue($project->contractors()->where('contractors.id', $mainContractor->id)->exists()); // 2. Change PM in Wizard Step 1 details -> Strictly 1 PM remains $this->actingAs($this->admin)->post(route('projects.wizard.save-details', $project), [ 'name' => 'Skyline Heights Commercial Center', 'client_name' => 'Skyline Development Group', 'location' => 'Makati City, Metro Manila', 'description' => 'Commercial center high-rise structure', 'project_type' => 'standard', 'contract_value' => 35000000, 'start_date' => now()->addDays(5)->format('Y-m-d'), 'target_end_date' => now()->addMonths(6)->format('Y-m-d'), 'contract_duration' => 180, 'classifications' => ['Building and Industrial Plant'], 'pm_id' => $secondPM->ulid, ]); $project->refresh(); $this->assertEquals(1, $project->personnel()->wherePivot('role', 'pm')->count()); $this->assertEquals($secondPM->id, $project->personnel()->wherePivot('role', 'pm')->first()->id); // 3. In Wizard Step 4, assign multiple site operations personnel from Subcontractor $this->actingAs($this->admin)->post(route('projects.wizard.labor', $project), [ 'user_ulids' => [$siteTechUser->ulid, $supervisorUser->ulid], 'labor' => [], ]); $project->refresh(); // 4. Verify exactly 1 PM and multiple site operations $this->assertEquals(1, $project->personnel()->wherePivot('role', 'pm')->count()); $this->assertEquals(2, $project->personnel()->wherePivot('role', 'member')->count()); // 5. Verify Subcontractor was automatically linked to the project in project_contractor $this->assertTrue($project->contractors()->where('contractors.id', $subcontractor->id)->exists()); } }