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>
682 lines
25 KiB
JSON
682 lines
25 KiB
JSON
{
|
||
"info": {
|
||
"name": "Slot Booking API",
|
||
"description": "Бронирование слотов: горячий кеш, идемпотентные холды, защита от оверсела.\n\nПеред прогоном сценария нужна свежая база:\n\n php artisan migrate:fresh --seed\n php artisan serve --host=0.0.0.0 --port=8001\n\nПапка «Сценарий по порядку» рассчитана на запуск через Collection Runner: запросы связаны переменными hold_id и idem_key, которые заполняются автоматически.\n\nПеременные коллекции:\n- base_url — адрес сервера\n- slot_id — слот с одним местом (3), на нём воспроизводится конфликт\n- other_slot_id — слот с десятью местами (1)\n- hold_id, idem_key — заполняются скриптами, руками трогать не нужно",
|
||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||
},
|
||
"variable": [
|
||
{
|
||
"key": "base_url",
|
||
"value": "http://localhost:8001",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"key": "slot_id",
|
||
"value": "3",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"key": "other_slot_id",
|
||
"value": "1",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"key": "hold_id",
|
||
"value": "",
|
||
"type": "string"
|
||
},
|
||
{
|
||
"key": "idem_key",
|
||
"value": "",
|
||
"type": "string"
|
||
}
|
||
],
|
||
"item": [
|
||
{
|
||
"name": "Сценарий по порядку",
|
||
"description": "Полный жизненный цикл холда. Запускать сверху вниз или целиком через Runner.",
|
||
"item": [
|
||
{
|
||
"name": "1. Доступность — исходное состояние",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK', () => pm.response.to.have.status(200));",
|
||
"",
|
||
"const slots = pm.response.json();",
|
||
"const target = Number(pm.collectionVariables.get('slot_id'));",
|
||
"const slot = slots.find((s) => s.slot_id === target);",
|
||
"",
|
||
"pm.test('слот ' + target + ' есть в ответе', () => pm.expect(slot).to.not.be.undefined);",
|
||
"",
|
||
"pm.test('на слоте есть свободное место (иначе нужен свежий seed)', function () {",
|
||
" pm.expect(slot.remaining, 'запусти: php artisan migrate:fresh --seed').to.be.above(0);",
|
||
"});",
|
||
"",
|
||
"console.log('Доступность:', JSON.stringify(slots));"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "GET",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/availability",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"availability"
|
||
]
|
||
},
|
||
"description": "capacity — вместимость слота, remaining — сколько реально можно забронировать: подтверждённые брони и активные холды уже вычтены.\n\nОтвет кешируется на 10 секунд."
|
||
}
|
||
},
|
||
{
|
||
"name": "2. Создать холд → 201",
|
||
"event": [
|
||
{
|
||
"listen": "prerequest",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"// Генерируем UUID и запоминаем его в переменной коллекции,",
|
||
"// чтобы следующий запрос смог повторить вызов с тем же ключом.",
|
||
"pm.collectionVariables.set('idem_key', pm.variables.replaceIn('{{$guid}}'));"
|
||
]
|
||
}
|
||
},
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('201 Created', () => pm.response.to.have.status(201));",
|
||
"",
|
||
"const hold = pm.response.json();",
|
||
"",
|
||
"pm.test('статус held', () => pm.expect(hold.status).to.eql('held'));",
|
||
"pm.test('слот тот, который просили', () => {",
|
||
" pm.expect(hold.slot_id).to.eql(Number(pm.collectionVariables.get('slot_id')));",
|
||
"});",
|
||
"pm.test('срок жизни холда задан', () => pm.expect(hold.expires_at).to.be.a('string'));",
|
||
"",
|
||
"pm.collectionVariables.set('hold_id', hold.hold_id);",
|
||
"console.log('hold_id =', hold.hold_id, '| ключ =', pm.collectionVariables.get('idem_key'));"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"key": "Idempotency-Key",
|
||
"value": "{{idem_key}}"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/{{slot_id}}/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"{{slot_id}}",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Тела нет — всё нужное в URL и заголовке Idempotency-Key.\n\nХолд придерживает место на 5 минут, но remaining слота пока не уменьшает: место списывается только при подтверждении.\n\nПолученный hold_id автоматически сохраняется в переменную коллекции."
|
||
}
|
||
},
|
||
{
|
||
"name": "3. Повтор с тем же ключом → 200",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK, а не 201 — ничего не создано', () => pm.response.to.have.status(200));",
|
||
"",
|
||
"pm.test('вернулся тот же холд', () => {",
|
||
" pm.expect(pm.response.json().hold_id).to.eql(Number(pm.collectionVariables.get('hold_id')));",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"key": "Idempotency-Key",
|
||
"value": "{{idem_key}}"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/{{slot_id}}/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"{{slot_id}}",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Тот же ключ, что и в предыдущем запросе. Это имитация ретрая клиента после таймаута.\n\nВторого холда не появится, второе место не съедено. Статус 200 вместо 201 — потому что ресурс создан не этим запросом."
|
||
}
|
||
},
|
||
{
|
||
"name": "4. Новый ключ, мест нет → 409",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('409 Conflict', () => pm.response.to.have.status(409));",
|
||
"pm.test('reason = capacity_exhausted', () => {",
|
||
" pm.expect(pm.response.json().reason).to.eql('capacity_exhausted');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"key": "Idempotency-Key",
|
||
"value": "{{$guid}}"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/{{slot_id}}/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"{{slot_id}}",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Ключ новый ({{$guid}} подставляет свежий UUID при каждой отправке), значит это уже другой запрос, а не повтор.\n\nЕдинственное место слота придержано холдом из шага 2, поэтому — конфликт. Именно это и есть защита от оверсела на этапе создания холда."
|
||
}
|
||
},
|
||
{
|
||
"name": "5. Тот же ключ, другой слот → 422",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('422 Unprocessable Content', () => pm.response.to.have.status(422));",
|
||
"pm.test('reason = idempotency_key_reuse', () => {",
|
||
" pm.expect(pm.response.json().reason).to.eql('idempotency_key_reuse');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"key": "Idempotency-Key",
|
||
"value": "{{idem_key}}"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/{{other_slot_id}}/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"{{other_slot_id}}",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Ключ из шага 2, но слот другой. Молча вернуть найденный холд нельзя — клиент получил бы бронь на слот, которого не просил.\n\nЭто ошибка клиента, поэтому 422, а не 409."
|
||
}
|
||
},
|
||
{
|
||
"name": "6. Подтвердить холд → 200",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK', () => pm.response.to.have.status(200));",
|
||
"",
|
||
"const hold = pm.response.json();",
|
||
"",
|
||
"pm.test('статус confirmed', () => pm.expect(hold.status).to.eql('confirmed'));",
|
||
"pm.test('confirmed_at заполнен', () => pm.expect(hold.confirmed_at).to.not.be.null);"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/holds/{{hold_id}}/confirm",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"holds",
|
||
"{{hold_id}}",
|
||
"confirm"
|
||
]
|
||
},
|
||
"description": "Здесь место списывается окончательно, одним атомарным UPDATE с условием remaining > 0.\n\nПосле коммита сбрасывается кеш доступности — проверяем это следующим запросом."
|
||
}
|
||
},
|
||
{
|
||
"name": "7. Доступность — кеш сброшен",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK', () => pm.response.to.have.status(200));",
|
||
"",
|
||
"const target = Number(pm.collectionVariables.get('slot_id'));",
|
||
"const slot = pm.response.json().find((s) => s.slot_id === target);",
|
||
"",
|
||
"pm.test('remaining = 0 сразу, не дожидаясь истечения TTL', () => {",
|
||
" pm.expect(slot.remaining).to.eql(0);",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "GET",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/availability",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"availability"
|
||
]
|
||
},
|
||
"description": "remaining стал нулём немедленно, хотя TTL кеша 10 секунд. Это и есть инвалидация после подтверждения.\n\nСравни с шагом 2: создание холда кеш намеренно не сбрасывает."
|
||
}
|
||
},
|
||
{
|
||
"name": "8. Подтвердить повторно → 200",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK — операция идемпотентна', () => pm.response.to.have.status(200));",
|
||
"pm.test('статус остался confirmed', () => {",
|
||
" pm.expect(pm.response.json().status).to.eql('confirmed');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/holds/{{hold_id}}/confirm",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"holds",
|
||
"{{hold_id}}",
|
||
"confirm"
|
||
]
|
||
},
|
||
"description": "Второе подтверждение того же холда не списывает второе место. Проверить можно шагом 7: remaining так и остался нулём, а не ушёл в минус."
|
||
}
|
||
},
|
||
{
|
||
"name": "9. Отменить холд → 200",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK', () => pm.response.to.have.status(200));",
|
||
"",
|
||
"const hold = pm.response.json();",
|
||
"",
|
||
"pm.test('статус cancelled', () => pm.expect(hold.status).to.eql('cancelled'));",
|
||
"pm.test('cancelled_at заполнен', () => pm.expect(hold.cancelled_at).to.not.be.null);"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "DELETE",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/holds/{{hold_id}}",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"holds",
|
||
"{{hold_id}}"
|
||
]
|
||
},
|
||
"description": "Тела у запроса нет.\n\nХолд был подтверждён, значит место списывалось — и отмена его возвращает. Если бы отменялся неподтверждённый холд, remaining не изменился бы: списания не было."
|
||
}
|
||
},
|
||
{
|
||
"name": "10. Подтвердить отменённый → 409",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('409 Conflict', () => pm.response.to.have.status(409));",
|
||
"pm.test('reason = hold_cancelled', () => {",
|
||
" pm.expect(pm.response.json().reason).to.eql('hold_cancelled');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/holds/{{hold_id}}/confirm",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"holds",
|
||
"{{hold_id}}",
|
||
"confirm"
|
||
]
|
||
},
|
||
"description": "Отменённый холд подтвердить нельзя. Запрос корректен, но конфликтует с текущим состоянием ресурса — это семантика 409."
|
||
}
|
||
},
|
||
{
|
||
"name": "11. Доступность — место вернулось",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('200 OK', () => pm.response.to.have.status(200));",
|
||
"",
|
||
"const target = Number(pm.collectionVariables.get('slot_id'));",
|
||
"const slot = pm.response.json().find((s) => s.slot_id === target);",
|
||
"",
|
||
"pm.test('remaining вернулся к 1', () => pm.expect(slot.remaining).to.eql(1));",
|
||
"pm.test('remaining не превысил capacity', () => {",
|
||
" pm.expect(slot.remaining).to.be.at.most(slot.capacity);",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "GET",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/availability",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"availability"
|
||
]
|
||
},
|
||
"description": "Место снова доступно, и remaining не превысил capacity — сколько бы раз ни повторить отмену."
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"name": "Ошибки и валидация",
|
||
"description": "Отдельные запросы, порядок не важен.",
|
||
"item": [
|
||
{
|
||
"name": "Без Idempotency-Key → 422",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('422 Unprocessable Content', () => pm.response.to.have.status(422));",
|
||
"pm.test('ошибка про idempotency_key', () => {",
|
||
" pm.expect(pm.response.json().errors).to.have.property('idempotency_key');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/{{other_slot_id}}/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"{{other_slot_id}}",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Заголовок обязателен. Валидация отрабатывает до контроллера, поэтому в базу запрос вообще не идёт."
|
||
}
|
||
},
|
||
{
|
||
"name": "Idempotency-Key не UUID → 422",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('422 Unprocessable Content', () => pm.response.to.have.status(422));",
|
||
"pm.test('ошибка про idempotency_key', () => {",
|
||
" pm.expect(pm.response.json().errors).to.have.property('idempotency_key');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"key": "Idempotency-Key",
|
||
"value": "not-a-uuid"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/{{other_slot_id}}/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"{{other_slot_id}}",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Формат ключа проверяется правилом uuid. Случайная строка вместо UUID — ошибка клиента."
|
||
}
|
||
},
|
||
{
|
||
"name": "Несуществующий слот → 404",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('404 Not Found', () => pm.response.to.have.status(404));",
|
||
"pm.test('reason = not_found', () => {",
|
||
" pm.expect(pm.response.json().reason).to.eql('not_found');",
|
||
"});",
|
||
"pm.test('имя класса модели в ответ не утекает', () => {",
|
||
" pm.expect(pm.response.text()).to.not.include('App\\\\Models');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
},
|
||
{
|
||
"key": "Idempotency-Key",
|
||
"value": "{{$guid}}"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/slots/999/hold",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"slots",
|
||
"999",
|
||
"hold"
|
||
]
|
||
},
|
||
"description": "Аккуратный JSON без стектрейса и без имени класса модели."
|
||
}
|
||
},
|
||
{
|
||
"name": "Несуществующий холд → 404",
|
||
"event": [
|
||
{
|
||
"listen": "test",
|
||
"script": {
|
||
"type": "text/javascript",
|
||
"exec": [
|
||
"pm.test('404 Not Found', () => pm.response.to.have.status(404));",
|
||
"pm.test('reason = not_found', () => {",
|
||
" pm.expect(pm.response.json().reason).to.eql('not_found');",
|
||
"});"
|
||
]
|
||
}
|
||
}
|
||
],
|
||
"request": {
|
||
"method": "POST",
|
||
"header": [
|
||
{
|
||
"key": "Accept",
|
||
"value": "application/json"
|
||
}
|
||
],
|
||
"url": {
|
||
"raw": "{{base_url}}/holds/999999/confirm",
|
||
"host": [
|
||
"{{base_url}}"
|
||
],
|
||
"path": [
|
||
"holds",
|
||
"999999",
|
||
"confirm"
|
||
]
|
||
}
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|