1
27import { afterAll, beforeAll, describe, expect, it } from "vitest";
28import { readFileSync } from "node:fs";
29import * as path from "node:path";
30import { fileURLToPath } from "node:url";
31import { parse as parseYaml } from "yaml";
32import { createTestApp, type TestAppHandle } from "./helpers/api";
33
34const HERE = path.dirname(fileURLToPath(import.meta.url));
35const SPEC_PATH = path.resolve(HERE, "..", "..", "docs", "api", "openapi.yaml");
36
37
38const toColon = (p: string) => p.replace(/\{([a-zA-Z0-9_]+)\}/g, ":$1");
39const toBrace = (p: string) => p.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
40
41interface SpecDoc {
42 openapi: string;
43 paths: Record<string, Record<string, unknown>>;
44}
45
46let app: TestAppHandle;
47let spec: SpecDoc;
48
49beforeAll(async () => {
50 app = await createTestApp();
51 spec = parseYaml(readFileSync(SPEC_PATH, "utf8")) as SpecDoc;
52}, 180_000);
53afterAll(async () => {
54 await app?.close();
55});
56
57
59function specOps(): Set<string> {
60 const out = new Set<string>();
61 for (const [p, ops] of Object.entries(spec.paths)) {
62 for (const m of Object.keys(ops)) out.add(`${m.toUpperCase()} ${toBrace(p)}`);
63 }
64 return out;
65}
66
67
70function liveOps(): Set<string> {
71 return new Set(
72 app
73 .liveRoutes()
74 .map((r) => `${r.method} ${toBrace(r.path)}`),
75 );
76}
77
78describe("OpenAPI 3.1 contract (TASK-0127)", () => {
79 it("spec: is a valid OpenAPI 3.1 document with a version", () => {
80 expect(spec.openapi).toBe("3.1.0");
81 expect(spec.paths).toBeTypeOf("object");
82 expect(Object.keys(spec.paths).length).toBeGreaterThan(50);
83 });
84
85 it("SPEC ⊆ LIVE — every documented operation has a live route", () => {
86 const live = liveOps();
87 const missing: string[] = [];
88 for (const op of specOps()) {
89 if (!live.has(op)) missing.push(op);
90 }
91 expect(missing, `documented in the spec but not served by the live app:\n${missing.join("\n")}`).toEqual([]);
92 });
93
94 it("LIVE ⊆ SPEC — every public v1 route is documented", () => {
95 const documented = specOps();
96 const missing: string[] = [];
97 for (const op of liveOps()) {
98 if (!documented.has(op)) missing.push(op);
99 }
100 expect(missing, `served by the live app but missing from the spec:\n${missing.join("\n")}`).toEqual([]);
101 });
102
103 it("spec paths are in the OpenAPI {param} template form (no bare :params)", () => {
104 for (const p of Object.keys(spec.paths)) {
105 expect(p, `spec path uses Express-style :param: ${p}`).not.toMatch(/:[a-zA-Z]/);
106 }
107 });
108
109 it("every live path parameter is declared in its spec operation", () => {
110
111
112
113 for (const [p, ops] of Object.entries(spec.paths)) {
114 const segments = p.split("/").filter((s) => s.startsWith("{"));
115 for (const [m, op] of Object.entries(ops)) {
116 const params = (op as { parameters?: Array<{ name?: string; in?: string; $ref?: string }> }).parameters ?? [];
117 const declared = params.filter((x) => x.in === "path").map((x) => x.name);
118 for (const seg of segments) {
119 const name = seg.slice(1, -1);
120 expect(declared, `${m.toUpperCase()} ${p}: path parameter {${name}} not declared`).toContain(name);
121 }
122 }
123 }
124 });
125
126 it("the docs surface serves the spec + interactive page (live smoke)", async () => {
127
128
129 const res = await app.request("GET", "/api/v1/docs/openapi.yaml");
130 expect(res.status).toBe(200);
131 expect(res.headers.get("content-type") ?? "").toContain("yaml");
132 const served = parseYaml(await res.text()) as SpecDoc;
133 expect(Object.keys(served.paths).sort()).toEqual(Object.keys(spec.paths).sort());
134
135
136 const html = await app.request("GET", "/api/v1/docs");
137 expect(html.status).toBe(200);
138 expect(html.headers.get("content-type") ?? "").toContain("text/html");
139 const body = await html.text();
140 expect(body).toContain("SwaggerUIBundle");
141 expect(body).toContain("openapi-spec");
142
143
144 const css = await app.request("GET", "/api/v1/docs/assets/swagger-ui.css");
145 expect(css.status).toBe(200);
146 });
147});
148