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'); } // Task 4.1: Reporting payload includes lifecycle total, month plan, month execution, and variances public function test_reporting_payload_includes_all_required_data(): void { $token = $this->loginAsManager(); // Create project with approved estimate $project = Project::factory()->create([ 'approved_estimate' => 3000, ]); // Create month plans ProjectMonthPlan::create([ 'project_id' => $project->id, 'month' => '2026-01-01', 'planned_hours' => 1200, ]); ProjectMonthPlan::create([ 'project_id' => $project->id, 'month' => '2026-02-01', 'planned_hours' => 1400, ]); // Create team member $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); // Create allocation Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $member->id, 'month' => '2026-01-01', 'allocated_hours' => 1300, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-02-28'); $response->assertStatus(200); // Verify top-level structure $response->assertJsonStructure([ 'period' => ['start', 'end'], 'view_type', 'projects' => [ '*' => [ 'id', 'code', 'title', 'approved_estimate', 'lifecycle_status', 'plan_sum', 'period_planned', 'period_allocated', 'period_variance', 'period_status', 'months' => [ '*' => [ 'month', 'planned_hours', 'is_blank', 'allocated_hours', 'variance', 'status', ], ], ], ], 'members' => [ '*' => [ 'id', 'name', 'period_allocated', 'projects' => [ '*' => [ 'project_id', 'project_code', 'project_title', 'total_hours', ], ], ], ], 'aggregates' => [ 'total_planned', 'total_allocated', 'total_variance', 'status', ], ]); // Verify data values $data = $response->json(); $projectData = collect($data['projects'])->firstWhere('id', $project->id); $this->assertEquals(3000.0, $projectData['approved_estimate']); $this->assertEquals(2600.0, $projectData['plan_sum']); // 1200 + 1400 $this->assertEquals(2600.0, $projectData['period_planned']); $this->assertEquals(1300.0, $projectData['period_allocated']); $this->assertEquals(-1300.0, $projectData['period_variance']); $this->assertEquals('UNDER', $projectData['period_status']); $this->assertEquals('UNDER', $projectData['lifecycle_status']); } // Task 4.2: Historical/current/future month slices are consistent public function test_view_type_did_for_past_months(): void { $token = $this->loginAsManager(); Project::factory()->create(); $now = Carbon::now(); $pastStart = $now->copy()->subMonths(3)->startOfMonth(); $pastEnd = $now->copy()->subMonth()->endOfMonth(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson("/api/reports/allocations?start_date={$pastStart->format('Y-m-d')}&end_date={$pastEnd->format('Y-m-d')}"); $response->assertStatus(200); $response->assertJsonPath('view_type', 'did'); } public function test_view_type_is_for_current_month(): void { $token = $this->loginAsManager(); Project::factory()->create(); $now = Carbon::now(); $currentStart = $now->copy()->startOfMonth(); $currentEnd = $now->copy()->endOfMonth(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson("/api/reports/allocations?start_date={$currentStart->format('Y-m-d')}&end_date={$currentEnd->format('Y-m-d')}"); $response->assertStatus(200); $response->assertJsonPath('view_type', 'is'); } public function test_view_type_will_for_future_months(): void { $token = $this->loginAsManager(); Project::factory()->create(); $now = Carbon::now(); $futureStart = $now->copy()->addMonth()->startOfMonth(); $futureEnd = $now->copy()->addMonths(3)->endOfMonth(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson("/api/reports/allocations?start_date={$futureStart->format('Y-m-d')}&end_date={$futureEnd->format('Y-m-d')}"); $response->assertStatus(200); $response->assertJsonPath('view_type', 'will'); } // Task 4.4: Distinguish blank plan vs explicit zero public function test_distinguishes_blank_plan_from_explicit_zero(): void { $token = $this->loginAsManager(); $project = Project::factory()->create(); $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); // January: explicit plan of 100 ProjectMonthPlan::create([ 'project_id' => $project->id, 'month' => '2026-01-01', 'planned_hours' => 100, ]); // February: explicit plan of 0 ProjectMonthPlan::create([ 'project_id' => $project->id, 'month' => '2026-02-01', 'planned_hours' => 0, ]); // March: blank (no plan entry) Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $member->id, 'month' => '2026-01-01', 'allocated_hours' => 80, ]); Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $member->id, 'month' => '2026-02-01', 'allocated_hours' => 50, ]); Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $member->id, 'month' => '2026-03-01', 'allocated_hours' => 30, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-03-31'); $response->assertStatus(200); $data = $response->json(); $projectData = collect($data['projects'])->firstWhere('id', $project->id); $january = collect($projectData['months'])->firstWhere('month', '2026-01'); $february = collect($projectData['months'])->firstWhere('month', '2026-02'); $march = collect($projectData['months'])->firstWhere('month', '2026-03'); // January: explicit 100 $this->assertEquals(100.0, $january['planned_hours']); $this->assertFalse($january['is_blank']); // February: explicit 0 $this->assertEquals(0.0, $february['planned_hours']); $this->assertFalse($february['is_blank']); // March: blank $this->assertNull($march['planned_hours']); $this->assertTrue($march['is_blank']); } public function test_filters_by_project_ids(): void { $token = $this->loginAsManager(); $project1 = Project::factory()->create(); $project2 = Project::factory()->create(); $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); ProjectMonthPlan::create([ 'project_id' => $project1->id, 'month' => '2026-01-01', 'planned_hours' => 100, ]); ProjectMonthPlan::create([ 'project_id' => $project2->id, 'month' => '2026-01-01', 'planned_hours' => 200, ]); Allocation::factory()->create([ 'project_id' => $project1->id, 'team_member_id' => $member->id, 'month' => '2026-01-01', 'allocated_hours' => 80, ]); Allocation::factory()->create([ 'project_id' => $project2->id, 'team_member_id' => $member->id, 'month' => '2026-01-01', 'allocated_hours' => 150, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson("/api/reports/allocations?start_date=2026-01-01&end_date=2026-01-31&project_ids[]={$project1->id}"); $response->assertStatus(200); $data = $response->json(); $this->assertCount(1, $data['projects']); $this->assertEquals($project1->id, $data['projects'][0]['id']); $this->assertEquals(100.0, $data['aggregates']['total_planned']); } public function test_includes_untracked_allocations(): void { $token = $this->loginAsManager(); $project = Project::factory()->create(); $role = Role::factory()->create(); $member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); ProjectMonthPlan::create([ 'project_id' => $project->id, 'month' => '2026-01-01', 'planned_hours' => 200, ]); // Tracked allocation Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => $member->id, 'month' => '2026-01-01', 'allocated_hours' => 100, ]); // Untracked allocation Allocation::factory()->create([ 'project_id' => $project->id, 'team_member_id' => null, 'month' => '2026-01-01', 'allocated_hours' => 50, ]); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-01-31'); $response->assertStatus(200); $data = $response->json(); // Project should have 150 total (100 tracked + 50 untracked) $projectData = collect($data['projects'])->firstWhere('id', $project->id); $this->assertEquals(150.0, $projectData['period_allocated']); // Members should include untracked row $untrackedRow = collect($data['members'])->firstWhere('name', 'Untracked'); $this->assertNotNull($untrackedRow); $this->assertEquals(50.0, $untrackedRow['period_allocated']); } public function test_validates_required_date_parameters(): void { $token = $this->loginAsManager(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/reports/allocations'); $response->assertStatus(422); $response->assertJsonValidationErrors(['start_date', 'end_date']); } public function test_validates_end_date_after_start_date(): void { $token = $this->loginAsManager(); $response = $this->withHeader('Authorization', "Bearer {$token}") ->getJson('/api/reports/allocations?start_date=2026-03-01&end_date=2026-01-01'); $response->assertStatus(422); $response->assertJsonValidationErrors(['end_date']); } }