Make PTO creation immediately approved, add PTO deletion, and ensure cache invalidation updates individual/team/revenue capacity consistently. Harden holiday duplicate handling (422), support PTO-day availability overrides without disabling edits, and align tests plus OpenSpec artifacts with the new behavior.
343 lines
11 KiB
PHP
343 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Models\Holiday;
|
|
use App\Models\Pto;
|
|
use App\Models\Role;
|
|
use App\Models\TeamMember;
|
|
use App\Models\TeamMemberAvailability;
|
|
use App\Models\User;
|
|
use App\Services\CapacityService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
use function Pest\Laravel\assertDatabaseHas;
|
|
|
|
/**
|
|
* @mixin \Tests\TestCase
|
|
*/
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
test('4.1.11 GET /api/capacity calculates individual capacity', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
|
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$teamMember->id}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$service = app(CapacityService::class);
|
|
$capacity = $service->calculateIndividualCapacity($teamMember->id, '2026-02');
|
|
$expected = [
|
|
'data' => [
|
|
'team_member_id' => $teamMember->id,
|
|
'month' => '2026-02',
|
|
'working_days' => $service->calculateWorkingDays('2026-02'),
|
|
'person_days' => $capacity['person_days'],
|
|
'hours' => $capacity['hours'],
|
|
'details' => $capacity['details'],
|
|
],
|
|
];
|
|
|
|
$response->assertExactJson($expected);
|
|
});
|
|
|
|
test('4.1.12 Capacity accounts for availability', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
|
|
TeamMemberAvailability::factory()->forDate('2026-02-03')->availability(0.5)->create(['team_member_id' => $member->id]);
|
|
TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->create(['team_member_id' => $member->id]);
|
|
|
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$details = collect($response->json('data.details'));
|
|
|
|
expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5);
|
|
expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0);
|
|
});
|
|
|
|
test('4.1.13 Capacity subtracts PTO', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
|
|
Pto::create([
|
|
'team_member_id' => $member->id,
|
|
'start_date' => '2026-02-10',
|
|
'end_date' => '2026-02-12',
|
|
'reason' => 'Vacation',
|
|
'status' => 'approved',
|
|
]);
|
|
|
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$details = collect($response->json('data.details'));
|
|
|
|
expect($details->where('is_pto', true)->count())->toBe(3);
|
|
expect($details->firstWhere('date', '2026-02-11')['availability'])->toBe(0);
|
|
});
|
|
|
|
test('4.1.14 Capacity subtracts holidays', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
|
|
Holiday::create([
|
|
'date' => '2026-02-17',
|
|
'name' => 'Presidents Day',
|
|
'description' => 'Company wide',
|
|
]);
|
|
|
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$dates = collect($response->json('details'))->pluck('date');
|
|
|
|
expect($dates)->not->toContain('2026-02-17');
|
|
});
|
|
|
|
test('4.1.15 GET /api/capacity/team sums active members', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$activeA = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
$activeB = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
TeamMember::factory()->inactive()->create(['role_id' => $role->id]);
|
|
|
|
$expectedDays = 0;
|
|
$expectedHours = 0;
|
|
|
|
foreach ([$activeA, $activeB] as $member) {
|
|
$capacity = app(CapacityService::class)->calculateIndividualCapacity($member->id, '2026-02');
|
|
$expectedDays += $capacity['person_days'];
|
|
$expectedHours += $capacity['hours'];
|
|
}
|
|
|
|
$response = $this->getJson('/api/capacity/team?month=2026-02', [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertJsonCount(2, 'data.members');
|
|
expect(round($response->json('data.person_days'), 2))->toBe(round($expectedDays, 2));
|
|
expect($response->json('data.hours'))->toBe($expectedHours);
|
|
});
|
|
|
|
test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 150]);
|
|
TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 125]);
|
|
|
|
$expectedRevenue = app(CapacityService::class)->calculatePossibleRevenue('2026-02');
|
|
|
|
$response = $this->getJson('/api/capacity/revenue?month=2026-02', [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
expect(round($response->json('data.possible_revenue'), 2))->toBe(round($expectedRevenue, 2));
|
|
});
|
|
|
|
test('4.1.25 POST /api/capacity/availability saves entry', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
|
|
$payload = [
|
|
'team_member_id' => $member->id,
|
|
'date' => '2026-02-03',
|
|
'availability' => 0.5,
|
|
];
|
|
|
|
$response = $this->postJson('/api/capacity/availability', $payload, [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
$response->assertJsonPath('data.date', '2026-02-03');
|
|
|
|
assertDatabaseHas('team_member_daily_availabilities', [
|
|
'team_member_id' => $member->id,
|
|
'date' => '2026-02-03 00:00:00',
|
|
'availability' => 0.5,
|
|
]);
|
|
});
|
|
|
|
test('4.1.17 POST /api/holidays creates holiday', function () {
|
|
$token = loginAsManager($this);
|
|
|
|
$response = $this->postJson('/api/holidays', [
|
|
'date' => '2026-02-20',
|
|
'name' => 'Test Holiday',
|
|
'description' => 'Test description',
|
|
], [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
assertDatabaseHas('holidays', ['date' => '2026-02-20 00:00:00', 'name' => 'Test Holiday']);
|
|
});
|
|
|
|
test('4.1.17b POST /api/holidays returns 422 for duplicate date', function () {
|
|
$token = loginAsManager($this);
|
|
|
|
// Create first holiday
|
|
$this->postJson('/api/holidays', [
|
|
'date' => '2026-02-20',
|
|
'name' => 'First Holiday',
|
|
], [
|
|
'Authorization' => "Bearer {$token}",
|
|
])->assertStatus(201);
|
|
|
|
// Try to create duplicate
|
|
$response = $this->postJson('/api/holidays', [
|
|
'date' => '2026-02-20',
|
|
'name' => 'Duplicate Holiday',
|
|
], [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJson([
|
|
'message' => 'A holiday already exists for this date.',
|
|
'errors' => [
|
|
'date' => ['A holiday already exists for this date.'],
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('4.1.18 POST /api/ptos creates PTO request', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
|
|
|
$response = $this->postJson('/api/ptos', [
|
|
'team_member_id' => $member->id,
|
|
'start_date' => '2026-02-10',
|
|
'end_date' => '2026-02-11',
|
|
'reason' => 'Refresh',
|
|
], [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$response->assertStatus(201);
|
|
$response->assertJsonPath('data.status', 'approved');
|
|
assertDatabaseHas('ptos', ['team_member_id' => $member->id, 'status' => 'approved']);
|
|
});
|
|
|
|
test('4.1.19 PTO creation invalidates team and revenue caches', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create([
|
|
'role_id' => $role->id,
|
|
'hourly_rate' => 80,
|
|
'active' => true,
|
|
]);
|
|
|
|
$month = '2026-02';
|
|
|
|
$this->getJson("/api/capacity/team?month={$month}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
])->assertStatus(200);
|
|
|
|
$this->getJson("/api/capacity/revenue?month={$month}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
])->assertStatus(200);
|
|
|
|
$this->postJson('/api/ptos', [
|
|
'team_member_id' => $member->id,
|
|
'start_date' => '2026-02-26',
|
|
'end_date' => '2026-02-28',
|
|
'reason' => 'Vacation',
|
|
], [
|
|
'Authorization' => "Bearer {$token}",
|
|
])->assertStatus(201);
|
|
|
|
$teamResponse = $this->getJson("/api/capacity/team?month={$month}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$teamResponse->assertStatus(200);
|
|
expect((float) $teamResponse->json('data.person_days'))->toBe(18.0);
|
|
expect($teamResponse->json('data.hours'))->toBe(144);
|
|
|
|
$revenueResponse = $this->getJson("/api/capacity/revenue?month={$month}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$revenueResponse->assertStatus(200);
|
|
expect((float) $revenueResponse->json('data.possible_revenue'))->toBe(11520.0);
|
|
});
|
|
|
|
test('4.1.20 DELETE /api/ptos/{id} removes PTO and refreshes capacity', function () {
|
|
$token = loginAsManager($this);
|
|
$role = Role::factory()->create();
|
|
$member = TeamMember::factory()->create([
|
|
'role_id' => $role->id,
|
|
'hourly_rate' => 80,
|
|
'active' => true,
|
|
]);
|
|
|
|
$createResponse = $this->postJson('/api/ptos', [
|
|
'team_member_id' => $member->id,
|
|
'start_date' => '2026-02-26',
|
|
'end_date' => '2026-02-28',
|
|
'reason' => 'Vacation',
|
|
], [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
|
|
$createResponse->assertStatus(201);
|
|
$ptoId = $createResponse->json('data.id');
|
|
|
|
$beforeDelete = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
$beforeDelete->assertStatus(200);
|
|
expect((float) $beforeDelete->json('data.person_days'))->toBe(18.0);
|
|
|
|
$deleteResponse = $this->deleteJson("/api/ptos/{$ptoId}", [], [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
$deleteResponse->assertStatus(200);
|
|
$deleteResponse->assertJson(['message' => 'PTO deleted']);
|
|
|
|
$afterDelete = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
|
'Authorization' => "Bearer {$token}",
|
|
]);
|
|
$afterDelete->assertStatus(200);
|
|
expect((float) $afterDelete->json('data.person_days'))->toBe(20.0);
|
|
|
|
$this->deleteJson('/api/ptos/non-existent', [], [
|
|
'Authorization' => "Bearer {$token}",
|
|
])->assertStatus(404);
|
|
});
|
|
|
|
function loginAsManager(TestCase $test): string
|
|
{
|
|
$user = User::factory()->create([
|
|
'email' => 'manager@example.com',
|
|
'password' => bcrypt('password123'),
|
|
'role' => 'manager',
|
|
'active' => true,
|
|
]);
|
|
|
|
$response = $test->postJson('/api/auth/login', [
|
|
'email' => 'manager@example.com',
|
|
'password' => 'password123',
|
|
]);
|
|
|
|
return $response->json('access_token');
|
|
}
|