withCapacity(2)->create(); $key = (string) Str::uuid(); $response = $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => $key]) ->assertCreated() ->assertJsonPath('slot_id', $slot->id) ->assertJsonPath('status', HoldStatus::Held->value); $hold = Hold::query()->sole(); $this->assertSame($key, $hold->idempotency_key); $this->assertSame($hold->id, $response->json('hold_id')); // Холды живут заданный TTL, по умолчанию пять минут. $this->assertEqualsWithDelta( now()->addSeconds((int) config('slots.hold_ttl'))->timestamp, $hold->expires_at->timestamp, 2, ); } public function test_a_hold_does_not_consume_a_seat_until_it_is_confirmed(): void { $slot = Slot::factory()->withCapacity(2)->create(); $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => (string) Str::uuid()]) ->assertCreated(); $this->assertSame(2, $slot->refresh()->remaining); } public function test_replaying_the_same_idempotency_key_returns_the_original_hold(): void { $slot = Slot::factory()->withCapacity(5)->create(); $key = (string) Str::uuid(); $first = $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => $key]) ->assertCreated(); $second = $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => $key]) ->assertOk(); $this->assertSame($first->json('hold_id'), $second->json('hold_id')); $this->assertSame(1, Hold::query()->count()); } public function test_it_returns_409_when_capacity_is_exhausted(): void { $slot = Slot::factory()->withCapacity(1)->create(); $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => (string) Str::uuid()]) ->assertCreated(); $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => (string) Str::uuid()]) ->assertConflict() ->assertJsonPath('reason', 'capacity_exhausted'); $this->assertSame(1, Hold::query()->count()); } public function test_capacity_freed_by_an_expired_hold_can_be_taken_again(): void { $slot = Slot::factory()->withCapacity(1)->create(); Hold::factory()->expired()->for($slot)->create(); $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => (string) Str::uuid()]) ->assertCreated(); } public function test_it_rejects_a_missing_idempotency_key(): void { $slot = Slot::factory()->create(); $this->postJson("/slots/{$slot->id}/hold") ->assertUnprocessable() ->assertJsonValidationErrors('idempotency_key'); } public function test_it_rejects_an_idempotency_key_that_is_not_a_uuid(): void { $slot = Slot::factory()->create(); $this->postJson("/slots/{$slot->id}/hold", [], ['Idempotency-Key' => 'not-a-uuid']) ->assertUnprocessable() ->assertJsonValidationErrors('idempotency_key'); } public function test_it_rejects_reusing_an_idempotency_key_for_another_slot(): void { $first = Slot::factory()->create(); $second = Slot::factory()->create(); $key = (string) Str::uuid(); $this->postJson("/slots/{$first->id}/hold", [], ['Idempotency-Key' => $key]) ->assertCreated(); $this->postJson("/slots/{$second->id}/hold", [], ['Idempotency-Key' => $key]) ->assertUnprocessable() ->assertJsonPath('reason', 'idempotency_key_reuse'); } public function test_it_returns_404_for_an_unknown_slot(): void { $this->postJson('/slots/999/hold', [], ['Idempotency-Key' => (string) Str::uuid()]) ->assertNotFound() ->assertExactJson(['message' => 'Resource not found.', 'reason' => 'not_found']); } }