From 466595b4d597bb94f280261b6c6a47a82c204f4a Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 29 May 2026 17:51:23 +0500 Subject: [PATCH] =?UTF-8?q?fix(swagger):=20operationId=20+=20schemaId=20?= =?UTF-8?q?=E2=80=94=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20OpenAPI=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В Development swagger.json валился двумя ошибками: 1. CustomOperationIds dereferencing api.ActionDescriptor.RouteValues['action'] для минимальных API (/health, /metrics, /connect/*) кидало KeyNotFoundException. Делаем TryGetValue + fallback на RelativePath. 2. CustomSchemaIds с FullName! падал NRE на типах без FullName (generic-параметры). Fallback на t.Name через ??. После фикса: /swagger/v1/swagger.json 200, 117 paths, все 19 новых модулей (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/ AuditLog/2FA/POS/Signup) присутствуют, schemaId без дубликатов. Co-Authored-By: Claude Opus 4.7 --- docs/stage-testing-progress.md | 2 +- src/food-market.api/Program.cs | 15 ++-- .../stage-swagger-2026-05-29T12-51-12-067Z.md | 42 ++++++++++++ tests/e2e/scenarios/stage-swagger.steps.ts | 68 +++++++++++++++++++ tests/e2e/scenarios/stage-swagger.yml | 18 +++++ 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/reports/stage-swagger-2026-05-29T12-51-12-067Z.md create mode 100644 tests/e2e/scenarios/stage-swagger.steps.ts create mode 100644 tests/e2e/scenarios/stage-swagger.yml diff --git a/docs/stage-testing-progress.md b/docs/stage-testing-progress.md index 9b4fbd7..eb573d3 100644 --- a/docs/stage-testing-progress.md +++ b/docs/stage-testing-progress.md @@ -29,7 +29,7 @@ - [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount_. Verb включён чтобы избежать коллизии // когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync) // получают одинаковое имя action → одинаковый operationId → duplicate. + // Минимальные API (/health, /metrics, /connect/*) не имеют ключей + // controller/action в RouteValues — для них фоллбэк на RelativePath. opts.CustomOperationIds(api => { - var ctrl = api.ActionDescriptor.RouteValues["controller"]; - var action = api.ActionDescriptor.RouteValues["action"]; + var rv = api.ActionDescriptor.RouteValues; + rv.TryGetValue("controller", out var ctrl); + rv.TryGetValue("action", out var action); var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : ""; + if (string.IsNullOrEmpty(ctrl) || string.IsNullOrEmpty(action)) + return $"{verb}_{api.RelativePath?.Replace('/', '_').Replace('{', '_').Replace('}', '_')}"; return $"{ctrl}_{verb}{action}"; }); // У нас есть одноимённые nested record'ы в разных контроллерах - // (например, StockRow в StockController и StockReportController). + // (например, StockRow в StockController и StockReportController, или + // EmployeeInput в Organizations.EmployeesController и SuperAdmin). // Включаем имя контроллера в schemaId через FullName-suffix чтобы не // словить duplicate schemaId — Swashbuckle падает на этом по умолчанию. - opts.CustomSchemaIds(t => t.FullName! + // FullName может быть null для generic-параметров — fallback на Name. + opts.CustomSchemaIds(t => (t.FullName ?? t.Name) .Replace("foodmarket.Api.Controllers.", "") .Replace("foodmarket.Application.", "") .Replace("foodmarket.Domain.", "") diff --git a/tests/e2e/reports/stage-swagger-2026-05-29T12-51-12-067Z.md b/tests/e2e/reports/stage-swagger-2026-05-29T12-51-12-067Z.md new file mode 100644 index 0000000..b70c4b3 --- /dev/null +++ b/tests/e2e/reports/stage-swagger-2026-05-29T12-51-12-067Z.md @@ -0,0 +1,42 @@ +# E2E report: stage-swagger + +Запущен: 2026-05-29T12:51:11.743Z +Длительность: 0.3с + +**Итог:** 3 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 3) + +## ✓ Step sw01_swagger_endpoint: GET /swagger/v1/swagger.json → 200 (Development), документ валидный JSON + +Длительность: 322мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | GET /swagger/v1/swagger.json → 200 | ✓ 200 | +| api | paths.length > 50 | ✓ paths=117 | + +## ✓ Step sw02_all_new_modules_present: Все новые модули (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/AuditLog/2FA/POS) в paths + +Длительность: 1мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | Все 19 новых модулей присутствуют | ✓ все ок | + +## ✓ Step sw03_schema_ids_unique: Все schemaId уникальны (нет коллизий типа EmployeeInput) + +Длительность: 1мс + +| Тип | Проверка | Результат | +|---|---|---| +| api | schemaId дубликатов нет (всего 172) | ✓ unique | + +## Summary + +- Passed: 3 +- Failed: 0 +- Warnings: 0 +- Skipped: 0 + +## Critical bugs + +Нет. diff --git a/tests/e2e/scenarios/stage-swagger.steps.ts b/tests/e2e/scenarios/stage-swagger.steps.ts new file mode 100644 index 0000000..27c04fe --- /dev/null +++ b/tests/e2e/scenarios/stage-swagger.steps.ts @@ -0,0 +1,68 @@ +/** + * Stage Swagger: проверяет что OpenAPI генерируется и все новые модули + * присутствуют. Stage работает в Production, поэтому Swagger там + * выключен — этот сценарий гоняется ЛОКАЛЬНО (E2E_ADMIN_URL=http://localhost:5081). + * Если эндпоинт недоступен, шаги помечаются skip. + */ +import { makeClient } from '../lib/api.js' +import type { CheckResult, Step, Report } from '../lib/report.js' + +type Ctx = { + apiOnly: boolean + swagger?: { paths: Record; components?: { schemas?: Record } } +} +interface StepCtx { ctx: Ctx; step: Step; report: Report } + +function check(step: Step, c: CheckResult) { step.checks.push(c) } + +export async function sw01_swagger_endpoint({ ctx, step, report }: StepCtx) { + const api = makeClient() + const r = await api.get('/swagger/v1/swagger.json') + if (r.status === 404) { + step.status = 'skip' + step.notes.push('Swagger не доступен на этом окружении (видимо Production). Запусти локально с E2E_ADMIN_URL=http://localhost:5081.') + return + } + check(step, { kind: 'api', description: 'GET /swagger/v1/swagger.json → 200', ok: r.status === 200, detail: `${r.status}` }) + check(step, { kind: 'api', description: 'paths.length > 50', ok: typeof r.data === 'object' && r.data && Object.keys(r.data.paths ?? {}).length > 50, detail: `paths=${Object.keys(r.data?.paths ?? {}).length}` }) + ctx.swagger = r.data +} + +export async function sw02_all_new_modules_present({ ctx, step, report }: StepCtx) { + if (!ctx.swagger) { step.status = 'skip'; return } + const paths = Object.keys(ctx.swagger.paths ?? {}) + const expected = [ + '/api/inventory/enters', '/api/inventory/losses', '/api/inventory/transfers', + '/api/inventory/inventories', '/api/inventory/stock', + '/api/purchases/supplier-returns', '/api/purchases/supplies', + '/api/sales/demands', '/api/sales/retail', + '/api/reports/sales', '/api/reports/stock', '/api/reports/profit', '/api/reports/abc', + '/api/admin/audit-log', '/api/me/2fa/enroll', '/api/me/2fa/verify', + '/api/me/2fa/disable', '/api/pos/v1', '/api/auth/signup', + ] + const missing = expected.filter(e => !paths.some(p => p.startsWith(e))) + check(step, { + kind: 'api', description: `Все ${expected.length} новых модулей присутствуют`, + ok: missing.length === 0, detail: missing.length ? `missing: ${missing.join(', ')}` : 'все ок', + }) +} + +export async function sw03_schema_ids_unique({ ctx, step, report }: StepCtx) { + if (!ctx.swagger) { step.status = 'skip'; return } + const schemas = ctx.swagger.components?.schemas ?? {} + const names = Object.keys(schemas) + // Уже проверка факта генерации (если был бы collision, swagger.json не сгенерился). + // Дополнительно — нет дублей по lowercase (защита от case-insensitive дубля). + const lower = new Map() + for (const n of names) { + const k = n.toLowerCase() + const arr = lower.get(k) ?? [] + arr.push(n) + lower.set(k, arr) + } + const dups = [...lower.entries()].filter(([, v]) => v.length > 1) + check(step, { + kind: 'api', description: `schemaId дубликатов нет (всего ${names.length})`, + ok: dups.length === 0, detail: dups.length ? `dups: ${dups.map(([k]) => k).slice(0, 5).join(', ')}` : 'unique', + }) +} diff --git a/tests/e2e/scenarios/stage-swagger.yml b/tests/e2e/scenarios/stage-swagger.yml new file mode 100644 index 0000000..80d47ed --- /dev/null +++ b/tests/e2e/scenarios/stage-swagger.yml @@ -0,0 +1,18 @@ +name: stage-swagger +description: | + OpenAPI/Swagger: stage работает в Production где /swagger не + раскрывается (sensitive endpoint enumeration). Тестируем локально + через E2E_ADMIN_URL=http://localhost:5081, проверяем что все новые + контроллеры зарегистрированы. + +preconditions: + reset_db: false + smoke_login_super_admin: false + +steps: + - id: sw01_swagger_endpoint + title: GET /swagger/v1/swagger.json → 200 (Development), документ валидный JSON + - id: sw02_all_new_modules_present + title: Все новые модули (Enter/Loss/Transfer/Inventory/SupplierReturn/Demand/Reports/AuditLog/2FA/POS) в paths + - id: sw03_schema_ids_unique + title: Все schemaId уникальны (нет коллизий типа EmployeeInput)