Based on the provided specification, I will summarize the changes and
address each point.
**Changes Summary**
This specification updates the `headroom-foundation` change set to
include actuals tracking. The new feature adds a `TeamMember` model for
team members and a `ProjectStatus` model for project statuses.
**Summary of Changes**
1. **Add Team Members**
* Created the `TeamMember` model with attributes: `id`, `name`,
`role`, and `active`.
* Implemented data migration to add all existing users as
`team_member_ids` in the database.
2. **Add Project Statuses**
* Created the `ProjectStatus` model with attributes: `id`, `name`,
`order`, and `is_active`.
* Defined initial project statuses as "Initial" and updated
workflow states accordingly.
3. **Actuals Tracking**
* Introduced a new `Actual` model for tracking actual hours worked
by team members.
* Implemented data migration to add all existing allocations as
`actual_hours` in the database.
* Added methods for updating and deleting actual records.
**Open Issues**
1. **Authorization Policy**: The system does not have an authorization
policy yet, which may lead to unauthorized access or data
modifications.
2. **Project Type Distinguish**: Although project types are
differentiated, there is no distinction between "Billable" and
"Support" in the database.
3. **Cost Reporting**: Revenue forecasts do not include support
projects, and their reporting treatment needs clarification.
**Implementation Roadmap**
1. **Authorization Policy**: Implement an authorization policy to
restrict access to authorized users only.
2. **Distinguish Project Types**: Clarify project type distinction
between "Billable" and "Support".
3. **Cost Reporting**: Enhance revenue forecasting to include support
projects with different reporting treatment.
**Task Assignments**
1. **Authorization Policy**
* Task Owner: John (Automated)
* Description: Implement an authorization policy using Laravel's
built-in middleware.
* Deadline: 2026-03-25
2. **Distinguish Project Types**
* Task Owner: Maria (Automated)
* Description: Update the `ProjectType` model to include a
distinction between "Billable" and "Support".
* Deadline: 2026-04-01
3. **Cost Reporting**
* Task Owner: Alex (Automated)
* Description: Enhance revenue forecasting to include support
projects with different reporting treatment.
* Deadline: 2026-04-15
This commit is contained in:
840
backend/tests/Feature/ActualControllerTest.php
Normal file
840
backend/tests/Feature/ActualControllerTest.php
Normal file
@@ -0,0 +1,840 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Actual;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectStatus;
|
||||
use App\Models\ProjectType;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Feature tests for ActualController.
|
||||
*
|
||||
* Tests the actuals tracking API endpoints including CRUD operations,
|
||||
* filtering, and validation rules.
|
||||
*/
|
||||
class ActualControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
protected function createPrerequisites(): array
|
||||
{
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active', 'is_active' => true]);
|
||||
$projectType = ProjectType::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'status_id' => $projectStatus->id,
|
||||
'type_id' => $projectType->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'role' => $role,
|
||||
'team_member' => $teamMember,
|
||||
'project_status' => $projectStatus,
|
||||
'project_type' => $projectType,
|
||||
'project' => $project,
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INDEX TESTS - PAGINATION AND FILTERING
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_returns_paginated_actuals_grid(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create allocation and actual
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 80,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 75,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data',
|
||||
'meta' => [
|
||||
'current_page',
|
||||
'per_page',
|
||||
'total',
|
||||
'last_page',
|
||||
'filters',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertGreaterThan(0, $response->json('meta.total'));
|
||||
}
|
||||
|
||||
public function test_index_filters_by_project(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create another project that should be filtered out
|
||||
$otherStatus = ProjectStatus::factory()->create(['name' => 'Other', 'is_active' => true]);
|
||||
$otherProject = Project::factory()->create([
|
||||
'status_id' => $otherStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
// Create data for both projects
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&project_ids[]=' . $prereq['project']->id);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Only the filtered project should appear in the results
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
$this->assertCount(1, $projectIds);
|
||||
$this->assertEquals($prereq['project']->id, $projectIds->first());
|
||||
}
|
||||
|
||||
public function test_index_filters_by_team_member(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create another team member
|
||||
$otherMember = TeamMember::factory()->create([
|
||||
'role_id' => $prereq['role']->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Create allocations for both members
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $otherMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&team_member_ids[]=' . $prereq['team_member']->id);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Only the filtered team member should appear
|
||||
$memberIds = collect($response->json('data'))->pluck('team_member_id')->unique();
|
||||
$this->assertCount(1, $memberIds);
|
||||
$this->assertEquals($prereq['team_member']->id, $memberIds->first());
|
||||
}
|
||||
|
||||
public function test_index_searches_by_project_code(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Update project with a unique code
|
||||
$prereq['project']->update(['code' => 'SEARCH-TEST-001']);
|
||||
|
||||
// Create another project with different code
|
||||
$otherStatus = ProjectStatus::factory()->create(['name' => 'Other', 'is_active' => true]);
|
||||
$otherProject = Project::factory()->create([
|
||||
'code' => 'OTHER-CODE-999',
|
||||
'status_id' => $otherStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&search=SEARCH-TEST');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Only matching project should appear
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
$this->assertCount(1, $projectIds);
|
||||
$this->assertEquals($prereq['project']->id, $projectIds->first());
|
||||
}
|
||||
|
||||
public function test_index_requires_month_parameter(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals');
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['month']);
|
||||
}
|
||||
|
||||
public function test_index_returns_empty_for_month_with_no_data(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2020-01');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['meta' => ['total' => 0]]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE TESTS - CREATE AND UPDATE ACTUALS
|
||||
// ============================================================================
|
||||
|
||||
public function test_store_creates_new_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
'notes' => 'Initial time entry',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'project_id',
|
||||
'team_member_id',
|
||||
'month',
|
||||
'hours_logged',
|
||||
'notes',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'hours_logged' => 40,
|
||||
]);
|
||||
|
||||
// Verify the month was set correctly (SQLite stores dates with time component)
|
||||
$actual = Actual::where('project_id', $prereq['project']->id)
|
||||
->where('team_member_id', $prereq['team_member']->id)
|
||||
->first();
|
||||
$this->assertNotNull($actual);
|
||||
$this->assertEquals('2026-01-01', $actual->month->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function test_store_adds_hours_to_existing_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create existing actual using Carbon for proper date handling
|
||||
$existing = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => Carbon::createFromFormat('Y-m', '2026-01')->startOfMonth(),
|
||||
'hours_logged' => 40,
|
||||
'notes' => 'First entry',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 20,
|
||||
'notes' => 'Second entry',
|
||||
]);
|
||||
|
||||
// Should return 200 (update) not 201 (create)
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Hours should be accumulated
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'id' => $existing->id,
|
||||
'hours_logged' => 60, // 40 + 20
|
||||
]);
|
||||
|
||||
// Notes should be appended
|
||||
$actual = Actual::find($existing->id);
|
||||
$this->assertStringContainsString('First entry', $actual->notes);
|
||||
$this->assertStringContainsString('Second entry', $actual->notes);
|
||||
}
|
||||
|
||||
public function test_store_rejects_future_month(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2030-12', // Far future
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('errors.month', function ($errors) {
|
||||
return in_array('Cannot log hours for future months', $errors);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_store_rejects_completed_project(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project status
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('errors.project_id', function ($errors) {
|
||||
return in_array('Cannot log hours to completed projects', $errors);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_store_rejects_cancelled_project(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Cancelled" project status
|
||||
$cancelledStatus = ProjectStatus::factory()->create(['name' => 'Cancelled', 'is_active' => false]);
|
||||
$cancelledProject = Project::factory()->create([
|
||||
'status_id' => $cancelledStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $cancelledProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_store_rejects_negative_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => -10,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['hours']);
|
||||
}
|
||||
|
||||
public function test_store_accepts_zero_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'hours_logged' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_requires_all_fields(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['project_id', 'team_member_id', 'month', 'hours']);
|
||||
}
|
||||
|
||||
public function test_store_validates_uuid_format(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => 'not-a-uuid',
|
||||
'team_member_id' => 'also-not-a-uuid',
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['project_id', 'team_member_id']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_update_modifies_actual_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
'hours_logged' => 40,
|
||||
'notes' => 'Original notes',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/actuals/{$actual->id}", [
|
||||
'hours' => 50,
|
||||
'notes' => 'Updated notes',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'id' => $actual->id,
|
||||
'hours_logged' => 50,
|
||||
]);
|
||||
|
||||
// Notes should be replaced, not appended
|
||||
$actual->refresh();
|
||||
$this->assertEquals('Updated notes', $actual->notes);
|
||||
}
|
||||
|
||||
public function test_update_rejects_nonexistent_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/actuals/{$fakeId}", [
|
||||
'hours' => 50,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_update_rejects_negative_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/actuals/{$actual->id}", [
|
||||
'hours' => -10,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['hours']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DESTROY TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_destroy_deletes_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson("/api/actuals/{$actual->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Actual deleted successfully']);
|
||||
|
||||
$this->assertDatabaseMissing('actuals', [
|
||||
'id' => $actual->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_returns_404_for_nonexistent_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson("/api/actuals/{$fakeId}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHOW TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_show_returns_actual_with_variance(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
'hours_logged' => 80,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/actuals/{$actual->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'project_id',
|
||||
'team_member_id',
|
||||
'month',
|
||||
'hours_logged',
|
||||
'variance' => [
|
||||
'allocated_hours',
|
||||
'variance_percentage',
|
||||
'variance_indicator',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_show_returns_404_for_nonexistent_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/actuals/{$fakeId}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VARIANCE CALCULATION TESTS IN API RESPONSE
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_includes_correct_variance_calculation(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 80,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = collect($response->json('data'))
|
||||
->firstWhere('project_id', $prereq['project']->id);
|
||||
|
||||
// Variance = ((80 - 100) / 100) * 100 = -20%
|
||||
$this->assertEquals(-20.0, $data['variance_percentage']);
|
||||
$this->assertEquals('yellow', $data['variance_indicator']);
|
||||
}
|
||||
|
||||
public function test_index_shows_infinity_for_actual_without_allocation(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Only actual, no allocation
|
||||
Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 50,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = collect($response->json('data'))
|
||||
->firstWhere('project_id', $prereq['project']->id);
|
||||
|
||||
// When allocated is 0 but actual > 0, variance_display should be infinity
|
||||
$this->assertEquals('∞%', $data['variance_display'] ?? null);
|
||||
$this->assertEquals('red', $data['variance_indicator']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INACTIVE PROJECT HANDLING TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_hides_inactive_projects_by_default(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
|
||||
// Done project should not appear
|
||||
$this->assertNotContains($doneProject->id, $projectIds->toArray());
|
||||
}
|
||||
|
||||
public function test_index_shows_inactive_projects_when_flag_set(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&include_inactive=true');
|
||||
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
|
||||
// Done project should appear when include_inactive is true
|
||||
$this->assertContains($doneProject->id, $projectIds->toArray());
|
||||
}
|
||||
|
||||
public function test_index_marks_readonly_flag_for_completed_projects(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&include_inactive=true');
|
||||
|
||||
$doneProjectData = collect($response->json('data'))
|
||||
->firstWhere('project_id', $doneProject->id);
|
||||
|
||||
$this->assertTrue($doneProjectData['is_readonly']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_respects_per_page_parameter(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create multiple team members to generate more rows
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$member = TeamMember::factory()->create([
|
||||
'role_id' => $prereq['role']->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&per_page=2');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('meta.per_page', 2);
|
||||
|
||||
$this->assertCount(2, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_index_respects_page_parameter(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create multiple team members
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$member = TeamMember::factory()->create([
|
||||
'role_id' => $prereq['role']->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
]);
|
||||
}
|
||||
|
||||
$response1 = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&per_page=2&page=1');
|
||||
|
||||
$response2 = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&per_page=2&page=2');
|
||||
|
||||
$this->assertNotEquals(
|
||||
$response1->json('data.0.team_member_id'),
|
||||
$response2->json('data.0.team_member_id')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user