Files
headroom/backend/tests/Feature/ReportTest.php
Santhosh Janardhanan 7fa5b9061c test(backend): add comprehensive tests for reporting and allocation
Add test coverage for:
- ReportTest: 9 tests for reporting API (payload, view types, filters, validation)
- ProjectMonthPlanTest: CRUD operations for monthly planning
- UntrackedAllocationTest: untracked allocation handling
- ReconciliationCalculatorTest: plan vs estimate reconciliation logic
- VarianceCalculatorTest: variance and status calculations

All tests passing (157 total).
2026-03-08 18:22:40 -04:00

368 lines
12 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Allocation;
use App\Models\Project;
use App\Models\ProjectMonthPlan;
use App\Models\Role;
use App\Models\TeamMember;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReportTest extends TestCase
{
use RefreshDatabase;
protected function loginAsManager(): string
{
$user = User::factory()->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']);
}
}