slot-booking/tests/Feature/AvailabilityTest.php
Dmitriy 16f3f851b1 feat: slot booking API with hot cache and oversell protection
GET /slots/availability, POST /slots/{id}/hold, POST /holds/{id}/confirm
and DELETE /holds/{id} on Laravel 12 and MySQL 8.

- availability cached for 10s behind a single-flight lock with a stale
  copy, invalidated after commit on confirm and cancel
- oversell prevented by a conditional decrement, verified by the number
  of affected rows rather than a preceding select
- idempotency enforced by a unique index on holds.idempotency_key
- active holds counted with an expires_at filter, so no background
  cleanup is required for correctness
- 27 feature tests against MySQL, service documentation, Postman collection

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:47:18 +03:00

108 lines
3.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Hold;
use App\Models\Slot;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
final class AvailabilityTest extends TestCase
{
use RefreshDatabase;
public function test_it_lists_every_slot_with_capacity_and_remaining(): void
{
$first = Slot::factory()->withCapacity(10)->create();
$second = Slot::factory()->withCapacity(5)->create();
$this->getJson('/slots/availability')
->assertOk()
->assertExactJson([
['slot_id' => $first->id, 'capacity' => 10, 'remaining' => 10],
['slot_id' => $second->id, 'capacity' => 5, 'remaining' => 5],
]);
}
public function test_active_holds_are_subtracted_from_remaining(): void
{
$slot = Slot::factory()->withCapacity(5)->create();
Hold::factory()->count(2)->for($slot)->create();
$this->getJson('/slots/availability')
->assertOk()
->assertJsonPath('0.remaining', 3);
}
public function test_expired_holds_stop_reserving_capacity(): void
{
$slot = Slot::factory()->withCapacity(2)->create();
Hold::factory()->expired()->for($slot)->create();
$this->getJson('/slots/availability')
->assertOk()
->assertJsonPath('0.remaining', 2);
}
public function test_cancelled_holds_stop_reserving_capacity(): void
{
$slot = Slot::factory()->withCapacity(2)->create();
Hold::factory()->cancelled()->for($slot)->create();
$this->getJson('/slots/availability')
->assertOk()
->assertJsonPath('0.remaining', 2);
}
public function test_the_response_is_served_from_cache_within_the_ttl(): void
{
$slot = Slot::factory()->withCapacity(3)->create();
$this->getJson('/slots/availability')->assertJsonPath('0.remaining', 3);
// Меняем источник истины за спиной кеша.
$slot->update(['remaining' => 1]);
$this->getJson('/slots/availability')->assertJsonPath('0.remaining', 3);
}
public function test_the_cache_expires_after_the_configured_ttl(): void
{
$slot = Slot::factory()->withCapacity(3)->create();
$this->getJson('/slots/availability')->assertJsonPath('0.remaining', 3);
$slot->update(['remaining' => 1]);
$this->travel(config('slots.availability_cache_ttl') + 1)->seconds();
$this->getJson('/slots/availability')->assertJsonPath('0.remaining', 1);
}
public function test_a_caller_that_loses_the_recompute_race_is_served_stale_data(): void
{
$slot = Slot::factory()->withCapacity(3)->create();
// Прогревает и основную запись, и stale-копию.
$this->getJson('/slots/availability')->assertJsonPath('0.remaining', 3);
$this->travel(config('slots.availability_cache_ttl') + 1)->seconds();
$slot->update(['remaining' => 0]);
// Имитируем воркер, который пересчитывает прямо сейчас.
$lock = Cache::lock('slots:availability:lock', 10);
$this->assertTrue($lock->get());
DB::enableQueryLog();
$response = $this->getJson('/slots/availability');
$queries = DB::getQueryLog();
DB::disableQueryLog();
$lock->release();
$response->assertOk()->assertJsonPath('0.remaining', 3);
$this->assertSame([], $queries, 'Проигравший гонку за пересчёт не должен обращаться к базе.');
}
}