withCapacity(3)->create(); $hold = Hold::factory()->for($slot)->create(); $this->postJson("/holds/{$hold->id}/confirm") ->assertOk() ->assertJsonPath('status', HoldStatus::Confirmed->value) ->assertJsonPath('confirmed_at', fn (?string $value): bool => $value !== null); $this->assertSame(2, $slot->refresh()->remaining); $this->assertSame(HoldStatus::Confirmed, $hold->refresh()->status); } public function test_confirming_twice_consumes_only_one_seat(): void { $slot = Slot::factory()->withCapacity(3)->create(); $hold = Hold::factory()->for($slot)->create(); $this->postJson("/holds/{$hold->id}/confirm")->assertOk(); $this->postJson("/holds/{$hold->id}/confirm")->assertOk(); $this->assertSame(2, $slot->refresh()->remaining); } public function test_two_holds_racing_for_the_last_seat_cannot_oversell(): void { // Состояние после проигранной гонки: два холда на одно место. // Созданы напрямую, минуя проверку вместимости. $slot = Slot::factory()->withCapacity(1)->create(); $winner = Hold::factory()->for($slot)->create(); $loser = Hold::factory()->for($slot)->create(); $this->postJson("/holds/{$winner->id}/confirm")->assertOk(); $this->postJson("/holds/{$loser->id}/confirm") ->assertConflict() ->assertJsonPath('reason', 'capacity_exhausted'); $this->assertSame(0, $slot->refresh()->remaining); $this->assertSame(HoldStatus::Held, $loser->refresh()->status); } public function test_it_refuses_to_confirm_an_expired_hold(): void { $slot = Slot::factory()->withCapacity(1)->create(); $hold = Hold::factory()->expired()->for($slot)->create(); $this->postJson("/holds/{$hold->id}/confirm") ->assertConflict() ->assertJsonPath('reason', 'hold_expired'); $this->assertSame(1, $slot->refresh()->remaining); } public function test_it_refuses_to_confirm_a_cancelled_hold(): void { $slot = Slot::factory()->withCapacity(1)->create(); $hold = Hold::factory()->cancelled()->for($slot)->create(); $this->postJson("/holds/{$hold->id}/confirm") ->assertConflict() ->assertJsonPath('reason', 'hold_cancelled'); $this->assertSame(1, $slot->refresh()->remaining); } public function test_it_invalidates_the_availability_cache(): void { $slot = Slot::factory()->withCapacity(3)->create(); $key = (string) Str::uuid(); // Кеш прогрет до всех изменений. $this->getJson('/slots/availability')->assertJsonPath('0.remaining', 3); $holdId = $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => $key]) ->assertCreated() ->json('hold_id'); // Создание холда кеш не сбрасывает: доступность справочная, // авторитетная проверка — POST /slots/{id}/hold. $this->getJson('/slots/availability')->assertJsonPath('0.remaining', 3); $this->postJson("/holds/{$holdId}/confirm")->assertOk(); $this->getJson('/slots/availability')->assertJsonPath('0.remaining', 2); } public function test_it_returns_404_for_an_unknown_hold(): void { $this->postJson('/holds/999/confirm')->assertNotFound(); } }