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] **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] **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 ✓)*
|
- [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.
|
- [ ] **13. OpenAPI/Swagger** — все новые контроллеры в /swagger/v1/swagger.json.
|
||||||
- [ ] **14. POS Sync API** — `POST /api/pos/sync` (dev-token), `POST /api/pos/sales` (idempotency-key — повтор не дублирует).
|
- [ ] **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 включён чтобы избежать коллизии
|
// <Controller>_<Verb><Action>. Verb включён чтобы избежать коллизии
|
||||||
// когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
|
// когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync)
|
||||||
// получают одинаковое имя action → одинаковый operationId → duplicate.
|
// получают одинаковое имя action → одинаковый operationId → duplicate.
|
||||||
|
// Минимальные API (/health, /metrics, /connect/*) не имеют ключей
|
||||||
|
// controller/action в RouteValues — для них фоллбэк на RelativePath.
|
||||||
opts.CustomOperationIds(api =>
|
opts.CustomOperationIds(api =>
|
||||||
{
|
{
|
||||||
var ctrl = api.ActionDescriptor.RouteValues["controller"];
|
var rv = api.ActionDescriptor.RouteValues;
|
||||||
var action = api.ActionDescriptor.RouteValues["action"];
|
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() : "";
|
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}";
|
return $"{ctrl}_{verb}{action}";
|
||||||
});
|
});
|
||||||
// У нас есть одноимённые nested record'ы в разных контроллерах
|
// У нас есть одноимённые nested record'ы в разных контроллерах
|
||||||
// (например, StockRow в StockController и StockReportController).
|
// (например, StockRow в StockController и StockReportController, или
|
||||||
|
// EmployeeInput в Organizations.EmployeesController и SuperAdmin).
|
||||||
// Включаем имя контроллера в schemaId через FullName-suffix чтобы не
|
// Включаем имя контроллера в schemaId через FullName-suffix чтобы не
|
||||||
// словить duplicate schemaId — Swashbuckle падает на этом по умолчанию.
|
// словить 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.Api.Controllers.", "")
|
||||||
.Replace("foodmarket.Application.", "")
|
.Replace("foodmarket.Application.", "")
|
||||||
.Replace("foodmarket.Domain.", "")
|
.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