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