132 lines
3.9 KiB
TypeScript
132 lines
3.9 KiB
TypeScript
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(),
|
|
cancel: 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", async (original) => ({
|
|
...(await original<typeof import("@/lib/services/furni-job-store")>()),
|
|
ImportJobStore: class {
|
|
create = mocks.create;
|
|
list = mocks.list;
|
|
requestCancellation = mocks.cancel;
|
|
},
|
|
}));
|
|
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
|
|
|
|
import { GET, PATCH, 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("requests bounded owner-scoped history from the store", async () => {
|
|
mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]);
|
|
const response = await GET(request({}), ctx);
|
|
expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 });
|
|
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
|
|
});
|
|
it("requests cancellation with the authenticated owner", async () => {
|
|
const id = randomUUID();
|
|
mocks.cancel.mockResolvedValue({ id, cancelRequested: true });
|
|
const response = await PATCH(request({ id }), ctx);
|
|
expect(response.status).toBe(200);
|
|
expect(mocks.cancel).toHaveBeenCalledWith(id, 7);
|
|
});
|
|
it("does not reveal other owners' jobs", async () => {
|
|
mocks.cancel.mockResolvedValue(null);
|
|
expect((await PATCH(request({ id: randomUUID() }), ctx)).status).toBe(404);
|
|
});
|
|
it("rejects unsafe cancellation IDs", async () => {
|
|
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
|
|
});
|