Add Catalog Studio inspection and guided import review
CI / check (push) Successful in 1m39s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 13:10:43 +02:00
1 parent 2578bf6a09
commit 26f071117b
7 files changed
+961 -44

No files matched your search

@@ -0,0 +1,97 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
read: vi.fn(),
stat: vi.fn(),
guard: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("@/lib/services/furni-data", () => ({ readFurniData: mocks.read }));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetDirs: async () => ({
nitroDir: "/assets/nitro",
iconDir: "/assets/icons",
}),
}));
vi.mock("node:fs", () => ({ promises: { stat: mocks.stat } }));
import { POST } from "./route";
const request = (classnames: unknown) =>
new NextRequest("http://localhost/api/admin/studio/inspect", {
method: "POST",
body: JSON.stringify({ classnames }),
});
describe("furniture inspection", () => {
beforeEach(() => {
mocks.execute.mockReset().mockResolvedValue([[], []]);
mocks.read.mockReset().mockResolvedValue({
roomitemtypes: { furnitype: [] },
wallitemtypes: { furnitype: [] },
});
mocks.stat.mockReset().mockRejectedValue({ code: "ENOENT" });
});
it("requires asset import permission", () => {
expect(mocks.guard).toHaveBeenCalledWith({
permission: expect.any(String),
});
});
it("rejects path traversal before reading data", async () => {
expect((await POST(request(["../secret"]))).status).toBe(400);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.stat).not.toHaveBeenCalled();
});
it("uses local SQL IDs and color-aware asset filenames", async () => {
mocks.execute
.mockResolvedValueOnce([
[
{
id: 90,
spriteId: 91,
classname: "chair*2",
name: "Chair",
type: "s",
},
],
[],
])
.mockResolvedValueOnce([
[{ classname: "chair*2", id: 5, pageId: 7, credits: 3, points: 0 }],
[],
]);
const response = await POST(request(["chair*2"]));
const body = await response.json();
expect(body.items[0].sql[0].id).toBe(90);
expect(body.items[0].catalog[0].pageId).toBe(7);
expect(
mocks.stat.mock.calls.map((call) =>
String(call[0]).replaceAll("\\", "/"),
),
).toEqual(["/assets/nitro/chair.nitro", "/assets/icons/chair_2_icon.png"]);
});
it("distinguishes unreadable data and denied assets from missing files", async () => {
mocks.read.mockRejectedValue(new Error("unreadable"));
mocks.stat.mockRejectedValue({ code: "EACCES" });
const body = await (await POST(request(["chair"]))).json();
expect(body.items[0].furnidataReadable).toBe(false);
expect(body.items[0].icon.exists).toBeNull();
expect(body.items[0].nitro.exists).toBeNull();
});
it("handles malformed furnidata without inventing a clean comparison", async () => {
mocks.read.mockResolvedValue({
roomitemtypes: { furnitype: {} },
wallitemtypes: { furnitype: [null] },
});
const response = await POST(request(["chair"]));
expect(response.status).toBe(200);
expect((await response.json()).items[0].furnidataReadable).toBe(false);
});
});