`next build` had never completed on this host, so three real defects were
sitting in the tree untested. All three are now fixed and the build is green.
- The build was not memory-bound the way it looked. Turbopack's builder reached
20.5GB RSS and died, and raising `--max-old-space-size` could never have
helped: that flag caps the V8 heap, while the 20GB sat in Turbopack's own Rust
allocator. The first symptom was misleading because the process doing the
allocating is a grandchild of `npx`, so watching the direct child shows a
95MB shim the whole time. Building with `--webpack` puts the build back under
the JS heap, where the flag actually applies: peak 5.9GB, 150s, exit 0.
- withAdmin's second parameter was typed `{ params?: ... }` and given a `= {}`
default, which made it optional and `RouteContext | undefined`. Next's
generated route types assert that argument against `ParamCheck<RouteContext>`
and reject it, across 113 route files. `tsc --noEmit` cannot see this, because
Next only adds `.next/types` to the project during a production build — so the
type check that everyone runs locally was structurally incapable of catching
the only type error that blocks a deploy. `params` is now required, which is
also what the code already assumed: it is awaited with no guard. The 35 test
call sites that invoked a handler with one argument now pass a real context,
and the await got a guard so a direct internal call cannot turn a missing
context into a 500.
- `src/app/api/admin/import/furni/route.ts` re-exported `ensureDirectories` and
`importSingleFurni` for "backward compatibility" that nothing used; the batch
route imports from `@/lib/services/furni-import` directly. Next rejects any
value export from a route module that is not an HTTP verb or config, so this
had been breaking the build for as long as it existed. Removed.
- `isomorphic-dompurify` builds its server-side DOM through jsdom. Bundled, that
pulls jsdom's `browser/default-stylesheet.css` into the server chunk, where the
path no longer resolves, and page-data collection dies with ENOENT on every
page that sanitizes HTML. Marked external so Node resolves it from
node_modules and the standalone tracer includes it.
The remaining build warning is a pre-existing circular dependency between
chunks that share the webpack runtime. It costs hash reuse, not correctness, and
is left alone rather than churned here.
Verified: build exit 0, 276 static pages generated, 3223 tests pass, tsc and
biome clean.
121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
read: vi.fn(),
|
|
write: vi.fn(),
|
|
rename: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
rm: vi.fn(),
|
|
generate: vi.fn(),
|
|
}));
|
|
vi.mock("node:fs", () => ({
|
|
promises: {
|
|
readFile: mocks.read,
|
|
writeFile: mocks.write,
|
|
rename: mocks.rename,
|
|
mkdir: mocks.mkdir,
|
|
rm: mocks.rm,
|
|
},
|
|
}));
|
|
vi.mock("@/lib/api-handler", () => ({
|
|
withAdmin: (_: unknown, h: unknown) => h,
|
|
}));
|
|
vi.mock("@/lib/services/furni-asset-dirs", () => ({
|
|
getFurniAssetDirs: async () => ({ nitroDir: "/nitro" }),
|
|
}));
|
|
vi.mock("@/lib/services/furni-data", () => ({
|
|
withFurniDataLock: (f: () => Promise<unknown>) => f(),
|
|
}));
|
|
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
|
withCatalogExport: (f: () => Promise<unknown>) => f(),
|
|
}));
|
|
vi.mock("@/lib/furni/nitro-scale32", () => ({
|
|
generateScale32: mocks.generate,
|
|
}));
|
|
vi.mock("@/lib/services/swf/nitro-builder", () => ({
|
|
parseNitroBundle: () => ({ json: { assets: {} } }),
|
|
}));
|
|
|
|
import { emptyRouteContext } from "@/test/route-context";
|
|
import { POST } from "./route";
|
|
|
|
const original = Buffer.from("original"),
|
|
generated = Buffer.from("generated"),
|
|
hash = (b: Buffer) => createHash("sha256").update(b).digest("hex");
|
|
const request = (body: object) =>
|
|
new NextRequest("http://localhost/api/admin/studio/nitro-scale32", {
|
|
method: "POST",
|
|
body: JSON.stringify({ classname: "chair", ...body }),
|
|
});
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.read.mockResolvedValue(original);
|
|
mocks.generate.mockResolvedValue({
|
|
bundle: generated,
|
|
before: "before",
|
|
after: "after",
|
|
count: 1,
|
|
});
|
|
});
|
|
it("preview does not write anything", async () => {
|
|
expect(
|
|
(await POST(request({ action: "preview" }), emptyRouteContext())).status,
|
|
).toBe(200);
|
|
expect(mocks.write).not.toHaveBeenCalled();
|
|
});
|
|
it("rejects stale source before generation or writes", async () => {
|
|
expect(
|
|
(
|
|
await POST(
|
|
request({ action: "apply", expectedHash: hash(generated) }),
|
|
emptyRouteContext(),
|
|
)
|
|
).status,
|
|
).toBe(409);
|
|
expect(mocks.generate).not.toHaveBeenCalled();
|
|
expect(mocks.write).not.toHaveBeenCalled();
|
|
});
|
|
it("backs up the original before replacing the bundle", async () => {
|
|
expect(
|
|
(
|
|
await POST(
|
|
request({ action: "apply", expectedHash: hash(original) }),
|
|
emptyRouteContext(),
|
|
)
|
|
).status,
|
|
).toBe(200);
|
|
expect(mocks.write.mock.calls[0][1]).toEqual(original);
|
|
expect(mocks.write.mock.calls[1][1]).toEqual(generated);
|
|
expect(mocks.rename).toHaveBeenCalledOnce();
|
|
});
|
|
it("restores the exact backup after checking the current hash", async () => {
|
|
mocks.read.mockResolvedValueOnce(generated).mockResolvedValueOnce(original);
|
|
expect(
|
|
(
|
|
await POST(
|
|
request({
|
|
action: "restore",
|
|
expectedHash: hash(generated),
|
|
backupHash: hash(original),
|
|
}),
|
|
emptyRouteContext(),
|
|
)
|
|
).status,
|
|
).toBe(200);
|
|
expect(mocks.write.mock.calls[0][1]).toEqual(original);
|
|
});
|
|
it("does not replace the live file when backup writing fails", async () => {
|
|
mocks.write.mockRejectedValueOnce(Error("disk full"));
|
|
expect(
|
|
(
|
|
await POST(
|
|
request({ action: "apply", expectedHash: hash(original) }),
|
|
emptyRouteContext(),
|
|
)
|
|
).status,
|
|
).toBe(422);
|
|
expect(mocks.rename).not.toHaveBeenCalled();
|
|
});
|