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>
108 lines
3.7 KiB
PHP
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, 'Проигравший гонку за пересчёт не должен обращаться к базе.');
|
|
}
|
|
}
|