slot-booking/docs/postman/slot-booking-api.postman_collection.json
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

682 lines
25 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"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"
]
}
}
}
]
}
]
}