diff --git a/README.md b/README.md index bc7469d..5c5e17a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,37 @@ У всех трёх одинаковое устройство входа. Форма `#login` с полями `#username` и `#password` и кнопкой `button.primary[data-test=login-button]`, флеш-сообщение `#flash`, выход по `/<курс>/logout`. Приложения описаны конфигом `apps` в `custom-server/src/ui-pages.ts`, страницы лежат фрагментами в `custom-server/assets/ui-pages/<курс>/`, префикс в них записан как `{{prefix}}`. +## Демонстрации HTTP для автоматизации + +Курс использует отдельные обработчики сессии, кеширования, событий и повторных запросов. Общие коллекции задач, пользователей и публикаций сохраняются. + +| Метод и путь | Результат | +| --- | --- | +| `GET /http-protocol/login-form` | Форма с учебным именем `student` | +| `POST /http-protocol/session` | Куки `session=hexlet-demo` на пять минут | +| `GET /http-protocol/session` | `Guest` без куки, `Welcome, student!` с учебной куки | +| `DELETE /http-protocol/session` | Удаление куки клиента | +| `GET /http-protocol/cache` | Расписание, `ETag` и `private, max-age=60` | +| `GET /http-protocol/cache/revalidate` | То же расписание с `no-cache` | +| `GET /http-protocol/cache/no-store` | То же расписание с `no-store` | +| `POST /http-protocol/webhooks/inboxes` | Новый приёмник, его `callback_url` и `events_url` | +| `POST /http-protocol/webhooks/inboxes/{id}/subscription` | Подписка с полученным `callback_url` | +| `POST /http-protocol/webhooks/inboxes/{id}/trigger` | Отправка учебного события настоящим HTTP-запросом в свой приёмник | +| `GET /http-protocol/webhooks/inboxes/{id}/events` | Сохранённые входящие сообщения и ответы приёмника | +| `POST /http-protocol/reliability/runs` | Новый отдельный опыт с `run_id` и `base_url` | +| `GET {base_url}/temporary` | Первый ответ 503 с `Retry-After: 1`, следующий 200 | +| `GET {base_url}/limited` | 429 с `Retry-After: 1`, после ожидания 200 | +| `GET {base_url}/slow` | Ответ через две секунды для проверки таймаута | +| `POST {base_url}/operations?lose_response=1` | Создание операции с обрывом связи до ответа | +| `GET {base_url}/operations` | Результаты созданных операций | +| `GET {base_url}/journal` | Связь попыток с результатами операций | + +Вебхуки принимают только подготовленные поля события. Подписка допускает приёмник этого же опыта, произвольные внешние адреса не вызываются. Реальный HTTP-запрос идёт на локальный порт приложения, за тем же публичным адресом. Получатель проверяет учебный `X-Webhook-Key: hexlet-demo-webhook` и подтверждает сохранение сообщения кодом 204. Этот общедоступный пример показывает форму проверки источника, настоящие учётные данные не используются. + +При создании операции поле `title` обязательно. Если `Idempotency-Key` повторяется с тем же названием, сервер возвращает существующую операцию. Другое название под тем же ключом даёт 409. Без ключа каждый запрос создаёт отдельную запись. Параметр `lose_response=1` закрывает соединение после записи новой операции; уже найденная по ключу операция возвращается обычным ответом. При обращении через Caddy первый обрыв превращается в ответ 502 без результата операции. Это тоже оставляет клиенту задачу проверить, было ли изменение выполнено. + +Приёмник и опыт живут 30 минут, данные хранятся только в памяти. Число опытов и размеры журналов ограничены. После истечения срока или перезапуска создаётся новый опыт. Фиксированный маркер сессии служит для демонстрации обмена куки, проверку настоящего аккаунта он не выполняет. + ## Prerequisites - [mise](https://mise.jdx.dev) (ставит Node, pnpm, Caddy и jactionlint по `mise.toml`) diff --git a/custom-server/assets/http-protocol/index.html b/custom-server/assets/http-protocol/index.html index 59cd05c..95a0377 100644 --- a/custom-server/assets/http-protocol/index.html +++ b/custom-server/assets/http-protocol/index.html @@ -13,6 +13,8 @@

