Files
headroom/backend/tests/Feature/Allocation/AllocationTest.php
Santhosh Janardhanan b7bbfb45c0 docs(openspec): add reporting API contract documentation
Add comprehensive API documentation for the reporting endpoint:
- Request/response structure
- View type inference (did/is/will)
- Blank vs explicit zero semantics
- Status values and error responses

Related to enhanced-allocation change.
2026-03-08 18:22:27 -04:00

418 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Allocation;
use App\Models\Allocation;
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 AllocationTest extends TestCase
{
use RefreshDatabase;
protected function loginAsManager()
{
$user = User::factory()->create([
'email' => 'manager@example.com',
'password' => bcrypt('password123'),
'role' => 'manager',
'active' => true,
]);
$response = $this->postJson('/api/auth/login', [
'email' => 'manager@example.com',
'password' => 'password123',
]);
return $response->json('access_token');
}
// 5.1.11 API test: POST /api/allocations creates allocation
public function test_post_allocations_creates_allocation()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations', [
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => 40,
]);
$response->assertStatus(201);
$response->assertJson([
'data' => [
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'allocated_hours' => '40.00',
],
]);
$this->assertDatabaseHas('allocations', [
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'allocated_hours' => 40,
]);
}
// 5.1.12 API test: Validate hours >= 0
public function test_validate_hours_must_be_greater_than_or_equal_zero()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
$project = Project::factory()->create();
// Test with negative hours
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations', [
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => -10,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['allocated_hours']);
$response->assertJsonFragment([
'allocated_hours' => ['The allocated hours field must be at least 0.'],
]);
}
// 5.1.13 API test: GET /api/allocations returns matrix
public function test_get_allocations_returns_matrix()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create();
// Create allocation
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => 40,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonStructure([
'data' => [
'*' => [
'project_id',
'team_member_id',
'month',
'allocated_hours',
],
],
]);
}
// 5.1.14 API test: PUT /api/allocations/{id} updates
public function test_put_allocations_updates()
{
$token = $this->loginAsManager();
$allocation = Allocation::factory()->create([
'allocated_hours' => 40,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->putJson("/api/allocations/{$allocation->id}", [
'allocated_hours' => 60,
]);
$response->assertStatus(200);
$response->assertJson([
'data' => [
'id' => $allocation->id,
'allocated_hours' => '60.00',
],
]);
$this->assertDatabaseHas('allocations', [
'id' => $allocation->id,
'allocated_hours' => 60,
]);
}
// 5.1.15 API test: DELETE /api/allocations/{id} removes
public function test_delete_allocation_removes()
{
$token = $this->loginAsManager();
$allocation = Allocation::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->deleteJson("/api/allocations/{$allocation->id}");
$response->assertStatus(200);
$response->assertJson([
'message' => 'Allocation deleted successfully',
]);
$this->assertDatabaseMissing('allocations', [
'id' => $allocation->id,
]);
}
// 5.1.16 API test: POST /api/allocations/bulk creates multiple
public function test_post_allocations_bulk_creates_multiple()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember1 = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$teamMember2 = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations/bulk', [
'allocations' => [
[
'project_id' => $project->id,
'team_member_id' => $teamMember1->id,
'month' => '2026-02',
'allocated_hours' => 40,
],
[
'project_id' => $project->id,
'team_member_id' => $teamMember2->id,
'month' => '2026-02',
'allocated_hours' => 32,
],
],
]);
$response->assertStatus(201);
$response->assertJsonCount(2, 'data');
$this->assertDatabaseHas('allocations', [
'project_id' => $project->id,
'team_member_id' => $teamMember1->id,
'allocated_hours' => 40,
]);
$this->assertDatabaseHas('allocations', [
'project_id' => $project->id,
'team_member_id' => $teamMember2->id,
'allocated_hours' => 32,
]);
}
// Test: Allocate zero hours is allowed
public function test_allocate_zero_hours_is_allowed()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
$project = Project::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations', [
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => 0,
]);
$response->assertStatus(201);
$response->assertJson([
'data' => [
'allocated_hours' => '0.00',
],
]);
}
// Test: Cannot update non-existent allocation
public function test_cannot_update_nonexistent_allocation()
{
$token = $this->loginAsManager();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->putJson('/api/allocations/nonexistent-id', [
'allocated_hours' => 60,
]);
$response->assertStatus(404);
$response->assertJson([
'message' => 'Allocation not found',
]);
}
// Test: Cannot delete non-existent allocation
public function test_cannot_delete_nonexistent_allocation()
{
$token = $this->loginAsManager();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->deleteJson('/api/allocations/nonexistent-id');
$response->assertStatus(404);
}
// ===== Allocation Indicator Tests =====
// 1.1 API test: GET /api/allocations returns allocation_indicator per item
public function test_get_allocations_returns_allocation_indicator()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create(['approved_estimate' => 100]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02-01',
'allocated_hours' => 100,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonStructure([
'data' => [
'*' => ['allocation_indicator'],
],
]);
}
// 1.2 API test: allocation_indicator is green when >= 100%
public function test_allocation_indicator_is_green_when_full()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create(['approved_estimate' => 100]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02-01',
'allocated_hours' => 100,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonPath('data.0.allocation_indicator', 'green');
}
// 1.3 API test: allocation_indicator is yellow when < 100%
public function test_allocation_indicator_is_yellow_when_under()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create(['approved_estimate' => 100]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02-01',
'allocated_hours' => 80,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonPath('data.0.allocation_indicator', 'yellow');
}
// 1.4 API test: allocation_indicator is red when > 100%
public function test_allocation_indicator_is_red_when_over()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create(['approved_estimate' => 100]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02-01',
'allocated_hours' => 120,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonPath('data.0.allocation_indicator', 'red');
}
// 1.5 API test: allocation_indicator is gray when no approved_estimate
public function test_allocation_indicator_is_gray_when_no_estimate()
{
$token = $this->loginAsManager();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$project = Project::factory()->create(['approved_estimate' => null]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02-01',
'allocated_hours' => 40,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonPath('data.0.allocation_indicator', 'gray');
}
// ===== Untracked Allocation Tests =====
// 2.1 API test: POST /api/allocations accepts null team_member_id
public function test_can_create_allocation_with_null_team_member()
{
$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);
$response->assertJsonPath('data.team_member_id', null);
$response->assertJsonPath('data.team_member', null);
}
// 2.2 API test: GET /api/allocations returns null team_member
public function test_get_allocations_returns_null_team_member()
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
// Create untracked allocation
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => null,
'month' => '2026-02-01',
'allocated_hours' => 40,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/allocations?month=2026-02');
$response->assertStatus(200);
$response->assertJsonPath('data.0.team_member_id', null);
$response->assertJsonPath('data.0.team_member', null);
}
}