import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import request from "supertest"; // No pacing or DNS here. config.ts reads the pace on load, so it's hoisted. vi.hoisted(() => { process.env.IMPORT_MAKERWORLD_CALL_DELAY_MS = "0"; }); vi.mock("node:dns/promises", () => ({ default: { resolve4: async () => ["93.184.216.34"], resolve6: async () => { throw new Error("no AAAA"); }, }, })); import { createApp } from "../src/app"; import { prisma } from "../src/db"; const app = createApp(); const stamp = Date.now(); let token: string; const auth = () => ({ Authorization: `Bearer ${token}` }); const originalFetch = global.fetch; function json(body: unknown): Response { return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }); } const DESIGNER = { uid: 1692606088, name: "Deus Cat" }; function design(designId: string) { return { id: Number(designId), modelId: `US${designId}`, title: `Phoenix ${designId}`, summary: "

A phoenix.

", tags: ["phoenix"], coverUrl: "https://makerworld.bblmw.com/phoenix/cover.jpg", designCreator: DESIGNER, defaultInstanceId: 2, instances: [ { id: 1, profileId: 101, title: "Single-colour version", instanceCreator: DESIGNER }, { id: 2, profileId: 102, title: "multi-colored version", instanceCreator: DESIGNER }, ], }; } /** "ok" imports; "fail-once" drops the first design fetch (a network blip); "gone" 404s forever. */ type Behavior = "ok" | "fail-once" | "gone"; const behavior = new Map(); const designCalls = new Map(); function mockFetch() { global.fetch = vi.fn<(input: RequestInfo | URL) => Promise>(async (input) => { const url = String(input); const model = url.match(/^https:\/\/api\.bambulab\.com\/v1\/design-service\/design\/(\d+)/); if (model) { const id = model[1]; const calls = (designCalls.get(id) ?? 0) + 1; designCalls.set(id, calls); const b = behavior.get(id) ?? "ok"; if (b === "gone") return new Response("gone", { status: 404 }); if (b === "fail-once" && calls === 1) throw new TypeError("network down"); return json(design(id)); } const profile = url.match(/^https:\/\/api\.bambulab\.com\/v1\/iot-service\/api\/user\/profile\/(\d+)/); if (profile) return json({ message: "success", url: `https://s3.example.com/phoenix-${profile[1]}.stl`, filename: `phoenix-${profile[1]}.stl`, }); const file = url.match(/^https:\/\/s3\.example\.com\/phoenix-(\d+)\.stl$/); if (file) return new Response(`solid phoenix-${file[1]}\nendsolid\n`, { headers: { "content-type": "application/octet-stream" }, }); if (url === "https://makerworld.bblmw.com/phoenix/cover.jpg") { return new Response( Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64", ), { headers: { "content-type": "image/png" } }, ); } return new Response("not found", { status: 404 }); // e.g. the author profile, behind Cloudflare }) as unknown as typeof fetch; } async function waitJob(jobId: string) { let job = (await request(app).get(`/api/import/jobs/${jobId}`).set(auth())).body; for (let i = 0; i < 150 && job.status === "RUNNING"; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); job = (await request(app).get(`/api/import/jobs/${jobId}`).set(auth())).body; } return job; } function startLinks(urls: string[], extra: Record = {}) { return request(app) .post("/api/import/links") .set(auth()) .send({ urls, makerworld_cookie: "token=test-bearer", ...extra }); } describe("the links import queue", () => { beforeAll(async () => { const res = await request(app) .post("/api/register") .send({ displayName: "Links Import Test", email: `links-${stamp}@example.com`, password: "password123", }); token = res.body.token; // The retry route takes the cookie from the user's saved settings, never from a stored payload. await request(app).patch("/api/settings/makerworld").set(auth()).send({ cookie: "token=test-bearer" }); mockFetch(); }); afterEach(() => { global.fetch = originalFetch; mockFetch(); behavior.clear(); designCalls.clear(); }); it("imports every pasted link as one job, with an item per link", async () => { const ids = [String(stamp % 1_000_000_000), String((stamp % 1_000_000_000) + 1)]; const start = await startLinks(ids.map((id) => `https://makerworld.com/en/models/${id}-phoenix`)); expect(start.status).toBe(202); const job = await waitJob(start.body.job_id); expect(job.status).toBe("DONE"); expect(job.total).toBe(2); expect(job.imported).toBe(2); expect(job.failed_count).toBe(0); const items = (await request(app).get(`/api/import/jobs/${job.id}`).set(auth())).body; expect(items.result_print_id).toBeNull(); for (const id of ids) { const print = await prisma.print.findFirst({ where: { userId: (await prisma.user.findFirst({ where: { email: `links-${stamp}@example.com` } }))!.id, sourceExternalId: id, }, }); expect(print).not.toBeNull(); } const dbItems = await prisma.importJobItem.findMany({ where: { jobId: job.id } }); expect(dbItems.map((i) => i.status)).toEqual(["DONE", "DONE"]); }); it("collapses repeated links into one queue item", async () => { const id = String((stamp % 1_000_000_000) + 2); const start = await startLinks([ `https://makerworld.com/en/models/${id}-phoenix`, `https://makerworld.com/en/models/${id}-phoenix`, ]); const job = await waitJob(start.body.job_id); expect(job.total).toBe(1); expect(job.imported).toBe(1); }); it("keeps importing when one link fails, then a retry reruns just the failed one", async () => { const goodId = String((stamp % 1_000_000_000) + 3); const badId = String((stamp % 1_000_000_000) + 4); behavior.set(badId, "gone"); const start = await startLinks([ `https://makerworld.com/en/models/${goodId}-phoenix`, `https://makerworld.com/en/models/${badId}-phoenix`, ]); const job = await waitJob(start.body.job_id); expect(job.status).toBe("DONE"); expect(job.imported).toBe(1); expect(job.failed_count).toBe(1); let items = await prisma.importJobItem.findMany({ where: { jobId: job.id }, orderBy: { createdAt: "asc" } }); expect(items.map((i) => i.status)).toEqual(["DONE", "FAILED"]); expect(items[1].errorMessage).toBeTruthy(); expect(items[1].attempts).toBe(1); // The model reappears: retrying the job brings in only the failed link. behavior.delete(badId); designCalls.clear(); const retry = await request(app).post(`/api/import/jobs/${job.id}/retry`).set(auth()).send({}); expect(retry.status).toBe(202); const rerun = await waitJob(job.id); expect(rerun.status).toBe("DONE"); expect(rerun.imported).toBe(2); expect(rerun.failed_count).toBe(0); items = await prisma.importJobItem.findMany({ where: { jobId: job.id }, orderBy: { createdAt: "asc" } }); expect(items.map((i) => i.status)).toEqual(["DONE", "DONE"]); expect(designCalls.get(goodId)).toBeUndefined(); // the good link wasn't downloaded again }); it("retries a transient network failure on its own", async () => { const id = String((stamp % 1_000_000_000) + 5); behavior.set(id, "fail-once"); const start = await startLinks([`https://makerworld.com/en/models/${id}-phoenix`]); const job = await waitJob(start.body.job_id); expect(job.status).toBe("DONE"); expect(job.imported).toBe(1); expect(job.failed_count).toBe(0); expect(designCalls.get(id)).toBe(2); const items = await prisma.importJobItem.findMany({ where: { jobId: job.id } }); expect(items[0].status).toBe("DONE"); expect(items[0].attempts).toBe(2); }); it("does not retry a provider's own 4xx answer", async () => { const id = String((stamp % 1_000_000_000) + 6); behavior.set(id, "gone"); const start = await startLinks([`https://makerworld.com/en/models/${id}-phoenix`]); const job = await waitJob(start.body.job_id); expect(job.failed_count).toBe(1); expect(designCalls.get(id)).toBe(1); const items = await prisma.importJobItem.findMany({ where: { jobId: job.id } }); expect(items[0].status).toBe("FAILED"); }); it("imports every wanted profile of a MakerWorld link onto one model", async () => { const id = String((stamp % 1_000_000_000) + 7); const start = await startLinks([`https://makerworld.com/en/models/${id}-phoenix`], { scope: "all" }); const job = await waitJob(start.body.job_id); expect(job.status).toBe("DONE"); // One link is one model, however many profiles it brought in. expect(job.imported).toBe(1); const items = await prisma.importJobItem.findMany({ where: { jobId: job.id } }); expect(items[0].status).toBe("DONE"); const user = await prisma.user.findFirst({ where: { email: `links-${stamp}@example.com` } }); const notification = await prisma.notification.findFirst({ where: { userId: user!.id }, orderBy: { createdAt: "desc" }, }); expect(notification!.title).toBe("Imported 1 of 1 models"); expect(notification!.body).toBe("2 of 2 print profiles imported."); const print = await prisma.print.findFirst({ where: { userId: user!.id, sourceExternalId: id }, include: { plates: true }, }); expect(print!.plates).toHaveLength(2); }); it("lists each link with its status and failure reason", async () => { const goodId = String((stamp % 1_000_000_000) + 9); const badId = String((stamp % 1_000_000_000) + 10); behavior.set(badId, "gone"); const start = await startLinks([ `https://makerworld.com/en/models/${goodId}-phoenix`, `https://makerworld.com/en/models/${badId}-phoenix`, ]); const job = await waitJob(start.body.job_id); const res = await request(app).get(`/api/import/jobs/${job.id}/items`).set(auth()); expect(res.status).toBe(200); expect(res.body).toHaveLength(2); expect(res.body[0]).toMatchObject({ status: "DONE", attempts: 1, error_message: null }); expect(res.body[1].status).toBe("FAILED"); expect(res.body[1].error_message).toBeTruthy(); expect(res.body[1].url).toContain(badId); }); it("refuses a retry with nothing failed", async () => { const id = String((stamp % 1_000_000_000) + 8); const start = await startLinks([`https://makerworld.com/en/models/${id}-phoenix`]); const job = await waitJob(start.body.job_id); expect(job.failed_count).toBe(0); const retry = await request(app).post(`/api/import/jobs/${job.id}/retry`).set(auth()).send({}); expect(retry.status).toBe(400); }); });