feat(import): run catalog synchronizations as durable jobs
CI / check (push) Successful in 57s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m18s

This commit is contained in:
Simo committed 2026-09-11 00:36:22 +02:00
1 parent a2954e4408
commit 76f0420d64
14 files changed
+559 -801

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
read: vi.fn(),
create: vi.fn(),
official: vi.fn(),
sources: vi.fn(),
fetch: vi.fn(),
ping: vi.fn(),
drain: vi.fn(),
after: vi.fn(),
existing: vi.fn(),
}));
vi.mock("next/server", () => ({ after: mocks.after, NextResponse: Response }));
vi.mock("@/lib/db", () => ({
ItemsBase: { itemName: "item_name" },
db: { select: () => ({ from: mocks.existing }) },
}));
vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
vi.mock("./furni-job-store", () => ({
validJobId: (id: unknown) => typeof id === "string" && id.length === 36,
ImportJobStore: class {
read = mocks.read;
create = mocks.create;
},
}));
vi.mock("./furni-job-worker", () => ({ drainFurnitureImports: mocks.drain }));
vi.mock("./clone-sources", () => ({ listSources: mocks.sources }));
vi.mock("./clone-import", () => ({ fetchSourceFurnidata: mocks.fetch }));
vi.mock("./habbo-furnidata-cache", () => ({
getOfficialHabboFurnidata: mocks.official,
}));
import { enqueueFurnitureSync } from "./furni-sync-queue";
const id = "11111111-1111-4111-8111-111111111111";
const request = (body: unknown = { id }) =>
new Request("https://example.test/api/sync", {
method: "POST",
body: JSON.stringify(body),
});
beforeEach(() => {
vi.clearAllMocks();
mocks.read.mockRejectedValue({ code: "ENOENT" });
mocks.create.mockImplementation(async (job) => job);
mocks.ping.mockResolvedValue("PONG");
mocks.existing.mockResolvedValue([]);
mocks.official.mockResolvedValue(new Map());
mocks.sources.mockResolvedValue([]);
});
describe("durable synchronization enqueue", () => {
it("persists an empty completed job when already up to date", async () => {
const res = await enqueueFurnitureSync(request(), 7, "official");
expect(res.status).toBe(200);
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({ state: "completed", items: [], userId: 7 }),
);
});
it("reuses an existing request before source discovery", async () => {
const job = { id, userId: 7, syncKind: "official" };
mocks.read.mockResolvedValue(job);
expect((await enqueueFurnitureSync(request(), 7, "official")).status).toBe(
200,
);
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.official).not.toHaveBeenCalled();
});
it("rejects other owners and changed operation kinds", async () => {
mocks.read.mockResolvedValue({ id, userId: 8, syncKind: "official" });
expect((await enqueueFurnitureSync(request(), 7, "official")).status).toBe(
409,
);
mocks.read.mockResolvedValue({ id, userId: 7, syncKind: "clone" });
expect((await enqueueFurnitureSync(request(), 7, "official")).status).toBe(
409,
);
});
it("does not enqueue while redis is unavailable", async () => {
mocks.ping.mockRejectedValue(Error("offline"));
expect((await enqueueFurnitureSync(request(), 7, "official")).status).toBe(
503,
);
expect(mocks.create).not.toHaveBeenCalled();
});
it("deduplicates classnames across source snapshots", async () => {
mocks.sources.mockResolvedValue([
{ id: "a", furnidataUrl: "a" },
{ id: "b", furnidataUrl: "b" },
]);
mocks.fetch.mockResolvedValue([
{
id: 1,
classname: "chair",
name: "Chair",
description: "",
itemType: "s",
},
]);
await enqueueFurnitureSync(request(), 7, "clone");
const job = mocks.create.mock.calls[0][0];
expect(job.items).toHaveLength(1);
expect(job.items[0].cloneSourceId).toBe("a");
expect(job.items[0].cloneEntry.classname).toBe("chair");
});
it("never queues partial discovery after a source failure", async () => {
mocks.sources.mockResolvedValue([{ id: "a", furnidataUrl: "a" }]);
mocks.fetch.mockRejectedValue(Error("unavailable"));
await expect(enqueueFurnitureSync(request(), 7, "clone")).rejects.toThrow(
"unavailable",
);
expect(mocks.create).not.toHaveBeenCalled();
});
});