feat(allocation): implement resource allocation feature
- Add AllocationController with CRUD + bulk endpoints - Add AllocationValidationService for capacity/estimate validation - Add AllocationMatrixService for optimized matrix queries - Add AllocationPolicy for authorization - Add AllocationResource for API responses - Add frontend allocationService and matrix UI - Add E2E tests for allocation matrix (20 tests) - Add unit tests for validation service and policies - Fix month format conversion (YYYY-MM to YYYY-MM-01)
This commit is contained in:
260
backend/tests/Feature/Allocation/AllocationTest.php
Normal file
260
backend/tests/Feature/Allocation/AllocationTest.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,103 @@ test('4.1.20 DELETE /api/ptos/{id} removes PTO and refreshes capacity', function
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
test('1.1 POST /api/capacity/availability/batch saves multiple updates and returns 200 with saved count', function () {
|
||||
$token = loginAsManager($this);
|
||||
$role = Role::factory()->create();
|
||||
$member1 = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$member2 = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [
|
||||
['team_member_id' => $member1->id, 'date' => '2026-02-03', 'availability' => 0.5],
|
||||
['team_member_id' => $member1->id, 'date' => '2026-02-04', 'availability' => 0],
|
||||
['team_member_id' => $member2->id, 'date' => '2026-02-05', 'availability' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.saved', 3);
|
||||
$response->assertJsonPath('data.month', '2026-02');
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member1->id,
|
||||
'date' => '2026-02-03 00:00:00',
|
||||
'availability' => 0.5,
|
||||
]);
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member1->id,
|
||||
'date' => '2026-02-04 00:00:00',
|
||||
'availability' => 0,
|
||||
]);
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member2->id,
|
||||
'date' => '2026-02-05 00:00:00',
|
||||
'availability' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
test('1.2 batch endpoint returns 422 when availability value is not in [0, 0.5, 1]', function () {
|
||||
$token = loginAsManager($this);
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [
|
||||
['team_member_id' => $member->id, 'date' => '2026-02-03', 'availability' => 0.75],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['updates.0.availability']);
|
||||
});
|
||||
|
||||
test('1.3 batch endpoint returns 422 when team_member_id does not exist', function () {
|
||||
$token = loginAsManager($this);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [
|
||||
['team_member_id' => 'non-existent-uuid', 'date' => '2026-02-03', 'availability' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['updates.0.team_member_id']);
|
||||
});
|
||||
|
||||
test('1.4 empty updates array returns 200 with saved count 0', function () {
|
||||
$token = loginAsManager($this);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.saved', 0);
|
||||
$response->assertJsonPath('data.month', '2026-02');
|
||||
});
|
||||
|
||||
function loginAsManager(TestCase $test): string
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
|
||||
Reference in New Issue
Block a user