fix(swagger): operationId + schemaId — генерация OpenAPI работает
Some checks are pending
Some checks are pending
В 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 <noreply@anthropic.com>
This commit is contained in:
parent
6b6f27d238
commit
466595b4d5
|
|
@ -29,7 +29,7 @@
|
|||
- [x] **9. Demand (Оптовая отгрузка)** — юрлицо, опт-цена, кредит (PaidAmount<Total), Stock минус WholesaleSale; multi-tenant. *(stage-demand.yml: 8/8 ✓)*
|
||||
- [x] **10. Отчёты** — Sales / Stock-на-дату / Profit / ABC; фильтры периода/магазина/группы; экспорт CSV/XLSX; edge. *(stage-reports.yml: 8/8 ✓, 3 фикса: UTC dates, Enter→Cost, ABC Pareto)*
|
||||
- [x] **11. OrgAuditLog** — UI /audit-log, CRUD продукта → запись с diff; multi-tenant строго. *(stage-audit-log.yml: 7/7 ✓)*
|
||||
- [ ] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400.
|
||||
- [x] **12. 2FA TOTP** — enroll (QR), verify, login (двухшаговый), disable; невалидный код → 400. *(stage-2fa.yml: 6/6 ✓)*
|
||||
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
|
||||
- [ ] **14. POS Sync API** — `POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует).
|
||||
|
||||
|
|
|
|||
|
|
@ -229,18 +229,25 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// <Controller>_<Verb><Action>. 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.", "")
|
||||
|
|
|
|||
42
tests/e2e/reports/stage-swagger-2026-05-29T12-51-12-067Z.md
Normal file
42
tests/e2e/reports/stage-swagger-2026-05-29T12-51-12-067Z.md
Normal file
|
|
@ -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
|
||||
|
||||
Нет.
|
||||
68
tests/e2e/scenarios/stage-swagger.steps.ts
Normal file
68
tests/e2e/scenarios/stage-swagger.steps.ts
Normal file
|
|
@ -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<string, unknown>; components?: { schemas?: Record<string, unknown> } }
|
||||
}
|
||||
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<string, string[]>()
|
||||
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',
|
||||
})
|
||||
}
|
||||
18
tests/e2e/scenarios/stage-swagger.yml
Normal file
18
tests/e2e/scenarios/stage-swagger.yml
Normal file
|
|
@ -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)
|
||||
Loading…
Reference in a new issue