feat(catalog): generate derived scale 32 with preview and backup restore

This commit is contained in:
simoleo89
2026-09-05 17:36:22 +02:00
parent 08b0b5021e
commit cd75361b9f
9 changed files with 749 additions and 11 deletions
@@ -0,0 +1,104 @@
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 { 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" }))).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) })))
.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) })))
.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),
}),
)
).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) })))
.status,
).toBe(422);
expect(mocks.rename).not.toHaveBeenCalled();
});