/** * AI Software Factory — Integration: OpenAPI 3.1 CONTRACT (TASK-0127). * * The CI gate that keeps `docs/api/openapi.yaml` honest, in BOTH directions: * * 1. SPEC ⊆ LIVE — every path documented in the spec exists on the live * router (same path, same methods; a documented method must not be * absent). The spec is the developer-facing contract; a path that is * documented but not served is a broken promise. * 2. LIVE ⊆ SPEC — every public `/api/v1` route the live router serves * (internal service-token surface + the bare `/health` probe excluded) * is documented in the spec. An undocumented public route is a * contract hole (the exit criterion "OpenAPI contract matches live * endpoints"). * * The live route table comes from the BOOTTED app's Express router * (`app.liveRoutes()` — the same router production mounts, so the * comparison cannot drift from a source re-scan). Path templates differ in * notation (Express `:id` vs OpenAPI `{id}`) and are normalized before * comparison. Regenerate the spec after a controller change: * `node scripts/generate-openapi.mjs`. * * The error-envelope walk (the third acceptance item) is covered by the * `api-keys.integration.test.ts` suite (real 401/403/404/409/429 envelopes * with canonical codes + `error.details[].reason` + `requestId`). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; import { createTestApp, type TestAppHandle } from "./helpers/api"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SPEC_PATH = path.resolve(HERE, "..", "..", "docs", "api", "openapi.yaml"); /** Express `:id` ↔ OpenAPI `{id}` — a shared template normalization. */ const toColon = (p: string) => p.replace(/\{([a-zA-Z0-9_]+)\}/g, ":$1"); const toBrace = (p: string) => p.replace(/:([a-zA-Z0-9_]+)/g, "{$1}"); interface SpecDoc { openapi: string; paths: Record>; } let app: TestAppHandle; let spec: SpecDoc; beforeAll(async () => { app = await createTestApp(); spec = parseYaml(readFileSync(SPEC_PATH, "utf8")) as SpecDoc; }, 180_000); afterAll(async () => { await app?.close(); }); /** `METHOD {spec-path}` keys for every operation the spec documents. The * spec already uses the `{param}` form — `toBrace` is a no-op safety net. */ function specOps(): Set { const out = new Set(); for (const [p, ops] of Object.entries(spec.paths)) { for (const m of Object.keys(ops)) out.add(`${m.toUpperCase()} ${toBrace(p)}`); } return out; } /** `METHOD {spec-path}` keys for every live public v1 route. Both sides * normalize to the SAME form (the spec's `{param}`) before comparison — * the Express router reports `:param`. */ function liveOps(): Set { return new Set( app .liveRoutes() .map((r) => `${r.method} ${toBrace(r.path)}`), ); } describe("OpenAPI 3.1 contract (TASK-0127)", () => { it("spec: is a valid OpenAPI 3.1 document with a version", () => { expect(spec.openapi).toBe("3.1.0"); expect(spec.paths).toBeTypeOf("object"); expect(Object.keys(spec.paths).length).toBeGreaterThan(50); }); it("SPEC ⊆ LIVE — every documented operation has a live route", () => { const live = liveOps(); const missing: string[] = []; for (const op of specOps()) { if (!live.has(op)) missing.push(op); } expect(missing, `documented in the spec but not served by the live app:\n${missing.join("\n")}`).toEqual([]); }); it("LIVE ⊆ SPEC — every public v1 route is documented", () => { const documented = specOps(); const missing: string[] = []; for (const op of liveOps()) { if (!documented.has(op)) missing.push(op); } expect(missing, `served by the live app but missing from the spec:\n${missing.join("\n")}`).toEqual([]); }); it("spec paths are in the OpenAPI {param} template form (no bare :params)", () => { for (const p of Object.keys(spec.paths)) { expect(p, `spec path uses Express-style :param: ${p}`).not.toMatch(/:[a-zA-Z]/); } }); it("every live path parameter is declared in its spec operation", () => { // The spec's contract is not only "the route exists" — the caller must // know the path parameters. For every spec operation, each `{param}` // segment must appear as an `in: "path"` parameter. for (const [p, ops] of Object.entries(spec.paths)) { const segments = p.split("/").filter((s) => s.startsWith("{")); for (const [m, op] of Object.entries(ops)) { const params = (op as { parameters?: Array<{ name?: string; in?: string; $ref?: string }> }).parameters ?? []; const declared = params.filter((x) => x.in === "path").map((x) => x.name); for (const seg of segments) { const name = seg.slice(1, -1); expect(declared, `${m.toUpperCase()} ${p}: path parameter {${name}} not declared`).toContain(name); } } } }); it("the docs surface serves the spec + interactive page (live smoke)", async () => { // /api/v1/docs/openapi.yaml must be the SAME document the test loaded — // the download link is the contract's distribution channel. const res = await app.request("GET", "/api/v1/docs/openapi.yaml"); expect(res.status).toBe(200); expect(res.headers.get("content-type") ?? "").toContain("yaml"); const served = parseYaml(await res.text()) as SpecDoc; expect(Object.keys(served.paths).sort()).toEqual(Object.keys(spec.paths).sort()); // /api/v1/docs is the swagger-ui shell (HTML, with the inlined spec). const html = await app.request("GET", "/api/v1/docs"); expect(html.status).toBe(200); expect(html.headers.get("content-type") ?? "").toContain("text/html"); const body = await html.text(); expect(body).toContain("SwaggerUIBundle"); expect(body).toContain("openapi-spec"); // A vendored asset resolves (the UI is not a dead page). const css = await app.request("GET", "/api/v1/docs/assets/swagger-ui.css"); expect(css.status).toBe(200); }); });