create([ 'email' => 'manager@test.com', 'password' => bcrypt('password123'), 'role' => 'manager', 'active' => true, ]); $response = $this->postJson('/api/auth/login', [ 'email' => 'manager@test.com', 'password' => 'password123', ]); return $response->json('access_token'); } protected function createPrerequisites(): array { $role = Role::factory()->create(); $teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); $projectStatus = ProjectStatus::factory()->create(['name' => 'Active', 'is_active' => true]); $projectType = ProjectType::factory()->create(); $project = Project::factory()->create([ 'status_id' => $projectStatus->id, 'type_id' => $projectType->id, ]); return [ 'role' => $role, 'team_member' => $teamMember, 'project_status' => $projectStatus, 'project_type' => $projectType, 'project' => $project, ]; } // ============================================================================ // INDEX TESTS - PAGINATION AND FILTERING // ============================================================================ public function test_index_returns_paginated_actuals_grid(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create allocation and actual Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 80, ]); Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'hours_logged' => 75, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02'); $response->assertStatus(200) ->assertJsonStructure([ 'data', 'meta' => [ 'current_page', 'per_page', 'total', 'last_page', 'filters', ], ]); $this->assertGreaterThan(0, $response->json('meta.total')); } public function test_index_filters_by_project(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create another project that should be filtered out $otherStatus = ProjectStatus::factory()->create(['name' => 'Other', 'is_active' => true]); $otherProject = Project::factory()->create([ 'status_id' => $otherStatus->id, 'type_id' => $prereq['project_type']->id, ]); // Create data for both projects Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); Allocation::factory()->create([ 'project_id' => $otherProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&project_ids[]=' . $prereq['project']->id); $response->assertStatus(200); // Only the filtered project should appear in the results $projectIds = collect($response->json('data'))->pluck('project_id')->unique(); $this->assertCount(1, $projectIds); $this->assertEquals($prereq['project']->id, $projectIds->first()); } public function test_index_filters_by_team_member(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create another team member $otherMember = TeamMember::factory()->create([ 'role_id' => $prereq['role']->id, 'active' => true, ]); // Create allocations for both members Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $otherMember->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&team_member_ids[]=' . $prereq['team_member']->id); $response->assertStatus(200); // Only the filtered team member should appear $memberIds = collect($response->json('data'))->pluck('team_member_id')->unique(); $this->assertCount(1, $memberIds); $this->assertEquals($prereq['team_member']->id, $memberIds->first()); } public function test_index_searches_by_project_code(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Update project with a unique code $prereq['project']->update(['code' => 'SEARCH-TEST-001']); // Create another project with different code $otherStatus = ProjectStatus::factory()->create(['name' => 'Other', 'is_active' => true]); $otherProject = Project::factory()->create([ 'code' => 'OTHER-CODE-999', 'status_id' => $otherStatus->id, 'type_id' => $prereq['project_type']->id, ]); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); Allocation::factory()->create([ 'project_id' => $otherProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&search=SEARCH-TEST'); $response->assertStatus(200); // Only matching project should appear $projectIds = collect($response->json('data'))->pluck('project_id')->unique(); $this->assertCount(1, $projectIds); $this->assertEquals($prereq['project']->id, $projectIds->first()); } public function test_index_requires_month_parameter(): void { $token = $this->loginAsManager(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals'); $response->assertStatus(422) ->assertJsonValidationErrors(['month']); } public function test_index_returns_empty_for_month_with_no_data(): void { $token = $this->loginAsManager(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2020-01'); $response->assertStatus(200) ->assertJson(['meta' => ['total' => 0]]); } // ============================================================================ // STORE TESTS - CREATE AND UPDATE ACTUALS // ============================================================================ public function test_store_creates_new_actual(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01', 'hours' => 40, 'notes' => 'Initial time entry', ]); $response->assertStatus(201) ->assertJsonStructure([ 'data' => [ 'id', 'project_id', 'team_member_id', 'month', 'hours_logged', 'notes', ], ]); $this->assertDatabaseHas('actuals', [ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'hours_logged' => 40, ]); // Verify the month was set correctly (SQLite stores dates with time component) $actual = Actual::where('project_id', $prereq['project']->id) ->where('team_member_id', $prereq['team_member']->id) ->first(); $this->assertNotNull($actual); $this->assertEquals('2026-01-01', $actual->month->format('Y-m-d')); } public function test_store_adds_hours_to_existing_actual(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create existing actual using Carbon for proper date handling $existing = Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => Carbon::createFromFormat('Y-m', '2026-01')->startOfMonth(), 'hours_logged' => 40, 'notes' => 'First entry', ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01', 'hours' => 20, 'notes' => 'Second entry', ]); // Should return 200 (update) not 201 (create) $response->assertStatus(200); // Hours should be accumulated $this->assertDatabaseHas('actuals', [ 'id' => $existing->id, 'hours_logged' => 60, // 40 + 20 ]); // Notes should be appended $actual = Actual::find($existing->id); $this->assertStringContainsString('First entry', $actual->notes); $this->assertStringContainsString('Second entry', $actual->notes); } public function test_store_rejects_future_month(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2030-12', // Far future 'hours' => 40, ]); $response->assertStatus(422) ->assertJsonPath('errors.month', function ($errors) { return in_array('Cannot log hours for future months', $errors); }); } public function test_store_rejects_completed_project(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create a "Done" project status $doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]); $doneProject = Project::factory()->create([ 'status_id' => $doneStatus->id, 'type_id' => $prereq['project_type']->id, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $doneProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01', 'hours' => 40, ]); $response->assertStatus(422) ->assertJsonPath('errors.project_id', function ($errors) { return in_array('Cannot log hours to completed projects', $errors); }); } public function test_store_rejects_cancelled_project(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create a "Cancelled" project status $cancelledStatus = ProjectStatus::factory()->create(['name' => 'Cancelled', 'is_active' => false]); $cancelledProject = Project::factory()->create([ 'status_id' => $cancelledStatus->id, 'type_id' => $prereq['project_type']->id, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $cancelledProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01', 'hours' => 40, ]); $response->assertStatus(422); } public function test_store_rejects_negative_hours(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01', 'hours' => -10, ]); $response->assertStatus(422) ->assertJsonValidationErrors(['hours']); } public function test_store_accepts_zero_hours(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01', 'hours' => 0, ]); $response->assertStatus(201); $this->assertDatabaseHas('actuals', [ 'project_id' => $prereq['project']->id, 'hours_logged' => 0, ]); } public function test_store_requires_all_fields(): void { $token = $this->loginAsManager(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', []); $response->assertStatus(422) ->assertJsonValidationErrors(['project_id', 'team_member_id', 'month', 'hours']); } public function test_store_validates_uuid_format(): void { $token = $this->loginAsManager(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->postJson('/api/actuals', [ 'project_id' => 'not-a-uuid', 'team_member_id' => 'also-not-a-uuid', 'month' => '2026-01', 'hours' => 40, ]); $response->assertStatus(422) ->assertJsonValidationErrors(['project_id', 'team_member_id']); } // ============================================================================ // UPDATE TESTS // ============================================================================ public function test_update_modifies_actual_hours(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $actual = Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01-01', 'hours_logged' => 40, 'notes' => 'Original notes', ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->putJson("/api/actuals/{$actual->id}", [ 'hours' => 50, 'notes' => 'Updated notes', ]); $response->assertStatus(200); $this->assertDatabaseHas('actuals', [ 'id' => $actual->id, 'hours_logged' => 50, ]); // Notes should be replaced, not appended $actual->refresh(); $this->assertEquals('Updated notes', $actual->notes); } public function test_update_rejects_nonexistent_actual(): void { $token = $this->loginAsManager(); $fakeId = '550e8400-e29b-41d4-a716-446655440000'; $response = $this->withHeader('Authorization', "Bearer {$token}") ->putJson("/api/actuals/{$fakeId}", [ 'hours' => 50, ]); $response->assertStatus(404); } public function test_update_rejects_negative_hours(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $actual = Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01-01', ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->putJson("/api/actuals/{$actual->id}", [ 'hours' => -10, ]); $response->assertStatus(422) ->assertJsonValidationErrors(['hours']); } // ============================================================================ // DESTROY TESTS // ============================================================================ public function test_destroy_deletes_actual(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); $actual = Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01-01', ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->deleteJson("/api/actuals/{$actual->id}"); $response->assertStatus(200) ->assertJson(['message' => 'Actual deleted successfully']); $this->assertDatabaseMissing('actuals', [ 'id' => $actual->id, ]); } public function test_destroy_returns_404_for_nonexistent_actual(): void { $token = $this->loginAsManager(); $fakeId = '550e8400-e29b-41d4-a716-446655440000'; $response = $this->withHeader('Authorization', "Bearer {$token}") ->deleteJson("/api/actuals/{$fakeId}"); $response->assertStatus(404); } // ============================================================================ // SHOW TESTS // ============================================================================ public function test_show_returns_actual_with_variance(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01-01', 'allocated_hours' => 100, ]); $actual = Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-01-01', 'hours_logged' => 80, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson("/api/actuals/{$actual->id}"); $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ 'id', 'project_id', 'team_member_id', 'month', 'hours_logged', 'variance' => [ 'allocated_hours', 'variance_percentage', 'variance_indicator', ], ], ]); } public function test_show_returns_404_for_nonexistent_actual(): void { $token = $this->loginAsManager(); $fakeId = '550e8400-e29b-41d4-a716-446655440000'; $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson("/api/actuals/{$fakeId}"); $response->assertStatus(404); } // ============================================================================ // VARIANCE CALCULATION TESTS IN API RESPONSE // ============================================================================ public function test_index_includes_correct_variance_calculation(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 100, ]); Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'hours_logged' => 80, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02'); $response->assertStatus(200); $data = collect($response->json('data')) ->firstWhere('project_id', $prereq['project']->id); // Variance = ((80 - 100) / 100) * 100 = -20% $this->assertEquals(-20.0, $data['variance_percentage']); $this->assertEquals('yellow', $data['variance_indicator']); } public function test_index_shows_infinity_for_actual_without_allocation(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Only actual, no allocation Actual::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'hours_logged' => 50, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02'); $response->assertStatus(200); $data = collect($response->json('data')) ->firstWhere('project_id', $prereq['project']->id); // When allocated is 0 but actual > 0, variance_display should be infinity $this->assertEquals('∞%', $data['variance_display'] ?? null); $this->assertEquals('red', $data['variance_indicator']); } // ============================================================================ // INACTIVE PROJECT HANDLING TESTS // ============================================================================ public function test_index_hides_inactive_projects_by_default(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create a "Done" project $doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]); $doneProject = Project::factory()->create([ 'status_id' => $doneStatus->id, 'type_id' => $prereq['project_type']->id, ]); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); Allocation::factory()->create([ 'project_id' => $doneProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02'); $projectIds = collect($response->json('data'))->pluck('project_id')->unique(); // Done project should not appear $this->assertNotContains($doneProject->id, $projectIds->toArray()); } public function test_index_shows_inactive_projects_when_flag_set(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create a "Done" project $doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]); $doneProject = Project::factory()->create([ 'status_id' => $doneStatus->id, 'type_id' => $prereq['project_type']->id, ]); Allocation::factory()->create([ 'project_id' => $doneProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&include_inactive=true'); $projectIds = collect($response->json('data'))->pluck('project_id')->unique(); // Done project should appear when include_inactive is true $this->assertContains($doneProject->id, $projectIds->toArray()); } public function test_index_marks_readonly_flag_for_completed_projects(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create a "Done" project $doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]); $doneProject = Project::factory()->create([ 'status_id' => $doneStatus->id, 'type_id' => $prereq['project_type']->id, ]); Allocation::factory()->create([ 'project_id' => $doneProject->id, 'team_member_id' => $prereq['team_member']->id, 'month' => '2026-02-01', 'allocated_hours' => 40, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&include_inactive=true'); $doneProjectData = collect($response->json('data')) ->firstWhere('project_id', $doneProject->id); $this->assertTrue($doneProjectData['is_readonly']); } // ============================================================================ // PAGINATION TESTS // ============================================================================ public function test_index_respects_per_page_parameter(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create multiple team members to generate more rows for ($i = 0; $i < 5; $i++) { $member = TeamMember::factory()->create([ 'role_id' => $prereq['role']->id, 'active' => true, ]); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $member->id, 'month' => '2026-02-01', ]); } $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&per_page=2'); $response->assertStatus(200) ->assertJsonPath('meta.per_page', 2); $this->assertCount(2, $response->json('data')); } public function test_index_respects_page_parameter(): void { $token = $this->loginAsManager(); $prereq = $this->createPrerequisites(); // Create multiple team members for ($i = 0; $i < 5; $i++) { $member = TeamMember::factory()->create([ 'role_id' => $prereq['role']->id, 'active' => true, ]); Allocation::factory()->create([ 'project_id' => $prereq['project']->id, 'team_member_id' => $member->id, 'month' => '2026-02-01', ]); } $response1 = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&per_page=2&page=1'); $response2 = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/actuals?month=2026-02&per_page=2&page=2'); $this->assertNotEquals( $response1->json('data.0.team_member_id'), $response2->json('data.0.team_member_id') ); } }