WFDASH-WEBAPP/src/app/api/route-export-contract.test.ts
2026-10-15 16:28:37 +02:00

108 lines
No EOL
3.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* Guards the App Router export contract for API route handlers.
*
* WHY THIS EXISTS
*
* Next.js type-checks every export from a `route.ts` against the Route
* handler contract at BUILD time. An extra named export does not fail
* `tsc --noEmit`, does not fail eslint, and does not fail any test — it
* only fails `next build`, i.e. in CI/production. That is how
* `collection-map/items/[id]/clear-problem/route.ts` shipped an
* `export const CLEARED_FIELDS` and took a deployment down with:
*
* Type error: Route "..." does not match the required types of a
* Next.js Route. "CLEARED_FIELDS" is not a valid Route export field.
*
* The failing check ran ~5 minutes into a Docker build on the deploy
* server, long after every local signal was green. This test runs in
* seconds and turns that into a local failure.
*/
/**
* Names Next.js permits from a route module. Derived from the Route
* contract: HTTP verb handlers plus segment config and the metadata /
* viewport family. Anything else is a build error.
*/
const ALLOWED_ROUTE_EXPORTS = new Set([
// HTTP verbs
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
// Segment config
"runtime",
"dynamic",
"dynamicParams",
"revalidate",
"fetchCache",
"preferredRegion",
"maxDuration",
"dynamicIO",
// Metadata / viewport
"metadata",
"generateMetadata",
"viewport",
"generateViewport",
// Misc
"generateStaticParams",
"config",
"default",
]);
const API_ROOT = join(process.cwd(), "src", "app", "api");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (entry === "route.ts") out.push(full);
}
return out;
}
/** Exported binding names in a module, ignoring anything inside comments. */
function exportedNames(source: string): string[] {
const stripped = source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
return [
...stripped.matchAll(
/^\s*export\s+(?:async\s+)?(?:const|function|let|var|class)\s+([A-Za-z0-9_]+)/gm,
),
].map((m) => m[1]);
}
describe("App Router export contract", () => {
const routes = walk(API_ROOT);
it("finds the API routes (guard against a broken path silently passing)", () => {
expect(routes.length).toBeGreaterThan(50);
});
it("no route.ts exports a name Next.js rejects at build time", () => {
const violations: string[] = [];
for (const file of routes) {
for (const name of exportedNames(readFileSync(file, "utf8"))) {
if (!ALLOWED_ROUTE_EXPORTS.has(name)) {
violations.push(
`${file.replace(process.cwd() + "/", "")} exports "${name}"`,
);
}
}
}
expect(
violations,
"These exports break `next build` with \"is not a valid Route export " +
"field\". Move the shared value into a non-route module and import " +
"it, or assert it through the handler's response instead.",
).toEqual([]);
});
});