chore: update route.ts

This commit is contained in:
Dave 2026-10-15 16:28:37 +02:00
parent d35c25a9e7
commit 248c390184
2 changed files with 129 additions and 9 deletions

View file

@ -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([]);
});
});

View file

@ -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<string, unknown> = {
problem_flag: false,
problem_type: "",
problem_notes: "",
};
const patch: Record<string, unknown> = { ...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;