From b94113a19bd5b7648a8d8654e53807189cedd2c8 Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Fri, 14 Aug 2026 16:35:42 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(http-api):=20FEEDBACK-36=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=86=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мок http-api работал в динамическом режиме, и данные брались у faker'а. Отсюда две проблемы, обе видны в уроках курса. Первая: данные не общие. Урок kinds сравнивает REST и RPC на одних и тех же задачах, но RPC отдавал три задачи из rpc.js, а REST при каждом запросе новую латинскую мешанину. Студент видел рядом несопоставимые ответы. Вторая: ответы не соответствовали спецификации. Модели объявляют uint16, то есть 0..65535, а приходило `"id":-39177245` и `"total":-33234336`. Динамический режим этого ограничения не знает. Теперь ответы берутся из примеров спецификации: моделям http-api добавлены `@example`, а prism для этой спецификации запускается без `-d`. Коды 404, 405, 422 и 401, на которых построены самостоятельные, статичный режим сохраняет. Набор задач вынесен в custom-server/src/data/tasks.js, чтобы у RPC и примеров спецификации был один текст, а не две копии в разных файлах. Примеры подогнаны под то, что уроки уже печатают: пользователи 1 и 2 совпадают с уроком example, токен /login с уроком authentication. Пользователь 1 это max@hotmail.com, под которым логинятся в самостоятельной, и посты принадлежат ему же, поэтому /posts и /users/1/posts не противоречат друг другу. Три остальные спецификации остаются на `-d`: примеров в них нет, и статичный режим отдал бы заглушки вместо данных. Co-Authored-By: Claude Opus 5 (1M context) --- bin/start.sh | 9 ++++++- custom-server/src/data/tasks.js | 28 ++++++++++++++++++++++ custom-server/src/rpc.js | 21 +---------------- typespec/http-api/models/auth.tsp | 5 ++++ typespec/http-api/models/comment.tsp | 18 ++++++++++++++ typespec/http-api/models/course.tsp | 15 ++++++++++++ typespec/http-api/models/post.tsp | 35 ++++++++++++++++++++++++++++ typespec/http-api/models/task.tsp | 34 +++++++++++++++++++++++++++ typespec/http-api/models/user.tsp | 26 +++++++++++++++++++++ 9 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 custom-server/src/data/tasks.js diff --git a/bin/start.sh b/bin/start.sh index 49e189a..7c5d296 100755 --- a/bin/start.sh +++ b/bin/start.sh @@ -23,7 +23,14 @@ stop_all() { trap 'stop_all' INT TERM -start_service prism-http-api "npx prism mock --multiprocess=false -d --json-schema-faker-fillProperties=false -p 4011 --host 0.0.0.0 ./tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml" +# http-api идёт без -d, то есть статичным моком: ответы берутся из примеров +# спецификации, а не генерируются faker'ом. Динамический режим не смотрит на +# uint16 и отдавал отрицательные id, а данные при каждом запросе были новые, +# из-за чего уроки курса http-api не могли на них опираться. Коды 404, 405, 422 +# и 401 статичный режим сохраняет, на них построен урок kinds. +# Остальные три спецификации остаются на -d осознанно: примеров в них нет, и +# статичный режим отдал бы вместо данных заглушки вида "string". +start_service prism-http-api "npx prism mock --multiprocess=false --json-schema-faker-fillProperties=false -p 4011 --host 0.0.0.0 ./tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml" start_service prism-http-protocol "npx prism mock --multiprocess=false -d --json-schema-faker-fillProperties=false -p 4012 --host 0.0.0.0 ./tsp-output/http-protocol/@typespec/openapi3/openapi.1.0.yaml" start_service prism-js-playwright "npx prism mock --multiprocess=false -d --json-schema-faker-fillProperties=false -p 4013 --host 0.0.0.0 ./tsp-output/js-playwright/@typespec/openapi3/openapi.1.0.yaml" start_service prism-postman "npx prism mock --multiprocess=false -d --json-schema-faker-fillProperties=false -p 4014 --host 0.0.0.0 ./tsp-output/postman/@typespec/openapi3/openapi.1.0.yaml" diff --git a/custom-server/src/data/tasks.js b/custom-server/src/data/tasks.js new file mode 100644 index 0000000..371eeec --- /dev/null +++ b/custom-server/src/data/tasks.js @@ -0,0 +1,28 @@ +// Набор задач, который отдаёт JSON-RPC эндпоинт. +// +// Те же самые задачи приведены примерами в спецификации, в моделях Task и Tasks +// (typespec/http-api/models/task.tsp), откуда их берёт статичный мок prism для +// REST-маршрутов /tasks. Урок kinds курса http-api сравнивает REST и RPC на +// одних и тех же данных, поэтому расхождение между этими двумя местами ломает +// урок. Совпадение проверяется прогоном bin/smoke-test.js, то есть правка +// здесь без правки спецификации свалит `make test`. +export default [ + { + id: 1, + title: 'Опубликовать курс по основам JavaScript', + description: 'Автор подготовил курс по JavaScript. Нужно его опубликовать', + status: 'Backlog', + }, + { + id: 2, + title: 'Записать скринкаст про HTTP API', + description: 'Показать, чем REST отличается от RPC', + status: 'In Progress', + }, + { + id: 3, + title: 'Обновить документацию', + description: 'Описать эндпоинт /rpc в спецификации', + status: 'Done', + }, +]; diff --git a/custom-server/src/rpc.js b/custom-server/src/rpc.js index 484cdb3..0a76655 100644 --- a/custom-server/src/rpc.js +++ b/custom-server/src/rpc.js @@ -2,26 +2,7 @@ // Shows the RPC style next to the REST routes served by the prism mock: // one endpoint, always POST, errors live in the body and the status is always 200. -const tasks = [ - { - id: 1, - title: 'Опубликовать курс по основам JavaScript', - description: 'Автор подготовил курс по JavaScript. Нужно его опубликовать', - status: 'Backlog', - }, - { - id: 2, - title: 'Записать скринкаст про HTTP API', - description: 'Показать, чем REST отличается от RPC', - status: 'In Progress', - }, - { - id: 3, - title: 'Обновить документацию', - description: 'Описать эндпоинт /rpc в спецификации', - status: 'Done', - }, -]; +import tasks from './data/tasks.js'; // Negative codes are the protocol level ones defined by the JSON-RPC spec. // Application errors like "task not found" get positive codes chosen by the API itself. diff --git a/typespec/http-api/models/auth.tsp b/typespec/http-api/models/auth.tsp index c1b89d0..802d393 100644 --- a/typespec/http-api/models/auth.tsp +++ b/typespec/http-api/models/auth.tsp @@ -6,6 +6,11 @@ model AuthData { password: string; } +// Этот же токен приведён в уроке authentication как пример ответа /login. +// Сервер демонстрационный и значение не проверяет, важна только форма. +@example(#{ + token: "r4AR4Fo0j29s9mFk4IUVA2rGTQmIrHWlioifaJLSQQYHbTXHxtSLFUVp8PANrRoAb7fgkSsbN7lt4a86pcJ07ivUpxBLyyCHaY4Pp9I7hRPphCHM7xpZ1om1", +}) model AuthToken { @minLength(1) token: string; diff --git a/typespec/http-api/models/comment.tsp b/typespec/http-api/models/comment.tsp index a6086b4..e9049d7 100644 --- a/typespec/http-api/models/comment.tsp +++ b/typespec/http-api/models/comment.tsp @@ -12,6 +12,14 @@ model EditCommentDto { body?: string; } +// Все комментарии относятся к посту 1, по той же причине, что и посты у одного +// автора: статичный мок отдаёт один список и на /comments, и на /posts/1/comments. +@example(#{ + id: 1, + authorId: 2, + postId: 1, + body: "Спасибо, наконец понял, зачем нужны заголовки", +}) model Comment { @key id: uint16; @@ -23,6 +31,16 @@ model Comment { body: string; } +@example(#{ + comments: #[ + #{ id: 1, authorId: 2, postId: 1, body: "Спасибо, наконец понял, зачем нужны заголовки" }, + #{ id: 2, authorId: 3, postId: 1, body: "А где почитать про кеширование подробнее?" }, + #{ id: 3, authorId: 4, postId: 1, body: "Схема запроса очень наглядная" } + ], + total: 3, + skip: 0, + limit: 30, +}) model Comments { comments: Comment[]; total: uint16; diff --git a/typespec/http-api/models/course.tsp b/typespec/http-api/models/course.tsp index 1b576fb..16902be 100644 --- a/typespec/http-api/models/course.tsp +++ b/typespec/http-api/models/course.tsp @@ -15,6 +15,11 @@ model EditCourseDto { description?: string; } +@example(#{ + id: 1, + title: "HTTP API", + description: "Учимся проектировать и вызывать API по HTTP", +}) model Course { @key id: uint16; @@ -27,6 +32,16 @@ model Course { } +@example(#{ + courses: #[ + #{ id: 1, title: "HTTP API", description: "Учимся проектировать и вызывать API по HTTP" }, + #{ id: 2, title: "Протокол HTTP", description: "Разбираем запрос, ответ, коды и заголовки" }, + #{ id: 3, title: "Основы JavaScript", description: "Первый язык программирования с нуля" } + ], + total: 3, + skip: 0, + limit: 30, +}) model Courses { courses: Course[]; total: uint16; diff --git a/typespec/http-api/models/post.tsp b/typespec/http-api/models/post.tsp index aade19b..427ea8a 100644 --- a/typespec/http-api/models/post.tsp +++ b/typespec/http-api/models/post.tsp @@ -14,6 +14,16 @@ model EditPostDto { body?: string; } +// Все посты принадлежат автору 1. Мок статичный и не умеет отбирать записи по +// пути, поэтому /posts и /users/1/posts отдают один и тот же список, и общий +// автор не даёт им противоречить друг другу. Урок authentication показывает, +// что сервер подставляет authorId сам, узнав пользователя по токену. +@example(#{ + id: 1, + authorId: 1, + title: "Как устроен HTTP", + body: "Разбираем запрос и ответ по частям: строка запроса, заголовки, тело", +}) model Post { @key id: uint16; @@ -27,6 +37,31 @@ model Post { body: string; } +@example(#{ + posts: #[ + #{ + id: 1, + authorId: 1, + title: "Как устроен HTTP", + body: "Разбираем запрос и ответ по частям: строка запроса, заголовки, тело", + }, + #{ + id: 2, + authorId: 1, + title: "Коды ответов на практике", + body: "Чем 401 отличается от 403 и почему 404 приходит чаще остальных", + }, + #{ + id: 3, + authorId: 1, + title: "REST и RPC", + body: "Один и тот же список задач двумя разными способами", + } + ], + total: 3, + skip: 0, + limit: 30, +}) model Posts { posts: Post[]; total: uint16; diff --git a/typespec/http-api/models/task.tsp b/typespec/http-api/models/task.tsp index 16f7c9b..b03e900 100644 --- a/typespec/http-api/models/task.tsp +++ b/typespec/http-api/models/task.tsp @@ -18,6 +18,15 @@ model EditTaskDto { status?: Status; } +// Набор задач продублирован в JSON-RPC эндпоинте, см. custom-server/src/data/tasks.js. +// Урок kinds курса http-api сравнивает REST и RPC на одних и тех же данных, +// поэтому расхождение здесь ломает урок. Совпадение проверяет bin/smoke-test.js. +@example(#{ + id: 1, + title: "Опубликовать курс по основам JavaScript", + description: "Автор подготовил курс по JavaScript. Нужно его опубликовать", + status: Status.Backlog, +}) model Task { @key id: uint16; @@ -31,6 +40,31 @@ model Task { status: Status; } +@example(#{ + tasks: #[ + #{ + id: 1, + title: "Опубликовать курс по основам JavaScript", + description: "Автор подготовил курс по JavaScript. Нужно его опубликовать", + status: Status.Backlog, + }, + #{ + id: 2, + title: "Записать скринкаст про HTTP API", + description: "Показать, чем REST отличается от RPC", + status: Status.InProgress, + }, + #{ + id: 3, + title: "Обновить документацию", + description: "Описать эндпоинт /rpc в спецификации", + status: Status.Done, + } + ], + total: 3, + skip: 0, + limit: 30, +}) model Tasks { tasks: Task[]; total: uint16; diff --git a/typespec/http-api/models/user.tsp b/typespec/http-api/models/user.tsp index c391a4d..b372f2c 100644 --- a/typespec/http-api/models/user.tsp +++ b/typespec/http-api/models/user.tsp @@ -23,6 +23,15 @@ model EditUserDto { lastName?: string; } +// Пользователь 1 это max@hotmail.com, тот же, под которым логинятся в уроке +// authentication. Первые два пользователя списка приведены в уроке example +// дословно, поэтому имена и адреса здесь менять нельзя. +@example(#{ + id: 1, + email: "max@hotmail.com", + firstName: "Allison", + lastName: "Bernier", +}) model User { @key id: uint16; @@ -37,6 +46,23 @@ model User { lastName: string; } +@example(#{ + users: #[ + #{ id: 1, email: "max@hotmail.com", firstName: "Allison", lastName: "Bernier" }, + #{ id: 2, email: "Colt97@yahoo.com", firstName: "Hudson", lastName: "Schowalter" }, + #{ id: 3, email: "Reyna_Bahringer@gmail.com", firstName: "Reyna", lastName: "Bahringer" }, + #{ id: 4, email: "Marcus.Kunde@hotmail.com", firstName: "Marcus", lastName: "Kunde" }, + #{ id: 5, email: "Elena_Padberg@yahoo.com", firstName: "Elena", lastName: "Padberg" }, + #{ id: 6, email: "Oscar.Runolfsson@gmail.com", firstName: "Oscar", lastName: "Runolfsson" }, + #{ id: 7, email: "Nadia_Hessel@outlook.com", firstName: "Nadia", lastName: "Hessel" }, + #{ id: 8, email: "Felix.Wiegand@yahoo.com", firstName: "Felix", lastName: "Wiegand" }, + #{ id: 9, email: "Iris_Turcotte@gmail.com", firstName: "Iris", lastName: "Turcotte" }, + #{ id: 10, email: "Damian.Volkman@hotmail.com", firstName: "Damian", lastName: "Volkman" } + ], + total: 10, + skip: 0, + limit: 30, +}) model Users { users: User[]; total: uint16; From c8dd6807c01ae4798086414cfd326e1b17b5fbc8 Mon Sep 17 00:00:00 2001 From: Nikolay Gagarinov Date: Fri, 14 Aug 2026 16:35:53 +0500 Subject: [PATCH 2/2] =?UTF-8?q?test(http-api):=20FEEDBACK-36=20=D0=B4?= =?UTF-8?q?=D1=8B=D0=BC=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=BD=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=83=D0=BB=D0=BB=D1=80=D0=B5=D0=BA=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make test` печатал «no tests». Между тем сломанную сборку в этом репозитории заметили только через десять дней, и заметили по тому, что на проде работает образ старее main. Прогон поднимает статичный мок и приложение и проверяет то, что уже ломалось незаметно: REST и RPC отдают один и тот же набор задач, коды 404, 405, 422, 401 и 201 на месте, ошибка RPC приходит в теле с кодом 200, числа в ответах не выходят за uint16. Расхождение между data/tasks.js и примерами спецификации валит прогон, то есть одну копию поправить молча нельзя. Caddy в прогоне не участвует, его нет в раннере. Мок и приложение опрашиваются напрямую по своим портам, как это делает Caddy, срезая префикс. Заодно сборка запускается на пуллреквестах. Публикацию образа это не задевает, у джобы deploy стоит условие на push. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 8 + Makefile | 2 +- bin/smoke-test.js | 265 ++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100755 bin/smoke-test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb50d16..7a7455b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,14 @@ on: push: branches: - main + # Сборка проверяется и на пуллреквестах. Без этого поломка попадает в main и + # обнаруживается только тем, что образ перестал собираться, а на проде + # продолжает работать прежний. Так уже вышло: сборка стояла сломанной с + # 3 августа. Публикацию образа это не задевает, у джобы deploy стоит + # условие на push. + pull_request: + branches: + - main jobs: build: diff --git a/Makefile b/Makefile index 55cc42e..f28f07a 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ start: ./bin/start.sh test: - echo no tests + node ./bin/smoke-test.js update-deps: npx ncu -u diff --git a/bin/smoke-test.js b/bin/smoke-test.js new file mode 100755 index 0000000..ee9687a --- /dev/null +++ b/bin/smoke-test.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node + +// Дымовой прогон http-api: поднимает статичный мок prism и приложение, после +// чего проверяет ответы запросами. +// +// Проверяется три вещи, каждая из которых уже ломалась незаметно. +// +// Первое: REST и RPC отдают одни и те же задачи. Урок kinds курса http-api +// сравнивает два стиля на одних данных, а живут они в двух местах, в примерах +// спецификации и в custom-server/src/data/tasks.js. Правка одного места без +// второго ломает урок, и снаружи это никак не видно. +// +// Второе: коды ответов. На 404, 405, 422 и 401 построены самостоятельные, и +// переключение мока между статичным и динамическим режимом их задевает. +// +// Третье: соответствие спецификации. Модели объявляют uint16, а динамический +// мок про это ограничение не знал и отдавал отрицательные id. +// +// Каддй здесь не участвует: он есть только в образе, а в CI его нет. Поэтому +// prism и приложение опрашиваются напрямую по своим портам, как это делает +// Caddy, срезая префикс /http-api. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; + +import expectedTasks from '../custom-server/src/data/tasks.js'; + +const SPEC = './tsp-output/http-api/@typespec/openapi3/openapi.1.0.yaml'; +const REST = 'http://127.0.0.1:4011'; +const APP = 'http://127.0.0.1:4010'; +const UINT16_MAX = 65535; + +const failures = []; +const children = []; + +const check = (name, passed, detail = '') => { + if (passed) { + console.log(` ok ${name}`); + return; + } + console.log(` FAIL ${name}${detail ? `\n ${detail}` : ''}`); + failures.push(name); +}; + +// Вывод дочерних процессов обязательно вычитывать. Если оставить трубу без +// читателя, prism упирается в заполненный буфер и встаёт, а прогон выглядит +// зависшим без всякой диагностики. Собранный вывод печатается, только если +// сервис не поднялся. +// detached обязателен. npx это обёртка, она порождает настоящий процесс внуком, +// и SIGTERM самой обёртке внука не задевает: prism и fastify продолжают жить, +// держат наши трубы открытыми, и прогон не завершается даже после всех проверок. +// Поэтому каждый сервис заводится своей группой процессов и снимается целиком. +const start = (name, command, args) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: true }); + const output = []; + child.stdout.on('data', (chunk) => output.push(chunk.toString())); + child.stderr.on('data', (chunk) => output.push(chunk.toString())); + children.push({ name, child, output }); + return child; +}; + +const dumpOutput = () => { + for (const { name, output } of children) { + console.log(`\n--- вывод ${name} ---\n${output.join('').trim() || '(пусто)'}`); + } +}; + +const killGroup = (child, signal) => { + try { + process.kill(-child.pid, signal); + } catch { + // группы уже нет, значит процесс снят + } +}; + +const stopAll = async () => { + await Promise.all(children.map(async ({ child }) => { + if (child.exitCode !== null || child.signalCode !== null) return; + killGroup(child, 'SIGTERM'); + await Promise.race([once(child, 'exit'), new Promise((r) => setTimeout(r, 3000))]); + killGroup(child, 'SIGKILL'); + })); +}; + +const waitFor = async (url, name) => { + for (let i = 0; i < 60; i += 1) { + try { + await fetch(url); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + dumpOutput(); + throw new Error(`${name} не поднялся за 30 секунд: ${url}`); +}; + +const getJson = async (url, options) => { + const response = await fetch(url, options); + const text = await response.text(); + let body = null; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: response.status, body }; +}; + +const rpc = (method, params) => getJson(`${APP}/http-api/rpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method, params, id: 1 }), +}); + +// Спецификация объявляет id, total, skip и limit как uint16. Проверяем каждое +// число ответа, а не только id, потому что отрицательный total ломал уроки так же. +const outOfRange = (value, path = '$') => { + if (typeof value === 'number') { + const bad = !Number.isInteger(value) || value < 0 || value > UINT16_MAX; + return bad ? [`${path} = ${value}`] : []; + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => outOfRange(item, `${path}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, item]) => outOfRange(item, `${path}.${key}`)); + } + return []; +}; + +const run = async () => { + start('prism', 'npx', [ + 'prism', 'mock', '--multiprocess=false', + '--json-schema-faker-fillProperties=false', + '-p', '4011', '--host', '127.0.0.1', SPEC, + ]); + start('app', 'npx', [ + 'fastify', 'start', '-p', '4010', '-a', '127.0.0.1', 'custom-server/src/index.js', + ]); + + await waitFor(`${REST}/tasks`, 'prism'); + await waitFor(`${APP}/`, 'приложение'); + + console.log('\nREST и RPC отдают одни и те же задачи'); + const restList = await getJson(`${REST}/tasks`); + const rpcList = await rpc('tasks.list', {}); + check('GET /tasks отвечает 200', restList.status === 200, `получено ${restList.status}`); + check( + 'GET /tasks отдаёт набор из data/tasks.js', + JSON.stringify(restList.body?.tasks) === JSON.stringify(expectedTasks), + JSON.stringify(restList.body?.tasks), + ); + check( + 'tasks.list отдаёт тот же набор, что REST', + JSON.stringify(rpcList.body?.result?.tasks) === JSON.stringify(restList.body?.tasks), + JSON.stringify(rpcList.body?.result?.tasks), + ); + check( + 'total совпадает с длиной списка', + restList.body?.total === expectedTasks.length, + `total = ${restList.body?.total}`, + ); + + const restOne = await getJson(`${REST}/tasks/1`); + const rpcOne = await rpc('tasks.get', { id: 1 }); + check( + 'GET /tasks/1 и tasks.get id=1 отдают одну задачу', + JSON.stringify(restOne.body) === JSON.stringify(rpcOne.body?.result), + `REST ${JSON.stringify(restOne.body)} против RPC ${JSON.stringify(rpcOne.body?.result)}`, + ); + + console.log('\nRPC держит ошибки в теле, а код оставляет успешным'); + const rpcMissing = await rpc('tasks.get', { id: 999 }); + const rpcUnknown = await rpc('tasks.destroy', { id: 1 }); + check('несуществующая задача: код 200', rpcMissing.status === 200, `получено ${rpcMissing.status}`); + check('несуществующая задача: есть error', Boolean(rpcMissing.body?.error)); + check('несуществующий метод: код 200', rpcUnknown.status === 200, `получено ${rpcUnknown.status}`); + check( + 'несуществующий метод: -32601', + rpcUnknown.body?.error?.code === -32601, + JSON.stringify(rpcUnknown.body?.error), + ); + + console.log('\nКоды ответов REST, на них построены самостоятельные'); + const cases = [ + ['GET /nosuch → 404', `${REST}/nosuch`, { method: 'GET' }, 404], + ['DELETE /tasks → 405', `${REST}/tasks`, { method: 'DELETE' }, 405], + ['POST /tasks с пустым телом → 422', `${REST}/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }, 422], + ['POST /posts без токена → 401', `${REST}/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'title', body: 'body' }), + }, 401], + ['GET /courses без ключа → 401', `${REST}/courses`, { method: 'GET' }, 401], + ]; + for (const [name, url, options, expected] of cases) { + const { status } = await getJson(url, options); + check(name, status === expected, `получено ${status}`); + } + + const created = await getJson(`${REST}/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer any-value' }, + body: JSON.stringify({ title: 'title', body: 'body' }), + }); + check('POST /posts с токеном → 201', created.status === 201, `получено ${created.status}`); + check( + 'созданный пост несёт authorId, проставленный сервером', + Number.isInteger(created.body?.authorId), + JSON.stringify(created.body), + ); + + const withKey = await getJson(`${REST}/courses`, { + method: 'GET', + headers: { 'X-API-KEY': 'any-value' }, + }); + check('GET /courses с ключом → 200', withKey.status === 200, `получено ${withKey.status}`); + + const login = await getJson(`${REST}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'max@hotmail.com', password: 'password' }), + }); + check( + 'POST /login отдаёт непустой token', + typeof login.body?.token === 'string' && login.body.token.length > 0, + JSON.stringify(login.body), + ); + + console.log('\nЧисла в ответах не выходят за uint16'); + for (const path of ['/tasks', '/tasks/1', '/posts', '/posts/1', '/users', '/users/1', '/comments']) { + const headers = { 'X-API-KEY': 'any-value' }; + const { body } = await getJson(`${REST}${path}`, { method: 'GET', headers }); + const bad = outOfRange(body, path); + check(`${path} в границах uint16`, bad.length === 0, bad.join(', ')); + } + const { body: coursesBody } = await getJson(`${REST}/courses`, { + method: 'GET', + headers: { 'X-API-KEY': 'any-value' }, + }); + const badCourses = outOfRange(coursesBody, '/courses'); + check('/courses в границах uint16', badCourses.length === 0, badCourses.join(', ')); +}; + +try { + await run(); +} catch (error) { + console.log(`\nПрогон прерван: ${error.message}`); + failures.push('прогон не дошёл до конца'); +} finally { + await stopAll(); +} + +if (failures.length > 0) { + console.log(`\nПровалов: ${failures.length}`); + process.exit(1); +} + +console.log('\nВсе проверки прошли'); +process.exit(0);