From 82ed31d27a2df8d8cd4da300c786f48e7d465216 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:47:24 +0200 Subject: [PATCH] docs(openapi): document GET and PUT on /api/combos/[id] (#10875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obrigado — TDD exemplar num gap real de contrato: as duas operações que o dashboard realmente chama em /api/combos/{id} (GET e PUT) estavam ausentes do openapi.yaml, enquanto a única operação documentada (patch, antes deste #10869) não tinha handler. Adiciona um floor de cobertura por OPERAÇÃO (não só por PATH) que o gate existente não capturava, medido em 343/985 (34.8%). Validação (worktree combinado a partir de origin/release/v3.8.50, 0 conflitos): - typecheck:core limpo, complexity/cognitive-complexity dentro do baseline - tests/unit/openapi-coverage.test.ts — passando com o novo floor de operações - openapi-routes/openapi-coverage/openapi-security-tiers gates — PASS --- .../10875-combos-id-verb-coverage.md | 1 + docs/openapi.yaml | 33 ++++++++ tests/unit/openapi-coverage.test.ts | 77 +++++++++++++++++-- 3 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 changelog.d/maintenance/10875-combos-id-verb-coverage.md diff --git a/changelog.d/maintenance/10875-combos-id-verb-coverage.md b/changelog.d/maintenance/10875-combos-id-verb-coverage.md new file mode 100644 index 0000000000..5610a7ea41 --- /dev/null +++ b/changelog.d/maintenance/10875-combos-id-verb-coverage.md @@ -0,0 +1 @@ +- **docs(openapi):** document the `GET` and `PUT` operations on `/api/combos/{id}`, and add an operation-level coverage floor so a missing verb can no longer hide behind a path that already counts as covered ([#10875](https://github.com/diegosouzapw/OmniRoute/pull/10875)) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 54689a0a57..fb255d3543 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2069,6 +2069,39 @@ paths: description: Created combo /api/combos/{id}: + get: + tags: [Combos] + summary: Get combo by ID + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: Combo details + "404": + description: Combo not found + put: + tags: [Combos] + summary: Update combo + description: >- + Partial update: the body is merged onto the stored combo, so a field left out keeps + its current value. An array that IS sent replaces the stored one outright. + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated combo + "400": + description: Invalid body, or the resulting combo fails validation + "404": + description: Combo not found + "409": + description: Name already taken, or the combo is quota-share managed patch: tags: [Combos] summary: Update combo diff --git a/tests/unit/openapi-coverage.test.ts b/tests/unit/openapi-coverage.test.ts index c07b545efc..c762024ec3 100644 --- a/tests/unit/openapi-coverage.test.ts +++ b/tests/unit/openapi-coverage.test.ts @@ -8,13 +8,13 @@ const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); -function collectRoutePaths(dir: string): string[] { +function collectRouteFiles(dir: string): { apiPath: string; file: string }[] { const entries = fs.readdirSync(dir, { withFileTypes: true }); - const paths: string[] = []; + const routes: { apiPath: string; file: string }[] = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - paths.push(...collectRoutePaths(fullPath)); + routes.push(...collectRouteFiles(fullPath)); continue; } if (entry.isFile() && entry.name === "route.ts") { @@ -22,10 +22,31 @@ function collectRoutePaths(dir: string): string[] { .dirname(fullPath) .replace(API_ROOT, "") .replace(/\[([^\]]+)\]/g, "{$1}"); - paths.push(`/api${apiPath}`); + routes.push({ apiPath: `/api${apiPath}`, file: fullPath }); } } - return paths; + return routes; +} + +function collectRoutePaths(dir: string): string[] { + return collectRouteFiles(dir).map((route) => route.apiPath); +} + +// OPTIONS is deliberately absent: every v1 route exports it for CORS preflight, so it is +// transport boilerplate rather than API surface a consumer calls. +const DOCUMENTABLE_METHODS = ["get", "post", "put", "patch", "delete", "head"] as const; + +/** The HTTP handlers a route.ts actually exports, across the export forms used in this repo. */ +function exportedMethods(routeFile: string): string[] { + const source = fs.readFileSync(routeFile, "utf-8"); + return DOCUMENTABLE_METHODS.filter((method) => { + const name = method.toUpperCase(); + return ( + new RegExp(`export\\s+(?:async\\s+)?function\\s+${name}\\b`).test(source) || + new RegExp(`export\\s+(?:const|let|var)\\s+${name}\\b`).test(source) || + new RegExp(`export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`).test(source) + ); + }); } function normalizePath(p: string): string { @@ -79,3 +100,49 @@ test("openapi.yaml does not regress documented-route coverage below the agreed f `Missing: ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? ` ... +${missing.length - 10} more` : ""}` ); }); + +// Floor recorded on 2026-08-20 for release/v3.8.50: 343/985 operations documented. +// The path floor above cannot see an operation: a route counts as covered the moment ONE +// of its verbs is documented. /api/combos/{id} exported GET, PUT and DELETE while the spec +// listed only `patch` and `delete` — a fully covered path hiding two operations, and the +// one `patch` it did document does not exist on that route. Schemathesis (dast-smoke.yml) +// only exercises documented operations, so the two hidden verbs never reached the fuzzer. +// Same "no regressions, not the absolute target" policy as the path floor: raising it is +// tracked as the same follow-up doc debt. +const OPENAPI_OPERATION_FLOOR_PERCENT = 34.8; + +test("openapi.yaml does not regress documented-operation coverage below the agreed floor", () => { + const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")) as { + paths?: Record>; + }; + const documentedPaths = raw.paths ?? {}; + + let covered = 0; + const missing: string[] = []; + + for (const { apiPath, file } of collectRouteFiles(API_ROOT)) { + const operations = documentedPaths[normalizePath(apiPath)]; + for (const method of exportedMethods(file)) { + if (operations && operations[method]) { + covered++; + } else { + missing.push(`${method.toUpperCase()} ${apiPath}`); + } + } + } + + const total = covered + missing.length; + const coverage = (covered / total) * 100; + + if (coverage < OPENAPI_OPERATION_FLOOR_PERCENT) { + console.error(`Operation coverage: ${coverage.toFixed(1)}% (${covered}/${total})`); + console.error("Undocumented operations:"); + missing.forEach((op) => console.error(` - ${op}`)); + } + + assert.ok( + coverage >= OPENAPI_OPERATION_FLOOR_PERCENT, + `OpenAPI operation coverage regressed: ${coverage.toFixed(1)}% < floor ${OPENAPI_OPERATION_FLOOR_PERCENT}%. ` + + `Undocumented: ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? ` ... +${missing.length - 10} more` : ""}` + ); +});