From 248c390184b75a502a1528a7a9785414ceba68e4 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 15 Oct 2026 16:28:37 +0200 Subject: [PATCH] chore: update route.ts --- src/app/api/route-export-contract.test.ts | 108 ++++++++++++++++++ .../items/[id]/clear-problem/route.ts | 30 +++-- 2 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 src/app/api/route-export-contract.test.ts diff --git a/src/app/api/route-export-contract.test.ts b/src/app/api/route-export-contract.test.ts new file mode 100644 index 0000000..2c24012 --- /dev/null +++ b/src/app/api/route-export-contract.test.ts @@ -0,0 +1,108 @@ +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([]); + }); +}); \ No newline at end of file diff --git a/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts b/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts index 6107119..a1e1dbb 100644 --- a/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts +++ b/src/app/api/v1/collection-map/items/[id]/clear-problem/route.ts @@ -34,7 +34,26 @@ type BodyInput = { source?: "customer" | "stock"; }; -const PROBLEM_FIELDS = ["problem_flag", "problem_type", "problem_notes"] as const; +/** + * The only fields this route is allowed to write. + * + * Kept as a local (NOT exported) constant: Next.js type-checks every + * export from a `route.ts` against the Route handler contract, and any + * extra name — `CLEARED_FIELDS` did this — fails `next build` with + * "is not a valid Route export field". A previously-declared + * `PROBLEM_FIELDS`/`CLEARED_FIELDS` pair existed only to be imported by + * a unit test that was never written, so nothing consumed them and they + * broke the production build for no benefit. + * + * If a test later needs this list, assert it through the PATCH + * response's `applied` field (already returned) instead of importing + * from the route module. + */ +const CLEARED_PROBLEM = { + problem_flag: false, + problem_type: "", + problem_notes: "", +}; export async function PATCH( req: NextRequest, @@ -64,11 +83,7 @@ export async function PATCH( return NextResponse.json({ ok: true, item: current, applied: null }); } - const patch: Record = { - problem_flag: false, - problem_type: "", - problem_notes: "", - }; + const patch: Record = { ...CLEARED_PROBLEM }; const updated = await pb.collection(collectionName).update(id, patch); return NextResponse.json({ ok: true, item: updated, applied: patch }); @@ -79,6 +94,3 @@ export async function PATCH( return NextResponse.json({ error: message }, { status }); } } - -/** Fields this route is allowed to write. Exported for the unit test. */ -export const CLEARED_FIELDS = PROBLEM_FIELDS; \ No newline at end of file