feat(catalog): queue furniture imports with history and matching file attachments
CI / check (push) Successful in 1m30s
CI / deploy (push) Successful in 1m26s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 17:02:26 +02:00
1 parent 775d14f861
commit 2619bec165
16 files changed
+1016 -232

No files matched your search

@@ -0,0 +1,116 @@
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { beforeEach, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
const mocks = vi.hoisted(() => ({
guard: vi.fn(),
create: vi.fn(),
list: vi.fn(),
after: vi.fn(),
ping: vi.fn(),
source: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("next/server", async (original) => ({
...(await original<typeof import("next/server")>()),
after: mocks.after,
}));
vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
vi.mock("@/lib/services/furni-job-worker", () => ({
drainFurnitureImports: vi.fn(),
}));
vi.mock("@/lib/services/furni-job-store", () => ({
ImportJobStore: class {
create = mocks.create;
list = mocks.list;
},
}));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
import { GET, POST } from "./route";
const item = {
id: 1,
classname: "nft_china_light",
name: "Lamp",
description: "",
type: "wallitem",
revision: 70317,
category: "other",
};
const ctx = { session: { user: { id: 7 } } } as never;
const request = (body: unknown) =>
new NextRequest("http://localhost/api/admin/studio/import-jobs", {
method: "POST",
body: JSON.stringify(body),
});
beforeEach(() => {
mocks.create.mockClear();
mocks.list.mockClear();
mocks.after.mockClear();
mocks.ping.mockResolvedValue("PONG");
mocks.source.mockResolvedValue(null);
mocks.create.mockImplementation((job) => job);
mocks.list.mockResolvedValue([]);
});
it("requires asset import permission", () => {
expect(mocks.guard).toHaveBeenCalledWith({ permission: PERMS.ASSETS_IMPORT });
});
it("queues a durable owner-scoped job and schedules execution after responding", async () => {
const response = await POST(
request({ id: randomUUID(), items: [item, item] }),
ctx,
);
expect(response.status).toBe(200);
expect(mocks.create.mock.calls[0][0]).toMatchObject({
userId: 7,
state: "queued",
items: [{ ...item, state: "pending" }],
});
expect(mocks.after).toHaveBeenCalledOnce();
});
it("rejects traversal before saving work", async () => {
expect(
(
await POST(
request({
id: randomUUID(),
items: [{ ...item, classname: "../bad" }],
}),
ctx,
)
).status,
).toBe(400);
expect(mocks.create).not.toHaveBeenCalled();
});
it("does not accept work when the worker lease service is unavailable", async () => {
mocks.ping.mockRejectedValue(Error("offline"));
expect(
(await POST(request({ id: randomUUID(), items: [item] }), ctx)).status,
).toBe(503);
expect(mocks.create).not.toHaveBeenCalled();
});
it("rejects a missing configured source", async () => {
expect(
(
await POST(
request({ id: randomUUID(), sourceId: "gone", items: [item] }),
ctx,
)
).status,
).toBe(404);
});
it("only returns the current operators history", async () => {
mocks.list.mockResolvedValue([
{ id: "a", userId: 7 },
{ id: "b", userId: 9 },
]);
const response = await GET(request({}), ctx);
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
});