/** * @file Fails when an Express route under /api has no operation in the OpenAPI * document. Builds the app with `createApp()`, walks every router layer with * its mount prefix (recorded by wrapping `express.application.use` before the * server loads), and checks each method + path against `createOpenApiSpec()`. * Path parameters are compared by position only (`:token` and `{ref}` match), * and the two alias mounts that the spec documents by their tag description * (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`) * are allowlisted. It also fails on a spec operation no route answers. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before } = require("node:test"); const assert = require("node:assert/strict"); const path = require("path"); const os = require("os"); const fs = require("fs"); const TEST_DB = path.join(os.tmpdir(), `dashboard-openapi-cov-${Date.now()}-${process.pid}.db`); process.env.DASHBOARD_DB_PATH = TEST_DB; process.env.DASHBOARD_LIVENESS_PROBE = "0"; // Record each app.use mount path on the layer it creates — Express keeps only // a compiled matcher, which cannot be turned back into the prefix. const express = require("express"); const originalUse = express.application.use; express.application.use = function (mount, ...rest) { const out = originalUse.call(this, mount, ...rest); if (typeof mount === "string" || Array.isArray(mount)) { const stack = (this.router || this._router).stack; stack[stack.length - 1].__mount = mount; } return out; }; const { createApp } = require("../index"); const { createOpenApiSpec } = require("../openapi"); /** Mounts that repeat another, documented mount by design (see the tags). */ const ALIAS_PREFIXES = ["/api/workspaces", "/api/ai-routers"]; /** Spec paths served by middleware rather than a route (Swagger UI). */ const MIDDLEWARE_PATHS = ["get /api/docs"]; const METHODS = new Set(["get", "post", "put", "patch", "delete"]); /** `:id`, `:id(\\d+)`, `{id}` all become `{}`; a trailing slash is dropped. */ function normalize(p) { return ( p .replace(/:[A-Za-z_]\w*(\([^)]*\))?\??/g, "{}") .replace(/\{[^}]+\}/g, "{}") .replace(/\/$/, "") || "/" ); } function collectRoutes(app) { const out = new Set(); const walk = (stack, prefixes) => { for (const layer of stack) { if (layer.route) { const routePaths = [].concat(layer.route.path); for (const prefix of prefixes) { for (const rp of routePaths) { for (const m of Object.keys(layer.route.methods)) { if (METHODS.has(m)) out.add(`${m} ${normalize(prefix + rp)}`); } } } } else if (layer.handle && Array.isArray(layer.handle.stack)) { const mounts = layer.__mount === undefined ? [""] : [].concat(layer.__mount); walk( layer.handle.stack, prefixes.flatMap((a) => mounts.map((b) => a + b)) ); } } }; walk((app.router || app._router).stack, [""]); return [...out].filter((k) => k.split(" ")[1].startsWith("/api")); } describe("OpenAPI coverage", () => { let routes; let documented; before(() => { routes = collectRoutes(createApp()); documented = new Set(); for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) { for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`); } }); it("finds the routes it is meant to check", () => { assert.ok(routes.length > 300, `only ${routes.length} routes found — the walk is broken`); assert.ok(routes.includes("get /api/lanes/{}")); }); it("documents every registered /api route", () => { const missing = routes.filter( (k) => !documented.has(k) && !ALIAS_PREFIXES.some((a) => { const p = k.split(" ")[1]; return p === a || p.startsWith(a + "/"); }) ); assert.deepEqual(missing, [], `routes missing from the OpenAPI spec:\n${missing.join("\n")}`); }); it("documents no operation that no route answers", () => { const have = new Set(routes); const stale = [...documented].filter((k) => !have.has(k) && !MIDDLEWARE_PATHS.includes(k)); assert.deepEqual(stale, [], `spec operations with no route:\n${stale.join("\n")}`); }); }); process.on("exit", () => { for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) { try { fs.unlinkSync(f); } catch { /* already gone */ } } });