It works!

  • Stream
  • HTML form
  • PNG image
  • +
  • Demo session
  • +
  • Cache example
  • diff --git a/custom-server/assets/http-protocol/login-form.html b/custom-server/assets/http-protocol/login-form.html new file mode 100644 index 0000000..d40bc8a --- /dev/null +++ b/custom-server/assets/http-protocol/login-form.html @@ -0,0 +1,16 @@ + + + + + + Demo session + + +

    Demo session

    +
    + + +
    +

    View session

    + + diff --git a/custom-server/src/demo-store.ts b/custom-server/src/demo-store.ts new file mode 100644 index 0000000..d0628c1 --- /dev/null +++ b/custom-server/src/demo-store.ts @@ -0,0 +1,31 @@ +import { randomBytes } from 'node:crypto'; + +// Данные отдельного опыта не меняют общие учебные коллекции. Хранилище ограничено +// по числу опытов и сроку жизни, чтобы публичный стенд не накапливал данные. +export function createDemoStore() { + const records = new Map(); + return { + create(value: T) { + const now = Date.now(); + for (const [id, record] of records) if (record.expires <= now) records.delete(id); + if (records.size >= 1000) return null; + const id = randomBytes(12).toString('hex'); + records.set(id, { value, expires: now + 30 * 60 * 1000 }); + return id; + }, + get(id: string) { + const record = records.get(id); + if (!record) return undefined; + if (record.expires <= Date.now()) { + records.delete(id); + return undefined; + } + return record.value; + }, + }; +} + +export const DEMO_ORIGIN = 'https://http.hexlet.app'; +export interface DemoParams { + id: string; +} diff --git a/custom-server/src/index.ts b/custom-server/src/index.ts index 42d6c54..9f42add 100644 --- a/custom-server/src/index.ts +++ b/custom-server/src/index.ts @@ -12,6 +12,9 @@ import rawConfig from '../../app.config.json' with { type: 'json' }; import setUpRpc from './rpc.ts'; import setUpBodyExamples from './body-examples.ts'; import setUpUiPages from './ui-pages.ts'; +import setUpSessionCacheExamples from './session-cache-examples.ts'; +import setUpWebhookExamples from './webhook-examples.ts'; +import setUpReliabilityExamples from './reliability-examples.ts'; import setUpSpec from './openapi-spec.ts'; // title в app.config.json нет, и документация всегда отдавалась без заголовка. @@ -115,6 +118,9 @@ export default async (app: FastifyInstance, _options: unknown) => { await app.register(setUpBodyExamples); await app.register(setUpUiPages); + await app.register(setUpSessionCacheExamples); + await app.register(setUpWebhookExamples); + await app.register(setUpReliabilityExamples); await setupDocs(app); diff --git a/custom-server/src/reliability-examples.ts b/custom-server/src/reliability-examples.ts new file mode 100644 index 0000000..e6f5547 --- /dev/null +++ b/custom-server/src/reliability-examples.ts @@ -0,0 +1,119 @@ +import { setTimeout as wait } from 'node:timers/promises'; +import type { FastifyInstance } from 'fastify'; +import { createDemoStore, DEMO_ORIGIN } from './demo-store.ts'; +import type { DemoParams } from './demo-store.ts'; + +interface Operation { + id: number; + title: string; +} +interface Run { + temporaryAttempts: number; + slowPending: boolean; + nextAllowedAt: number | null; + operations: Operation[]; + keys: Map; + journal: { attempt: number; operation_id: number; result: string; response: string }[]; +} +export default async function setUpReliabilityExamples(app: FastifyInstance) { + const runs = createDemoStore(); + const prefix = '/http-protocol/reliability/runs'; + app.post(prefix, (_request, reply) => { + const id = runs.create({ + temporaryAttempts: 0, + slowPending: false, + nextAllowedAt: null, + operations: [], + keys: new Map(), + journal: [], + }); + if (!id) return reply.code(503).send({ error: 'demo_capacity_reached' }); + return reply + .code(201) + .header('Cache-Control', 'no-store') + .send({ run_id: id, base_url: `${DEMO_ORIGIN}${prefix}/${id}` }); + }); + app.get<{ Params: DemoParams }>(`${prefix}/:id/temporary`, (request, reply) => { + const run = runs.get(request.params.id); + if (!run) return reply.code(404).send({ error: 'demo_not_found' }); + reply.header('Cache-Control', 'no-store'); + if (run.temporaryAttempts++ === 0) + return reply.code(503).header('Retry-After', '1').send({ error: 'temporarily_unavailable' }); + return { status: 'ready' }; + }); + app.get<{ Params: DemoParams }>(`${prefix}/:id/limited`, (request, reply) => { + const run = runs.get(request.params.id); + if (!run) return reply.code(404).send({ error: 'demo_not_found' }); + reply.header('Cache-Control', 'no-store'); + run.nextAllowedAt ??= Date.now() + 1000; + if (Date.now() < run.nextAllowedAt) + return reply.code(429).header('Retry-After', '1').send({ error: 'rate_limit_exceeded' }); + return { status: 'ready' }; + }); + app.get<{ Params: DemoParams }>(`${prefix}/:id/slow`, async (request, reply) => { + const run = runs.get(request.params.id); + if (!run) return reply.code(404).send({ error: 'demo_not_found' }); + if (run.slowPending) return reply.code(429).send({ error: 'slow_request_in_progress' }); + run.slowPending = true; + try { + await wait(2000); + return reply.header('Cache-Control', 'no-store').send({ status: 'ready' }); + } finally { + run.slowPending = false; + } + }); + app.get<{ Params: DemoParams }>(`${prefix}/:id/operations`, (request, reply) => { + const run = runs.get(request.params.id); + if (!run) return reply.code(404).send({ error: 'demo_not_found' }); + return reply.header('Cache-Control', 'no-store').send({ operations: run.operations }); + }); + app.get<{ Params: DemoParams }>(`${prefix}/:id/journal`, (request, reply) => { + const run = runs.get(request.params.id); + if (!run) return reply.code(404).send({ error: 'demo_not_found' }); + return reply.header('Cache-Control', 'no-store').send({ journal: run.journal }); + }); + app.post<{ Params: DemoParams; Querystring: { lose_response?: string }; Body: { title?: string } }>( + `${prefix}/:id/operations`, + (request, reply) => { + const run = runs.get(request.params.id); + if (!run) return reply.code(404).send({ error: 'demo_not_found' }); + const title = request.body?.title; + const key = request.headers['idempotency-key']; + if ( + typeof title !== 'string' || + !title.trim() || + title.length > 120 || + (key !== undefined && (typeof key !== 'string' || key.length > 80 || !key)) + ) { + return reply.code(422).send({ error: 'invalid_operation' }); + } + const previous = key === undefined ? undefined : run.keys.get(key); + if (previous && previous.title !== title) return reply.code(409).send({ error: 'idempotency_key_conflict' }); + if (run.journal.length >= 40 || (!previous && run.operations.length >= 20)) { + return reply.code(429).send({ error: 'demo_operation_limit' }); + } + const operation = previous ?? { id: run.operations.length + 1, title }; + if (!previous) { + run.operations.push(operation); + if (key !== undefined) run.keys.set(key, operation); + } + const lose = !previous && request.query.lose_response === '1'; + run.journal.push({ + attempt: run.journal.length + 1, + operation_id: operation.id, + result: previous ? 'reused' : 'created', + response: lose ? 'connection_closed' : 'sent', + }); + if (lose) { + // Операция уже зафиксирована, но клиент не получает HTTP-ответ. + reply.hijack(); + reply.raw.destroy(); + return reply; + } + return reply + .code(previous ? 200 : 201) + .header('Cache-Control', 'no-store') + .send(operation); + }, + ); +} diff --git a/custom-server/src/session-cache-examples.ts b/custom-server/src/session-cache-examples.ts new file mode 100644 index 0000000..2e0dd16 --- /dev/null +++ b/custom-server/src/session-cache-examples.ts @@ -0,0 +1,56 @@ +import type { FastifyInstance } from 'fastify'; + +const OFFICE_HOURS = 'Office hours: 09:00-18:00'; +const VERSION = '"office-v1"'; + +export default async function setUpSessionCacheExamples(app: FastifyInstance) { + app.get('/http-protocol/login-form', (_request, reply) => reply.sendFile('http-protocol/login-form.html')); + app.get('/http-protocol/session', (request, reply) => { + const text = request.cookies.session === 'hexlet-demo' ? 'Welcome, student!' : 'Guest'; + return reply.type('text/plain; charset=utf-8').header('Cache-Control', 'no-store').send(text); + }); + app.post<{ Body: { username?: string } }>('/http-protocol/session', (request, reply) => { + if (request.body?.username !== 'student') { + return reply.code(400).type('text/plain; charset=utf-8').send('Use the demo username student'); + } + return reply + .setCookie('session', 'hexlet-demo', { + path: '/http-protocol', + maxAge: 300, + httpOnly: true, + secure: true, + sameSite: 'lax', + }) + .type('text/plain; charset=utf-8') + .header('Cache-Control', 'no-store') + .send('Session started'); + }); + app.delete('/http-protocol/session', (_request, reply) => + reply + .clearCookie('session', { + path: '/http-protocol', + httpOnly: true, + secure: true, + sameSite: 'lax', + }) + .type('text/plain; charset=utf-8') + .header('Cache-Control', 'no-store') + .send('Session ended'), + ); + + for (const [url, policy] of [ + ['/http-protocol/cache', 'private, max-age=60'], + ['/http-protocol/cache/revalidate', 'no-cache'], + ['/http-protocol/cache/no-store', 'no-store'], + ]) { + app.get(url, (request, reply) => { + reply.header('Cache-Control', policy); + if (policy !== 'no-store') { + reply.header('ETag', VERSION); + const matches = request.headers['if-none-match']?.split(',').map((tag) => tag.trim().replace(/^W\//, '')); + if (matches?.includes(VERSION) || matches?.includes('*')) return reply.code(304).send(); + } + return reply.type('text/plain; charset=utf-8').send(OFFICE_HOURS); + }); + } +} diff --git a/custom-server/src/webhook-examples.ts b/custom-server/src/webhook-examples.ts new file mode 100644 index 0000000..36e256c --- /dev/null +++ b/custom-server/src/webhook-examples.ts @@ -0,0 +1,112 @@ +import type { FastifyInstance } from 'fastify'; +import { createDemoStore, DEMO_ORIGIN } from './demo-store.ts'; +import type { DemoParams } from './demo-store.ts'; + +interface Event { + event_id: string; + type: string; + data: { id: number; title: string }; +} +interface Inbox { + subscribed: boolean; + delivering: boolean; + nextEvent: number; + events: { method: string; headers: Record; body: Event; response_status: number }[]; +} + +export default async function setUpWebhookExamples(app: FastifyInstance) { + const inboxes = createDemoStore(); + const prefix = '/http-protocol/webhooks/inboxes'; + app.post(prefix, (_request, reply) => { + const id = inboxes.create({ subscribed: false, delivering: false, nextEvent: 1, events: [] }); + if (!id) return reply.code(503).send({ error: 'demo_capacity_reached' }); + return reply + .code(201) + .header('Cache-Control', 'no-store') + .send({ + inbox_id: id, + callback_url: `${DEMO_ORIGIN}${prefix}/${id}`, + events_url: `${DEMO_ORIGIN}${prefix}/${id}/events`, + }); + }); + app.post<{ Params: DemoParams; Body: { callback_url?: string } }>(`${prefix}/:id/subscription`, (request, reply) => { + const inbox = inboxes.get(request.params.id); + if (!inbox) return reply.code(404).send({ error: 'demo_not_found' }); + const callback = `${DEMO_ORIGIN}${prefix}/${request.params.id}`; + // Приёмник выбирается из созданного опыта. Произвольные внешние адреса стенд + // не вызывает, иначе учебная подписка позволяла бы обращаться во внутреннюю сеть. + if (request.body?.callback_url !== callback) return reply.code(422).send({ error: 'use_the_inbox_callback_url' }); + inbox.subscribed = true; + return { event: 'task.created', callback_url: callback }; + }); + app.get<{ Params: DemoParams }>(`${prefix}/:id/events`, (request, reply) => { + const inbox = inboxes.get(request.params.id); + if (!inbox) return reply.code(404).send({ error: 'demo_not_found' }); + return reply.header('Cache-Control', 'no-store').send({ events: inbox.events }); + }); + app.post<{ Params: DemoParams; Body: Event }>(`${prefix}/:id`, (request, reply) => { + const inbox = inboxes.get(request.params.id); + if (!inbox) return reply.code(404).send({ error: 'demo_not_found' }); + if (request.headers['x-webhook-key'] !== 'hexlet-demo-webhook') + return reply.code(401).send({ error: 'invalid_webhook_key' }); + const event = request.body; + if ( + !event || + typeof event.event_id !== 'string' || + !event.event_id.trim() || + event.event_id.length > 80 || + event.type !== 'task.created' || + !event.data || + typeof event.data.title !== 'string' || + event.data.title.length > 120 || + !Number.isInteger(event.data.id) || + event.data.id < 1 || + (request.headers['content-type']?.length ?? 0) > 200 + ) { + return reply.code(422).send({ error: 'invalid_event' }); + } + inbox.events.push({ + method: 'POST', + headers: { + 'content-type': request.headers['content-type'] ?? '', + 'x-webhook-key': 'hexlet-demo-webhook', + }, + body: { event_id: event.event_id, type: event.type, data: { id: event.data.id, title: event.data.title } }, + response_status: 204, + }); + if (inbox.events.length > 30) inbox.events.shift(); + return reply.code(204).send(); + }); + app.post<{ Params: DemoParams }>(`${prefix}/:id/trigger`, async (request, reply) => { + const inbox = inboxes.get(request.params.id); + if (!inbox) return reply.code(404).send({ error: 'demo_not_found' }); + if (!inbox.subscribed) return reply.code(409).send({ error: 'subscription_required' }); + if (inbox.delivering) return reply.code(409).send({ error: 'delivery_in_progress' }); + if (inbox.nextEvent > 30) return reply.code(429).send({ error: 'demo_event_limit' }); + const address = app.server.address(); + if (!address || typeof address === 'string') return reply.code(503).send({ error: 'server_not_listening' }); + const event: Event = { + event_id: `evt-${inbox.nextEvent++}`, + type: 'task.created', + data: { id: 101, title: 'Check the webhook' }, + }; + // Настоящий HTTP-обмен с приёмником того же учебного сервера, без SSRF. + inbox.delivering = true; + try { + const response = await fetch(`http://127.0.0.1:${address.port}${prefix}/${request.params.id}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Key': 'hexlet-demo-webhook', + Host: 'http.hexlet.app', + }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(3000), + }); + await response.arrayBuffer(); + return reply.code(201).send({ event_id: event.event_id, receiver_status: response.status }); + } finally { + inbox.delivering = false; + } + }); +} diff --git a/test/course-demos.test.ts b/test/course-demos.test.ts new file mode 100644 index 0000000..8c726b8 --- /dev/null +++ b/test/course-demos.test.ts @@ -0,0 +1,161 @@ +import { setTimeout as wait } from 'node:timers/promises'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp, createClient } from './helpers.ts'; + +let app: FastifyInstance; +let client: ReturnType; +let origin: string; +beforeAll(async () => { + app = await buildApp(); + origin = await app.listen({ host: '127.0.0.1', port: 0 }); + client = createClient(app); +}); +afterAll(() => app.close()); + +describe('Куки и условные запросы', () => { + it('вход выдаёт куки, которая меняет ответ страницы сессии', async () => { + expect((await client.send('/http-protocol/session')).body).toBe('Guest'); + const login = await client.send('/http-protocol/session', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: 'username=student', + }); + expect(login.status).toBe(200); + expect(login.body).toBe('Session started'); + expect(login.headers['set-cookie']).toContain('session=hexlet-demo'); + expect(login.headers['set-cookie']).toContain('Max-Age=300'); + expect(login.headers['set-cookie']).toContain('HttpOnly; Secure; SameSite=Lax'); + const member = await client.send('/http-protocol/session', { headers: { cookie: 'session=hexlet-demo' } }); + expect(member.body).toBe('Welcome, student!'); + expect(member.headers['cache-control']).toBe('no-store'); + expect((await client.send('/http-protocol/session', { method: 'DELETE' })).headers['set-cookie']).toContain( + 'Max-Age=0', + ); + }); + it('подтверждает версию без тела и отличает no-cache от no-store', async () => { + const first = await client.send('/http-protocol/cache'); + expect(first.body).toBe('Office hours: 09:00-18:00'); + expect(first.headers['content-length']).toBe('25'); + expect(first.headers['cache-control']).toBe('private, max-age=60'); + const match = await client.send('/http-protocol/cache', { headers: { 'if-none-match': first.headers.etag } }); + expect(match.status).toBe(304); + expect(match.body).toBe(''); + expect(match.headers['content-length']).toBeUndefined(); + expect((await client.send('/http-protocol/cache', { headers: { 'if-none-match': '"old-version"' } })).status).toBe( + 200, + ); + expect((await client.send('/http-protocol/cache/revalidate')).headers['cache-control']).toBe('no-cache'); + const noStore = await client.send('/http-protocol/cache/no-store', { + headers: { 'if-none-match': first.headers.etag }, + }); + expect(noStore.status).toBe(200); + expect(noStore.headers['cache-control']).toBe('no-store'); + expect(noStore.headers.etag).toBeUndefined(); + }); +}); + +describe('Доставка вебхука', () => { + it('требует подписку и делает настоящий HTTP-запрос в отдельный приёмник', async () => { + const created = await client.json('/http-protocol/webhooks/inboxes', 'POST'); + const prefix = `/http-protocol/webhooks/inboxes/${created.body.inbox_id}`; + expect(created.status).toBe(201); + expect((await client.json(`${prefix}/trigger`, 'POST')).status).toBe(409); + const unsafe = await client.json(`${prefix}/subscription`, 'POST', { callback_url: 'http://127.0.0.1/admin' }); + expect(unsafe.status).toBe(422); + expect( + (await client.json(`${prefix}/subscription`, 'POST', { callback_url: created.body.callback_url })).status, + ).toBe(200); + const delivered = await client.json(`${prefix}/trigger`, 'POST'); + expect(delivered.body).toEqual({ event_id: 'evt-1', receiver_status: 204 }); + const saved = await client.send(`${prefix}/events`); + expect(saved.body.events).toHaveLength(1); + expect(saved.body.events[0]).toMatchObject({ + method: 'POST', + response_status: 204, + body: { event_id: 'evt-1', type: 'task.created', data: { id: 101, title: 'Check the webhook' } }, + }); + const other = await client.json('/http-protocol/webhooks/inboxes', 'POST'); + expect((await client.send(`/http-protocol/webhooks/inboxes/${other.body.inbox_id}/events`)).body.events).toEqual( + [], + ); + }); + it('отклоняет сообщение без учебного подтверждения источника', async () => { + const inbox = await client.json('/http-protocol/webhooks/inboxes', 'POST'); + const denied = await client.json(`/http-protocol/webhooks/inboxes/${inbox.body.inbox_id}`, 'POST', { + event_id: 'evt-1', + type: 'task.created', + data: { id: 101, title: 'Check the webhook' }, + }); + expect(denied.status).toBe(401); + }); +}); + +const createRun = async () => { + const reply = await client.json('/http-protocol/reliability/runs', 'POST'); + expect(reply.status).toBe(201); + return `/http-protocol/reliability/runs/${reply.body.run_id}`; +}; + +describe('Управляемые сбои HTTP', () => { + it('временный отказ не затрагивает опыт другого учащегося', async () => { + const first = await createRun(); + const second = await createRun(); + const refused = await client.send(`${first}/temporary`); + expect(refused.status).toBe(503); + expect(refused.headers['retry-after']).toBe('1'); + expect((await client.send(`${first}/temporary`)).status).toBe(200); + expect((await client.send(`${second}/temporary`)).status).toBe(503); + }); + it('ограничение частоты снимается после задержки из Retry-After', async () => { + const prefix = await createRun(); + const refused = await client.send(`${prefix}/limited`); + expect(refused.status).toBe(429); + expect(refused.headers['retry-after']).toBe('1'); + expect((await client.send(`${prefix}/limited`)).status).toBe(429); + await wait(1100); + expect((await client.send(`${prefix}/limited`)).status).toBe(200); + }); + it('повтор после обрыва возвращает сохранённую операцию по тому же ключу', async () => { + const prefix = await createRun(); + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'lesson-operation-1' }, + body: JSON.stringify({ title: 'Check the repeat' }), + }; + await expect(fetch(`${origin}${prefix}/operations?lose_response=1`, options)).rejects.toThrow('fetch failed'); + const repeat = await fetch(`${origin}${prefix}/operations?lose_response=1`, options); + expect(repeat.status).toBe(200); + expect(await repeat.json()).toEqual({ id: 1, title: 'Check the repeat' }); + expect((await client.send(`${prefix}/operations`)).body.operations).toHaveLength(1); + const journal = (await client.send(`${prefix}/journal`)).body.journal; + expect(journal.map((entry: { result: string }) => entry.result)).toEqual(['created', 'reused']); + expect( + ( + await client.json( + `${prefix}/operations`, + 'POST', + { title: 'Different' }, + { + 'Idempotency-Key': 'lesson-operation-1', + }, + ) + ).status, + ).toBe(409); + }); + it('два запроса без ключа после обрыва создают две операции', async () => { + const prefix = await createRun(); + const options = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"title":"Check the repeat"}', + }; + await expect(fetch(`${origin}${prefix}/operations?lose_response=1`, options)).rejects.toThrow('fetch failed'); + expect((await client.json(`${prefix}/operations`, 'POST', { title: 'Check the repeat' })).status).toBe(201); + expect((await client.send(`${prefix}/operations`)).body.operations).toHaveLength(2); + }); + it('прерывание ожидания клиентом не требует HTTP-кода от сервера', async () => { + const prefix = await createRun(); + await expect(fetch(`${origin}${prefix}/slow`, { signal: AbortSignal.timeout(50) })).rejects.toThrow(/timeout/i); + }); +});