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:
134
backend/tests/Feature/ProjectMonthPlanTest.php
Normal file
134
backend/tests/Feature/ProjectMonthPlanTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectMonthPlanTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function loginAsManager()
|
||||
{
|
||||
$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');
|
||||
}
|
||||
|
||||
public function test_bulk_update_creates_new_plan_records(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson('/api/project-month-plans/bulk', [
|
||||
'year' => 2026,
|
||||
'items' => [
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01',
|
||||
'planned_hours' => 100,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_validation_rejects_invalid_data(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson('/api/project-month-plans/bulk', [
|
||||
'year' => 2026,
|
||||
'items' => [
|
||||
[
|
||||
'project_id' => 'invalid-uuid',
|
||||
'month' => '2026-01',
|
||||
'planned_hours' => 100,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_index_returns_existing_plan_for_month(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-02-01',
|
||||
'planned_hours' => 50,
|
||||
]);
|
||||
|
||||
$projectData = $this->fetchProjectPlan($token, $project);
|
||||
|
||||
$this->assertNotNull($projectData['months']['2026-02-01']);
|
||||
}
|
||||
|
||||
public function test_index_returns_null_for_missing_month(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$projectData = $this->fetchProjectPlan($token, $project);
|
||||
|
||||
$this->assertNull($projectData['months']['2026-03-01']);
|
||||
}
|
||||
|
||||
public function test_bulk_update_roundtrip_populates_month(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson('/api/project-month-plans/bulk', [
|
||||
'year' => 2026,
|
||||
'items' => [
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-02',
|
||||
'planned_hours' => 72,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertStatus(200);
|
||||
|
||||
$projectData = $this->fetchProjectPlan($token, $project);
|
||||
|
||||
$this->assertNotNull($projectData['months']['2026-02-01']);
|
||||
$this->assertEquals(72, $projectData['plan_sum']);
|
||||
}
|
||||
|
||||
private function fetchProjectPlan(string $token, Project $project): array
|
||||
{
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/project-month-plans?year=2026');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$projectData = collect($response->json('data'))->firstWhere('project_id', $project->id);
|
||||
|
||||
$this->assertNotNull($projectData);
|
||||
|
||||
return $projectData;
|
||||
}
|
||||
}
|
||||
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']);
|
||||
}
|
||||
}
|
||||
113
backend/tests/Feature/UntrackedAllocationTest.php
Normal file
113
backend/tests/Feature/UntrackedAllocationTest.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UntrackedAllocationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function loginAsManager()
|
||||
{
|
||||
$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');
|
||||
}
|
||||
|
||||
public function test_create_allocation_accepts_null_team_member_id(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseHas('allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_bulk_create_accepts_mixed_tracked_and_untracked(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations/bulk', [
|
||||
'allocations' => [
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
],
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 30,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertDatabaseCount('allocations', 2);
|
||||
}
|
||||
|
||||
public function test_partial_bulk_persists_valid_rows(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$project = Project::factory()->create();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations/bulk', [
|
||||
'allocations' => [
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
],
|
||||
[
|
||||
'project_id' => 'invalid-uuid',
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 20,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('summary.created', 1);
|
||||
$response->assertJsonPath('summary.failed', 1);
|
||||
$this->assertDatabaseCount('allocations', 1);
|
||||
}
|
||||
}
|
||||
180
backend/tests/Unit/ReconciliationCalculatorTest.php
Normal file
180
backend/tests/Unit/ReconciliationCalculatorTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use App\Services\ReconciliationCalculator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ReconciliationCalculatorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private ReconciliationCalculator $calculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->calculator = new ReconciliationCalculator;
|
||||
}
|
||||
|
||||
// 1.1: Unit test - reconciliation status OVER
|
||||
public function test_returns_over_when_plan_sum_exceeds_approved_estimate(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 1000,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 600,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-02-01',
|
||||
'planned_hours' => 600,
|
||||
]);
|
||||
|
||||
$status = $this->calculator->calculateStatus($project);
|
||||
|
||||
$this->assertEquals('OVER', $status);
|
||||
}
|
||||
|
||||
// 1.2: Unit test - reconciliation status UNDER
|
||||
public function test_returns_under_when_plan_sum_is_less_than_approved_estimate(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 1000,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 400,
|
||||
]);
|
||||
|
||||
$status = $this->calculator->calculateStatus($project);
|
||||
|
||||
$this->assertEquals('UNDER', $status);
|
||||
}
|
||||
|
||||
// 1.3: Unit test - reconciliation status MATCH
|
||||
public function test_returns_match_when_plan_sum_equals_approved_estimate(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 1000,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 500,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-02-01',
|
||||
'planned_hours' => 500,
|
||||
]);
|
||||
|
||||
$status = $this->calculator->calculateStatus($project);
|
||||
|
||||
$this->assertEquals('MATCH', $status);
|
||||
}
|
||||
|
||||
// Additional test: decimal-safe MATCH
|
||||
public function test_returns_match_with_decimal_precision(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 100.50,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 50.25,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-02-01',
|
||||
'planned_hours' => 50.25,
|
||||
]);
|
||||
|
||||
$status = $this->calculator->calculateStatus($project);
|
||||
|
||||
$this->assertEquals('MATCH', $status);
|
||||
}
|
||||
|
||||
// Test: blank/null planned_hours are excluded from sum
|
||||
public function test_excludes_null_planned_hours_from_sum(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 500,
|
||||
]);
|
||||
|
||||
// Create plan with null planned_hours (blank cell)
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => null,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-02-01',
|
||||
'planned_hours' => 500,
|
||||
]);
|
||||
|
||||
$status = $this->calculator->calculateStatus($project);
|
||||
$planSum = $this->calculator->calculatePlanSum($project);
|
||||
|
||||
$this->assertEquals(500, $planSum);
|
||||
$this->assertEquals('MATCH', $status);
|
||||
}
|
||||
|
||||
// Test: no approved estimate returns UNDER
|
||||
public function test_returns_under_when_no_approved_estimate(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 0,
|
||||
]);
|
||||
|
||||
$status = $this->calculator->calculateStatus($project);
|
||||
|
||||
$this->assertEquals('UNDER', $status);
|
||||
}
|
||||
|
||||
// Test: calculateForProjects returns array with correct structure
|
||||
public function test_calculate_for_projects_returns_correct_structure(): void
|
||||
{
|
||||
$project1 = Project::factory()->create(['approved_estimate' => 1000]);
|
||||
$project2 = Project::factory()->create(['approved_estimate' => 500]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project1->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 1000,
|
||||
]);
|
||||
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project2->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 300,
|
||||
]);
|
||||
|
||||
$projects = Project::whereIn('id', [$project1->id, $project2->id])->get();
|
||||
$results = $this->calculator->calculateForProjects($projects, 2026);
|
||||
|
||||
$this->assertArrayHasKey($project1->id, $results);
|
||||
$this->assertArrayHasKey($project2->id, $results);
|
||||
$this->assertEquals(1000, $results[$project1->id]['plan_sum']);
|
||||
$this->assertEquals('MATCH', $results[$project1->id]['status']);
|
||||
$this->assertEquals(300, $results[$project2->id]['plan_sum']);
|
||||
$this->assertEquals('UNDER', $results[$project2->id]['status']);
|
||||
}
|
||||
}
|
||||
160
backend/tests/Unit/VarianceCalculatorTest.php
Normal file
160
backend/tests/Unit/VarianceCalculatorTest.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\VarianceCalculator;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class VarianceCalculatorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private VarianceCalculator $calculator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->calculator = new VarianceCalculator;
|
||||
}
|
||||
|
||||
// 2.1: Unit test - row variance uses selected month planned value
|
||||
public function test_row_variance_uses_planned_month_value(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// Create month plan for Jan 2026
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 100,
|
||||
]);
|
||||
|
||||
// Create allocation totaling 120 hours using factory for proper date handling
|
||||
$allocation = Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null, // untracked
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 120,
|
||||
]);
|
||||
|
||||
$result = $this->calculator->calculateRowVariance($project->id, '2026-01');
|
||||
|
||||
$this->assertEquals(120, $result['allocated_total']);
|
||||
$this->assertEquals(100, $result['planned_month']);
|
||||
$this->assertEquals(20, $result['variance']);
|
||||
$this->assertEquals('OVER', $result['status']);
|
||||
}
|
||||
|
||||
// 2.2: Unit test - blank month plan treated as zero for row variance
|
||||
public function test_blank_month_plan_treated_as_zero(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// No month plan created (blank)
|
||||
|
||||
// Create allocation of 50 hours
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 50,
|
||||
]);
|
||||
|
||||
$result = $this->calculator->calculateRowVariance($project->id, '2026-01');
|
||||
|
||||
// Blank plan = 0, so variance = 50 - 0 = 50
|
||||
$this->assertEquals(50, $result['allocated_total']);
|
||||
$this->assertEquals(0, $result['planned_month']);
|
||||
$this->assertEquals(50, $result['variance']);
|
||||
$this->assertEquals('OVER', $result['status']);
|
||||
}
|
||||
|
||||
// 2.3: Unit test - column variance uses member month capacity
|
||||
public function test_column_variance_uses_member_capacity(): void
|
||||
{
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
// Create allocation for member
|
||||
Allocation::factory()->create([
|
||||
'project_id' => Project::factory()->create()->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 120,
|
||||
]);
|
||||
|
||||
// Mock capacity service - we'll test basic logic here
|
||||
$mockCapacityService = $this->createMock(\App\Services\CapacityService::class);
|
||||
$mockCapacityService->method('calculateIndividualCapacity')
|
||||
->willReturn(['hours' => 160]);
|
||||
|
||||
$result = $this->calculator->calculateColumnVariance($teamMember->id, '2026-01', $mockCapacityService);
|
||||
|
||||
$this->assertEquals(120, $result['allocated']);
|
||||
$this->assertEquals(160, $result['capacity']);
|
||||
$this->assertEquals(-40, $result['variance']);
|
||||
$this->assertEquals('UNDER', $result['status']);
|
||||
}
|
||||
|
||||
// Additional test: MATCH status when variance is zero
|
||||
public function test_determine_status_returns_match_when_variance_is_zero(): void
|
||||
{
|
||||
$status = $this->calculator->determineStatus(0);
|
||||
$this->assertEquals('MATCH', $status);
|
||||
}
|
||||
|
||||
// Additional test: get planned hours returns zero for non-existent plan
|
||||
public function test_get_planned_hours_returns_zero_for_no_plan(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$plannedHours = $this->calculator->getPlannedHoursForMonth($project->id, '2026-01');
|
||||
|
||||
$this->assertEquals(0, $plannedHours);
|
||||
}
|
||||
|
||||
// Additional test: untracked allocation is included in row variance
|
||||
public function test_untracked_allocation_included_in_row_variance(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// Create month plan
|
||||
ProjectMonthPlan::create([
|
||||
'project_id' => $project->id,
|
||||
'month' => '2026-01-01',
|
||||
'planned_hours' => 100,
|
||||
]);
|
||||
|
||||
// Create tracked allocation
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 60,
|
||||
]);
|
||||
|
||||
// Create untracked allocation (team_member_id = null)
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => null,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 60,
|
||||
]);
|
||||
|
||||
$result = $this->calculator->calculateRowVariance($project->id, '2026-01');
|
||||
|
||||
// Total should include both tracked and untracked
|
||||
$this->assertEquals(120, $result['allocated_total']);
|
||||
$this->assertEquals(20, $result['variance']);
|
||||
$this->assertEquals('OVER', $result['status']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user