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).
368 lines
12 KiB
PHP
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']);
|
|
}
|
|
}
|