mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
557 lines
24 KiB
TypeScript
557 lines
24 KiB
TypeScript
/**
|
|
* Every route is published, declared unpublished, or registered as retired.
|
|
*
|
|
* `pnpm openapi:check` compares the registry against `docs/api/openapi.yaml`
|
|
* and fails on drift between them. It has never compared the ROUTES against
|
|
* the registry, so a route that was never registered at all produced no drift
|
|
* and no failure: the spec was internally consistent and silently incomplete.
|
|
* That is how two hundred and twenty-two paths, forty-three of them called by
|
|
* the native client, ended up undocumented while the contract check ran green
|
|
* on every commit for months. The first count taken of this gap said
|
|
* forty-five, because it compared against the paths the YAML already listed
|
|
* rather than against the verbs the routes export.
|
|
*
|
|
* This file closes the third side of the triangle. It walks `src/app` for
|
|
* route modules, reads the HTTP verbs each one actually exports, and requires
|
|
* every verb to be either present in the route table or named in
|
|
* {@link UNPUBLISHED} with a reason. Adding a route without touching either
|
|
* side fails here, which is the only place it can be caught before a client
|
|
* goes looking for a contract that was never written.
|
|
*
|
|
* ## The third state: retired
|
|
*
|
|
* Published and exempted both describe a route that exists. Removing one used
|
|
* to leave exactly one legal move — drop it from the contract too — and that
|
|
* move is invisible to every client already calling it. The native app spent
|
|
* twelve days rendering a permanent error card over a chart because
|
|
* `/api/auth/me/research-mode` went from published to absent with no third
|
|
* thing in between, and a bare 404 reads as a broken deployment rather than a
|
|
* decision.
|
|
*
|
|
* So retirement is a state here rather than a deletion. A path in
|
|
* `RETIRED_ROUTES` is required to be gone from `src/app` AND present in the
|
|
* contract, which is the one combination the fourth case below used to reject
|
|
* outright. Removing a route now forces a choice: drop it from the contract, or
|
|
* register it as retired and let the proxy answer 410 for it. What is no longer
|
|
* available is doing neither and finding out from a client.
|
|
*
|
|
* Be exact about what that is worth, because the obvious overclaim is easy to
|
|
* write and was written here first. This guard reads the tree as it stands. It
|
|
* cannot see a deletion, so it cannot insist that a removal SHOULD have been
|
|
* registered: a route deleted from `src/app` and from its route module in one
|
|
* commit leaves nothing behind to notice, and no check over a single commit's
|
|
* state can recover the fact that something used to be there. Proving otherwise
|
|
* needs an anchor outside the commit — the previous release tag — and the job
|
|
* that runs this suite checks out at depth one with no tags, so a history
|
|
* comparison here would be a check that silently cannot fail.
|
|
*
|
|
* What the third state does buy, and it is not nothing: deleting a route while
|
|
* it is still published now has TWO legal answers instead of one, and the
|
|
* failure message names both. Before, the only way past the fourth case was to
|
|
* erase the path — the move that is invisible to every client. Now the cheaper
|
|
* move is to leave the path standing as a tombstone, which is the move that
|
|
* reaches them. And once registered, the registration is held in three
|
|
* directions by the cases below, so it cannot rot into a lie about a route that
|
|
* came back or a retirement the contract never mentions.
|
|
*
|
|
* ## Why the exemption list carries prose
|
|
*
|
|
* A bare allowlist of paths decays into a place to put things. Each entry here
|
|
* says WHY the route is absent from the public contract, so the next reader can
|
|
* disagree with the reason rather than guess at it, and so an entry whose
|
|
* reason has stopped being true is visible as text rather than as a path in a
|
|
* list. The reasons fall into a small number of kinds — browser-only
|
|
* navigation, an OAuth callback the provider drives, a webhook authenticated by
|
|
* a shared secret, an internal cron trigger — and a route that does not fit one
|
|
* of those kinds probably belongs in the spec.
|
|
*
|
|
* ## What this guard does NOT prove
|
|
*
|
|
* - It proves a verb is PRESENT in the table, not that the operation is
|
|
* accurate. A published path whose request schema has drifted from the
|
|
* handler passes here. That is the limit of a structural check; the accuracy
|
|
* comes from the schemas being imported from `src/lib/validations/*` rather
|
|
* than retyped.
|
|
* - It reads exports with a regex over comment-stripped source. A verb exported
|
|
* through an indirection this matcher cannot see would read as absent, which
|
|
* fails loudly rather than passing quietly, and is the direction an
|
|
* imprecise matcher should fail in.
|
|
* - `globSync` skips dot-directories by default, which once hid fourteen guards
|
|
* from `src/app/.well-known`. The sweep below passes an explicit pattern list
|
|
* that includes it, and asserts a floor on the number of modules found so an
|
|
* enumeration that silently matches nothing cannot read as full coverage.
|
|
*
|
|
* Mutation checks, each run and confirmed red:
|
|
*
|
|
* 1. Delete any single entry from {@link UNPUBLISHED} → the third case fails
|
|
* naming that path.
|
|
* 2. Delete a path from a route module → the second case fails naming the
|
|
* verbs that lost their contract.
|
|
* 3. Delete a live route module that is published — `src/app/api/version` —
|
|
* → the fourth case fails naming `/api/version` as a published path no
|
|
* route serves. This is the seam the third state opens: the failure now
|
|
* has two ways out, erase the path or register the retirement, and the
|
|
* message says so.
|
|
* 4. Recreate `src/app/api/auth/me/research-mode/route.ts` with a GET export
|
|
* → the fifth case fails naming it, because a tombstone over a live route
|
|
* is wrong in three places at once (this contract, the capability list,
|
|
* and the 410 the proxy answers before the route can ever run).
|
|
* 5. Drop `...retiredPaths` from the route-table index → the sixth case fails
|
|
* naming every registered path as answered by the server and absent from
|
|
* the contract.
|
|
*
|
|
* Deliberately NOT claimed: deleting an entry from `RETIRED_ROUTES` does not
|
|
* fail anything here, because the contract entries are generated from that same
|
|
* constant and both sides disappear together. That is the limit stated above,
|
|
* written down so the next reader does not mistake this file for a deletion
|
|
* detector.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { globSync } from "node:fs";
|
|
import { relative } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { openApiPaths } from "@/lib/openapi/routes";
|
|
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
|
|
|
|
const ROOT = process.cwd();
|
|
|
|
/**
|
|
* The verbs a route module can export. `HEAD` and `OPTIONS` are deliberately
|
|
* out: Next.js answers both from the others, and neither is a contract a client
|
|
* writes against.
|
|
*/
|
|
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
|
|
/**
|
|
* The kinds of route that are deliberately outside the published contract.
|
|
*
|
|
* A kind rather than a free-text reason per path, because these fall into a
|
|
* small number of genuinely different shapes and writing the same sentence
|
|
* seventy-three times would hide the one entry whose sentence is different. A
|
|
* route that fits none of these kinds belongs in the spec.
|
|
*/
|
|
const REASONS = {
|
|
/**
|
|
* `requireAdmin()` is cookie-only by construction (`src/lib/api-handler.ts`),
|
|
* so a Bearer token cannot reach these however wide its scope. The only
|
|
* caller that can is the operator's own browser on the admin console, which
|
|
* ships with the server and does not read a spec to find out what it talks
|
|
* to. Publishing them would describe an API surface that no API client can
|
|
* use.
|
|
*/
|
|
adminConsole:
|
|
"cookie-only admin console; requireAdmin() refuses every Bearer caller",
|
|
/**
|
|
* A browser navigation, not a request a client makes and reads. The response
|
|
* is a redirect to the provider's own authorise page; the client that starts
|
|
* it is a link, and the thing that follows it is the address bar.
|
|
*/
|
|
browserHandoff:
|
|
"browser navigation that ends at the provider's authorise page",
|
|
/**
|
|
* Driven by the provider, arriving with the provider's own query string. A
|
|
* client never constructs one, and the contract that governs its shape is
|
|
* the provider's, not this one.
|
|
*/
|
|
oauthCallback: "provider-driven redirect back from an OAuth authorise step",
|
|
/**
|
|
* Called by the provider, authenticated by a shared secret rather than by a
|
|
* session or a token. The payload shape is the provider's to define, and
|
|
* documenting it here would suggest a caller could construct one.
|
|
*/
|
|
providerWebhook:
|
|
"provider-called, authenticated by a shared secret rather than a session",
|
|
/**
|
|
* Serves the deployment itself: crash reports, web vitals, the analytics
|
|
* proxy, the deploy hook. No user-facing client calls these, and two of them
|
|
* accept a body defined by a browser reporting standard rather than by us.
|
|
*/
|
|
internalOps: "serves the deployment itself, not any user-facing client",
|
|
/**
|
|
* The Model Context Protocol transport and the OAuth dance in front of it.
|
|
* These speak wire formats that MCP and RFC 8414 / RFC 7591 define, including
|
|
* error bodies the standard response envelope cannot express, which is why
|
|
* they are the documented exception to the `apiHandler` rule as well.
|
|
*/
|
|
mcpTransport: "speaks the MCP and OAuth wire formats, not this envelope",
|
|
/**
|
|
* A clinician share link, authenticated by the URL credential itself. The
|
|
* audience is a person who was sent a link, and the whole point is that they
|
|
* need nothing else — no account, no token, and no contract to read.
|
|
*/
|
|
shareLink:
|
|
"authenticated by the URL credential, for a recipient with no account",
|
|
/**
|
|
* A discovery document whose format belongs to somebody else: Apple's
|
|
* associated-domains file, and the two OAuth metadata documents. Their shape
|
|
* is fixed by an external specification, so restating it here would create a
|
|
* second source of truth that can only be wrong.
|
|
*/
|
|
wellKnown: "format fixed by an external specification",
|
|
} as const;
|
|
|
|
type ExemptionKind = keyof typeof REASONS;
|
|
|
|
interface Exemption {
|
|
kind: ExemptionKind;
|
|
/** The verbs this exemption covers. Any other verb must be published. */
|
|
methods: readonly HttpMethod[];
|
|
}
|
|
|
|
/**
|
|
* Routes that are deliberately not in the public contract.
|
|
*
|
|
* Keyed by path, listing the verbs the exemption covers, so a route that grows
|
|
* a new verb has to justify it rather than inherit somebody else's reason.
|
|
*/
|
|
const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
|
"/api/admin/ai-quality": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/ai-settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
|
|
"/api/admin/app-logs": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/audit-log": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/audit-log/actions": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
|
|
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
|
|
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
|
|
// `/api/admin/backups/{id}/download` and `/api/admin/backups/{id}/restore`
|
|
// are published rather than exempted: both open the encrypted stored copy,
|
|
// and the refusal they answer when it will not open is a promise an operator
|
|
// needs to be able to look up. See `src/lib/openapi/routes/admin-backups.ts`.
|
|
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/central-codex": {
|
|
kind: "adminConsole",
|
|
methods: ["DELETE", "GET"],
|
|
},
|
|
"/api/admin/central-codex/device-poll": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/central-codex/device-start": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/data": { kind: "adminConsole", methods: ["DELETE"] },
|
|
"/api/admin/drain-per-sample-cumulative": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
|
|
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/import-apple-health-export": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/monitoring/glitchtip-test": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/monitoring/umami-test": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
|
|
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
|
|
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
|
|
"/api/admin/settings/assistant-flags": {
|
|
kind: "adminConsole",
|
|
methods: ["GET", "PUT"],
|
|
},
|
|
"/api/admin/settings/module-availability": {
|
|
kind: "adminConsole",
|
|
methods: ["GET", "PATCH"],
|
|
},
|
|
"/api/admin/settings/web-push-vapid/generate": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
|
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
|
"/api/admin/users/{id}/force-logout": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/admin/users/{id}/reset-password": {
|
|
kind: "adminConsole",
|
|
methods: ["POST"],
|
|
},
|
|
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
|
|
// now. Nightscout has no OAuth step and no authorise page: the route is an
|
|
// authenticated JSON POST that Zod-parses a URL and a token, probes the
|
|
// instance and encrypts the pair onto the row. It was the only POST among
|
|
// eight `connect` entries and the only one whose siblings — status, test,
|
|
// disconnect — were all published, which is what makes it read as an entry
|
|
// that inherited a neighbour's reason rather than earned its own. The seven
|
|
// GET handoffs below keep it: each of those really does end at a provider's
|
|
// authorise page.
|
|
"/api/oura/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
"/api/polar/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
"/api/strava/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
"/api/whoop/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
"/api/withings/connect": { kind: "browserHandoff", methods: ["GET"] },
|
|
"/api/internal/deploy-webhook": {
|
|
kind: "internalOps",
|
|
methods: ["GET", "POST"],
|
|
},
|
|
"/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] },
|
|
"/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] },
|
|
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
|
|
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
|
|
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
|
|
"/api/send": { kind: "internalOps", methods: ["POST"] },
|
|
"/api/mcp/oauth/authorize": {
|
|
kind: "mcpTransport",
|
|
methods: ["GET", "POST"],
|
|
},
|
|
"/api/mcp/oauth/register": { kind: "mcpTransport", methods: ["POST"] },
|
|
"/api/mcp/oauth/token": { kind: "mcpTransport", methods: ["POST"] },
|
|
"/mcp": { kind: "mcpTransport", methods: ["DELETE", "GET", "POST"] },
|
|
"/api/auth/oidc/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/fitbit/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/google-health/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/oura/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/polar/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/strava/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/whoop/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/withings/callback": { kind: "oauthCallback", methods: ["GET"] },
|
|
"/api/telegram/webhook": {
|
|
kind: "providerWebhook",
|
|
methods: ["GET", "POST"],
|
|
},
|
|
"/api/whoop/webhook/{token}": { kind: "providerWebhook", methods: ["POST"] },
|
|
"/api/withings/webhook": {
|
|
kind: "providerWebhook",
|
|
methods: ["GET", "POST"],
|
|
},
|
|
"/api/withings/webhook/{token}": {
|
|
kind: "providerWebhook",
|
|
methods: ["GET", "POST"],
|
|
},
|
|
// `/c/{token}/report.pdf` and `/c/{token}/fhir` were exempt here as
|
|
// `shareLink` and are published now. That reason holds for the page a
|
|
// recipient opens; it does not hold for two routes that serve machine
|
|
// formats a practice files into another system, which is precisely the
|
|
// audience a contract has. `/c/{token}/d/{id}` was published all along, one
|
|
// route away in the same tree, which is what makes the exemption read as an
|
|
// oversight rather than a decision. The `shareLink` reason stays: the next
|
|
// recipient-facing route is likelier to want it than not.
|
|
"/.well-known/apple-app-site-association": {
|
|
kind: "wellKnown",
|
|
methods: ["GET"],
|
|
},
|
|
"/.well-known/oauth-authorization-server": {
|
|
kind: "wellKnown",
|
|
methods: ["GET"],
|
|
},
|
|
"/.well-known/oauth-protected-resource": {
|
|
kind: "wellKnown",
|
|
methods: ["GET"],
|
|
},
|
|
};
|
|
|
|
function stripComments(text: string): string {
|
|
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
|
|
}
|
|
|
|
/** `src/app/api/foo/[id]/route.ts` → `/api/foo/{id}`. */
|
|
function toApiPath(file: string): string {
|
|
return (
|
|
"/" +
|
|
relative("src/app", file)
|
|
.replace(/\/route\.ts$/, "")
|
|
.replace(/\[\.\.\.([A-Za-z0-9_$]+)\]/g, "{$1}")
|
|
.replace(/\[([A-Za-z0-9_$]+)\]/g, "{$1}")
|
|
);
|
|
}
|
|
|
|
function exportedMethods(source: string): HttpMethod[] {
|
|
return HTTP_METHODS.filter((method) =>
|
|
new RegExp(
|
|
`export\\s+(?:async\\s+)?(?:function\\s+${method}\\b|const\\s+${method}\\b)`,
|
|
).test(source),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Every route module under `src/app`, dot-directories included.
|
|
*
|
|
* The two patterns are not redundant: `globSync("src/app/**")` does not descend
|
|
* into `.well-known`, and a sweep that quietly skips it is exactly the failure
|
|
* this project has already had once.
|
|
*/
|
|
function routeModules(): string[] {
|
|
const found = new Set<string>([
|
|
...globSync("src/app/**/route.ts", { cwd: ROOT }),
|
|
...globSync("src/app/**/.*/**/route.ts", { cwd: ROOT }),
|
|
]);
|
|
return [...found].sort();
|
|
}
|
|
|
|
interface DiskRoute {
|
|
path: string;
|
|
methods: HttpMethod[];
|
|
}
|
|
|
|
function routesOnDisk(): DiskRoute[] {
|
|
const routes: DiskRoute[] = [];
|
|
for (const file of routeModules()) {
|
|
const methods = exportedMethods(stripComments(readFileSync(file, "utf8")));
|
|
if (methods.length === 0) continue;
|
|
routes.push({ path: toApiPath(file), methods });
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
describe("every route is published, unpublished, or retired", () => {
|
|
const disk = routesOnDisk();
|
|
const retiredPathSet = new Set(RETIRED_ROUTES.map((route) => route.path));
|
|
|
|
it("finds the route modules at all", () => {
|
|
// A floor, not a count: the point is that an enumeration returning nothing
|
|
// cannot read as "everything is covered". The number moves with the tree,
|
|
// so it sits well below the real one.
|
|
expect(
|
|
disk.length,
|
|
"the route sweep found almost nothing, which means the pattern is wrong rather than the tree empty",
|
|
).toBeGreaterThan(300);
|
|
});
|
|
|
|
it("no route verb is missing from the contract without a reason", () => {
|
|
const table = openApiPaths as Record<string, Record<string, unknown>>;
|
|
const undocumented: string[] = [];
|
|
|
|
for (const { path, methods } of disk) {
|
|
const exempt = UNPUBLISHED[path];
|
|
const published = table[path] ?? {};
|
|
for (const method of methods) {
|
|
if (exempt?.methods.includes(method)) continue;
|
|
if (method.toLowerCase() in published) continue;
|
|
undocumented.push(`${method} ${path}`);
|
|
}
|
|
}
|
|
|
|
expect(
|
|
undocumented,
|
|
"these routes answer requests and no client can read what they answer with",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("the exemption list names routes that exist", () => {
|
|
const byPath = new Map(disk.map((route) => [route.path, route.methods]));
|
|
const stale: string[] = [];
|
|
|
|
for (const [path, exempt] of Object.entries(UNPUBLISHED)) {
|
|
const methods = byPath.get(path);
|
|
if (!methods) {
|
|
stale.push(`${path} (no such route)`);
|
|
continue;
|
|
}
|
|
for (const method of exempt.methods) {
|
|
if (!methods.includes(method)) {
|
|
stale.push(`${method} ${path} (route does not export it)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(
|
|
stale,
|
|
"an exemption for a route that is gone hides the next route that needs one",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("the exemption list names routes that are not published after all", () => {
|
|
// The other way an entry goes stale, and the one this list had: a route
|
|
// gets published and its exemption stays behind, so the list reads as
|
|
// larger than it is and the reason attached to it looks load-bearing when
|
|
// nothing rests on it. Two share-link routes sat here in exactly that
|
|
// state, beside a published sibling in the same tree.
|
|
const table = openApiPaths as Record<string, Record<string, unknown>>;
|
|
const redundant: string[] = [];
|
|
|
|
for (const [path, exempt] of Object.entries(UNPUBLISHED)) {
|
|
for (const method of exempt.methods) {
|
|
if (method.toLowerCase() in (table[path] ?? {})) {
|
|
redundant.push(`${method} ${path}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(
|
|
redundant,
|
|
"these operations are published AND exempt, so the exemption states a reason nothing depends on",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("the contract does not publish a path that no route serves, unless it is retired", () => {
|
|
const byPath = new Set(disk.map((route) => route.path));
|
|
const phantom = Object.keys(openApiPaths).filter(
|
|
(path) => !byPath.has(path) && !retiredPathSet.has(path),
|
|
);
|
|
|
|
expect(
|
|
phantom,
|
|
"a published path with no route behind it is a promise the deployment cannot keep — register it in RETIRED_ROUTES if it is gone on purpose",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("a retired path is really gone from src/app", () => {
|
|
// The other direction of the exemption above. A tombstone standing over a
|
|
// route that still answers would publish 410 as the only response while
|
|
// the proxy short-circuits the live handler — the contract, the capability
|
|
// list and the runtime would all be wrong together, and each of them looks
|
|
// internally consistent on its own.
|
|
const byPath = new Set(disk.map((route) => route.path));
|
|
const undead = RETIRED_ROUTES.map((route) => route.path).filter((path) =>
|
|
byPath.has(path),
|
|
);
|
|
|
|
expect(
|
|
undead,
|
|
"these paths are registered as retired and a route module still exports verbs for them",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("every retired path is published as gone", () => {
|
|
// Registering a retirement and not publishing it puts a client back where
|
|
// it started: the proxy answers 410, and a client generated from the
|
|
// contract has never heard of the path. The retirement is only worth
|
|
// anything if it reaches the reader.
|
|
const table = openApiPaths as Record<string, Record<string, unknown>>;
|
|
const unpublished: string[] = [];
|
|
|
|
for (const route of RETIRED_ROUTES) {
|
|
const published = table[route.path];
|
|
if (!published) {
|
|
unpublished.push(`${route.path} (absent from the contract)`);
|
|
continue;
|
|
}
|
|
for (const method of route.methods) {
|
|
const operation = published[method.toLowerCase()] as
|
|
{ responses?: Record<string, unknown> } | undefined;
|
|
if (!operation) {
|
|
unpublished.push(`${method} ${route.path} (verb not published)`);
|
|
continue;
|
|
}
|
|
if (!operation.responses?.["410"]) {
|
|
unpublished.push(`${method} ${route.path} (published without a 410)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(
|
|
unpublished,
|
|
"these retirements are answered by the server and invisible in the contract",
|
|
).toEqual([]);
|
|
});
|
|
});
|