265 lines
9.3 KiB
TypeScript
265 lines
9.3 KiB
TypeScript
import { beforeEach, expect, it, vi } from "vitest";
|
|
import type { ImportJob } from "@/lib/furni/import-job";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
set: vi.fn(),
|
|
eval: vi.fn(),
|
|
list: vi.fn(),
|
|
cancel: vi.fn(),
|
|
save: vi.fn(),
|
|
import: vi.fn(),
|
|
attachment: vi.fn(),
|
|
export: vi.fn(),
|
|
translate: vi.fn(),
|
|
updateCatalog: vi.fn(),
|
|
updateItems: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } }));
|
|
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
|
|
vi.mock("./audit", () => ({ logAudit: vi.fn().mockResolvedValue(undefined) }));
|
|
vi.mock("./catalog-git-queue", () => ({ withCatalogExport: mocks.export }));
|
|
vi.mock("./clone-sources", () => ({ getSource: async () => null }));
|
|
vi.mock("./furni-job-store", () => ({
|
|
ImportJobStore: class {
|
|
list = mocks.list;
|
|
isCancellationRequested = mocks.cancel;
|
|
save = mocks.save;
|
|
},
|
|
}));
|
|
vi.mock("./furni-import", () => ({
|
|
ensureDirectories: async () => {},
|
|
importSingleFurni: mocks.import,
|
|
}));
|
|
vi.mock("./furni-data-i18n", () => ({
|
|
patchLocalizedFurniDataEntries: mocks.translate,
|
|
}));
|
|
vi.mock("./furni-attachment", () => ({
|
|
readFurnitureAttachment: mocks.attachment,
|
|
}));
|
|
vi.mock("./rcon", () => ({
|
|
rcon: { updateCatalog: mocks.updateCatalog, updateItems: mocks.updateItems },
|
|
}));
|
|
|
|
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
|
|
|
|
vi.mock("./furni-sync-item", () => ({ runSyncJobItem: mocks.import }));
|
|
|
|
import { drainFurnitureImports } from "./furni-job-worker";
|
|
|
|
let job: ImportJob;
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.set.mockResolvedValue("OK");
|
|
mocks.cancel.mockResolvedValue(false);
|
|
mocks.eval.mockResolvedValue(1);
|
|
mocks.export.mockImplementation((fn) => fn());
|
|
mocks.import.mockResolvedValue({ ok: true, itemId: 900, warnings: [] });
|
|
mocks.save.mockResolvedValue(undefined);
|
|
mocks.updateCatalog.mockResolvedValue(undefined);
|
|
mocks.updateItems.mockResolvedValue(undefined);
|
|
mocks.translate.mockResolvedValue(undefined);
|
|
job = {
|
|
id: "job",
|
|
userId: 5,
|
|
createdAt: "now",
|
|
updatedAt: "now",
|
|
state: "queued",
|
|
translate: false,
|
|
items: [
|
|
{
|
|
id: 1,
|
|
classname: "chair",
|
|
name: "Chair",
|
|
description: "",
|
|
type: "flooritem",
|
|
revision: 1,
|
|
category: "other",
|
|
state: "pending",
|
|
},
|
|
],
|
|
};
|
|
mocks.list.mockResolvedValue([job]);
|
|
});
|
|
it("imports with repair enabled and saves the allocated local ID", async () => {
|
|
await drainFurnitureImports();
|
|
expect(mocks.import).toHaveBeenCalledWith(
|
|
expect.objectContaining({ classname: "chair", repairExisting: true }),
|
|
);
|
|
expect(job.state).toBe("completed");
|
|
expect(job.items[0]).toMatchObject({ state: "done", itemId: 900 });
|
|
expect(mocks.export).toHaveBeenCalledOnce();
|
|
});
|
|
it("does not run a second worker while another holds the lease", async () => {
|
|
mocks.set.mockResolvedValue(null);
|
|
await drainFurnitureImports();
|
|
expect(mocks.import).not.toHaveBeenCalled();
|
|
});
|
|
it("marks abandoned work interrupted without repeating an uncertain import", async () => {
|
|
job.state = "running";
|
|
job.items[0].state = "running";
|
|
await drainFurnitureImports();
|
|
expect(job.state).toBe("interrupted");
|
|
expect(job.items[0].state).toBe("interrupted");
|
|
expect(mocks.import).not.toHaveBeenCalled();
|
|
});
|
|
it("keeps errors for failed items and continues the batch", async () => {
|
|
job.items.push({ ...job.items[0], classname: "table" });
|
|
mocks.import.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: "Missing asset",
|
|
warnings: [],
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(job.items.map((i) => i.state)).toEqual(["failed", "done"]);
|
|
expect(job.items[0].error).toBe("Missing asset");
|
|
});
|
|
it("loads attachments using the job owner and exact classname", async () => {
|
|
job.items[0].attachmentId = "upload";
|
|
mocks.attachment.mockResolvedValue(Buffer.from("verified"));
|
|
await drainFurnitureImports();
|
|
expect(mocks.attachment).toHaveBeenCalledWith("upload", "chair", 5);
|
|
expect(mocks.import).toHaveBeenCalledWith(
|
|
expect.objectContaining({ providedNitro: Buffer.from("verified") }),
|
|
);
|
|
});
|
|
|
|
it("cancels queued work without importing any items", async () => {
|
|
mocks.cancel.mockResolvedValue(true);
|
|
await drainFurnitureImports();
|
|
expect(job.state).toBe("cancelled");
|
|
expect(job.items[0].state).toBe("cancelled");
|
|
expect(mocks.import).not.toHaveBeenCalled();
|
|
});
|
|
it("finishes the in-flight item and cancels only the remaining items", async () => {
|
|
job.items.push({ ...job.items[0], classname: "table" });
|
|
mocks.import.mockImplementationOnce(async () => {
|
|
mocks.cancel.mockResolvedValue(true);
|
|
return { ok: true, itemId: 900, warnings: [] };
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(job.state).toBe("cancelled");
|
|
expect(job.items.map((item) => item.state)).toEqual(["done", "cancelled"]);
|
|
expect(mocks.import).toHaveBeenCalledOnce();
|
|
});
|
|
it("reports completion if cancellation arrives during the final item", async () => {
|
|
mocks.import.mockImplementationOnce(async () => {
|
|
mocks.cancel.mockResolvedValue(true);
|
|
return { ok: true, itemId: 900, warnings: [] };
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(job.state).toBe("completed");
|
|
});
|
|
it("preserves pending items when recovery finds an uncertain in-flight import", async () => {
|
|
job.state = "running";
|
|
job.items[0].state = "running";
|
|
job.items.push({ ...job.items[0], classname: "table", state: "pending" });
|
|
await drainFurnitureImports();
|
|
expect(job.items.map((item) => item.state)).toEqual([
|
|
"interrupted",
|
|
"pending",
|
|
]);
|
|
expect(mocks.import).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not overwrite another worker's interruption after losing ownership during the final import", async () => {
|
|
let persisted: ImportJob | undefined;
|
|
mocks.save.mockImplementation(async (value: ImportJob) => {
|
|
persisted = structuredClone(value);
|
|
});
|
|
mocks.import.mockImplementationOnce(async () => {
|
|
persisted = {
|
|
...structuredClone(job),
|
|
state: "interrupted",
|
|
items: job.items.map((item) => ({ ...item, state: "interrupted" })),
|
|
};
|
|
mocks.eval.mockResolvedValue(0);
|
|
return { ok: true, itemId: 900, warnings: [] };
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(mocks.import).toHaveBeenCalledOnce();
|
|
expect(mocks.save).toHaveBeenCalledTimes(2);
|
|
expect(persisted?.state).toBe("interrupted");
|
|
expect(persisted?.items[0].state).toBe("interrupted");
|
|
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
|
});
|
|
it("keeps an uncertain import running on disk when lease verification fails", async () => {
|
|
const snapshots: ImportJob[] = [];
|
|
mocks.save.mockImplementation(async (value: ImportJob) => {
|
|
snapshots.push(structuredClone(value));
|
|
});
|
|
mocks.import.mockImplementationOnce(async () => {
|
|
mocks.eval.mockRejectedValue(new Error("Redis unavailable"));
|
|
throw new Error("SQL outcome unknown");
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(snapshots).toHaveLength(2);
|
|
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
|
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
|
});
|
|
it("does not persist final completion when ownership changes during cache refresh", async () => {
|
|
const snapshots: ImportJob[] = [];
|
|
mocks.save.mockImplementation(async (value: ImportJob) => {
|
|
snapshots.push(structuredClone(value));
|
|
});
|
|
mocks.updateCatalog.mockImplementationOnce(async () => {
|
|
mocks.eval.mockResolvedValue(0);
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(snapshots.at(-1)?.state).toBe("running");
|
|
expect(snapshots.at(-1)?.items[0].state).toBe("done");
|
|
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
|
|
expect(mocks.updateItems).not.toHaveBeenCalled();
|
|
});
|
|
it("does not save a translated outcome after ownership is lost during translation", async () => {
|
|
const snapshots: ImportJob[] = [];
|
|
job.translate = true;
|
|
mocks.save.mockImplementation(async (value: ImportJob) => {
|
|
snapshots.push(structuredClone(value));
|
|
});
|
|
mocks.translate.mockImplementationOnce(async () => {
|
|
mocks.eval.mockResolvedValue(0);
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(mocks.translate).toHaveBeenCalledOnce();
|
|
expect(snapshots).toHaveLength(3);
|
|
expect(snapshots.at(-1)?.items[0].state).toBe("running");
|
|
expect(mocks.updateCatalog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores the original operation context while running a durable job", async () => {
|
|
const { getOperationContext } = await import(
|
|
"@/lib/foundation/request-context"
|
|
);
|
|
job.operationId = "original-operation";
|
|
mocks.import.mockImplementation(async () => {
|
|
expect(getOperationContext()).toEqual({
|
|
operationId: "original-operation",
|
|
userId: 5,
|
|
});
|
|
return { ok: true, itemId: 900, warnings: [] };
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(getOperationContext()).toEqual({});
|
|
});
|
|
|
|
it("keeps successful imports complete if their audit write fails", async () => {
|
|
const { logAudit } = await import("./audit");
|
|
vi.mocked(logAudit).mockRejectedValueOnce(new Error("Audit unavailable"));
|
|
await drainFurnitureImports();
|
|
expect(job.items[0].state).toBe("done");
|
|
expect(job.items[0].warnings).toContain("Audit record could not be saved");
|
|
});
|
|
|
|
it("persists progress before continuing an import and retains the failed phase", async () => {
|
|
mocks.import.mockImplementationOnce(async (params) => {
|
|
await params.onProgress("downloading");
|
|
expect(job.items[0].phase).toBe("downloading");
|
|
expect(mocks.save).toHaveBeenCalled();
|
|
await params.onProgress("converting");
|
|
throw Error("Conversion failed");
|
|
});
|
|
await drainFurnitureImports();
|
|
expect(job.items[0]).toMatchObject({ state: "failed", phase: "converting" });
|
|
expect(job.items[0].phaseStartedAt).toBeTruthy();
|
|
});
|