feat(cms): improve catalog, editorial recovery and operations

This commit is contained in:
2026-09-09 19:36:15 +02:00
parent 2fead134e6
commit c389c3893d
122 changed files with 8137 additions and 703 deletions
+113 -2
View File
@@ -5,11 +5,14 @@ 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() }));
@@ -19,6 +22,7 @@ vi.mock("./clone-sources", () => ({ getSource: async () => null }));
vi.mock("./furni-job-store", () => ({
ImportJobStore: class {
list = mocks.list;
isCancellationRequested = mocks.cancel;
save = mocks.save;
},
}));
@@ -33,7 +37,7 @@ vi.mock("./furni-attachment", () => ({
readFurnitureAttachment: mocks.attachment,
}));
vi.mock("./rcon", () => ({
rcon: { updateCatalog: async () => {}, updateItems: async () => {} },
rcon: { updateCatalog: mocks.updateCatalog, updateItems: mocks.updateItems },
}));
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
@@ -44,10 +48,14 @@ 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,
@@ -89,7 +97,7 @@ it("marks abandoned work interrupted without repeating an uncertain import", asy
job.items[0].state = "running";
await drainFurnitureImports();
expect(job.state).toBe("interrupted");
expect(job.items[0].state).toBe("failed");
expect(job.items[0].state).toBe("interrupted");
expect(mocks.import).not.toHaveBeenCalled();
});
it("keeps errors for failed items and continues the batch", async () => {
@@ -112,3 +120,106 @@ it("loads attachments using the job owner and exact classname", async () => {
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(2);
expect(snapshots.at(-1)?.items[0].state).toBe("running");
expect(mocks.updateCatalog).not.toHaveBeenCalled();
});