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).
This commit is contained in:
367
backend/tests/Feature/ReportTest.php
Normal file
367
backend/tests/Feature/ReportTest.php
Normal file
@@ -0,0 +1,367 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user