import { afterAll, 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"); }, }, })); // Holds the runner's final notification, so a test can act while the job already reads DONE but // its runner hasn't returned yet. const notifyDelay = vi.hoisted(() => ({ ms: 0 })); vi.mock("../src/services/notificationService", async (importOriginal) => { const original = await importOriginal(); return { ...original, createNotification: async (...args: Parameters) => { if (notifyDelay.ms) await new Promise((resolve) => setTimeout(resolve, notifyDelay.ms)); return original.createNotification(...args); }, }; }); import { createApp } from "../src/app"; import { prisma } from "../src/db"; import { makerworldCaptchaCooloffActive, resetCaptchaCooloffForTest } from "../src/services/makerworldCaptcha"; const app = createApp(); const stamp = Date.now(); const base = (stamp % 1_000_000_000) + 1000; const originalFetch = global.fetch; type Account = { id: string; token: string }; let admin: Account; let member: Account; let other: Account; const auth = (account: Account) => ({ Authorization: `Bearer ${account.token}` }); async function register(label: string, role: "ADMIN" | "MEMBER"): Promise { const email = `queue-${label}-${stamp}@example.com`; const res = await request(app) .post("/api/register") .send({ displayName: `Queue ${label}`, email, password: "password123" }); if (res.status !== 200) throw new Error(`register ${label}: ${res.status} ${JSON.stringify(res.body)}`); await prisma.user.update({ where: { id: res.body.user.id }, data: { role } }); // Tokens embed the role at issue time. const login = await request(app).post("/api/login").send({ email, password: "password123" }); return { id: res.body.user.id, token: login.body.token }; } 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"], 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 }, ], }; } // Per design id: how often it was fetched, an optional gate that holds the fetch until released, // and a 404 switch. inFlight/maxInFlight catch two jobs importing at once. const designCalls = new Map(); const gates = new Map; release: () => void }>(); const gone = new Set(); // Design ids MakerWorld answers with its CAPTCHA challenge. const challenged = new Set(); let inFlight = 0; let maxInFlight = 0; 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]; designCalls.set(id, (designCalls.get(id) ?? 0) + 1); inFlight++; maxInFlight = Math.max(maxInFlight, inFlight); try { await gates.get(id)?.held; await new Promise((resolve) => setTimeout(resolve, 20)); } finally { inFlight--; } if (gone.has(id)) return new Response("gone", { status: 404 }); if (challenged.has(id)) { return new Response(JSON.stringify({ captchaId: "c1", error: "confirm you are not a robot" }), { status: 418, headers: { "content-type": "application/json" }, }); } 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" }, }); return new Response("not found", { status: 404 }); }) as unknown as typeof fetch; } function gate(id: string): () => void { let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); gates.set(id, { held, release }); return () => { gates.delete(id); release(); }; } const modelUrl = (id: number | string) => `https://makerworld.com/en/models/${id}-phoenix`; function queue(account: Account, body: Record) { return request(app).post("/api/import/queue").set(auth(account)).send(body); } async function job(jobId: string) { return prisma.importJob.findUniqueOrThrow({ where: { id: jobId } }); } async function items(jobId: string) { return prisma.importJobItem.findMany({ where: { jobId }, orderBy: { createdAt: "asc" } }); } async function waitFor(read: () => Promise, done: (value: T) => boolean, timeoutMs = 15000): Promise { const deadline = Date.now() + timeoutMs; for (;;) { const value = await read(); if (done(value) || Date.now() > deadline) return value; await new Promise((resolve) => setTimeout(resolve, 50)); } } const waitStatus = (jobId: string, status: string) => waitFor( () => job(jobId), (j) => j.status === status, ); /** Each test starts from an empty queue: the admin page acts on every user's jobs, so another * test's leftovers would be started by "Start all" too. */ async function clearQueue() { await request(app).post("/api/admin/import-queue/pause-all").set(auth(admin)); await waitFor( async () => (await request(app).get("/api/admin/import-queue").set(auth(admin))).body, (body) => !body.bulk.running && !body.jobs.some((j: { runner_live: boolean }) => j.runner_live), ); await prisma.importJob.deleteMany({ where: { type: "LINKS" } }); // Going around the API leaves the queues' notifications behind. await prisma.notification.deleteMany({ where: { userId: { in: [admin.id, member.id, other.id] } } }); } describe("the import queue", () => { beforeAll(async () => { admin = await register("admin", "ADMIN"); member = await register("member", "MEMBER"); other = await register("other", "MEMBER"); // Start/retry take the owner's saved cookie, never one from the request. for (const account of [admin, member, other]) { await request(app).patch("/api/settings/makerworld").set(auth(account)).send({ cookie: "token=test-bearer" }); } mockFetch(); }); afterEach(async () => { // A failed assertion can leave a fetch held, which would keep its runner alive forever. for (const { release } of gates.values()) release(); gates.clear(); await clearQueue(); global.fetch = originalFetch; mockFetch(); designCalls.clear(); gone.clear(); challenged.clear(); resetCaptchaCooloffForTest(); inFlight = 0; maxInFlight = 0; notifyDelay.ms = 0; }); afterAll(async () => { await prisma.user.deleteMany({ where: { id: { in: [admin.id, member.id, other.id] } } }); }); describe("sending links from the extension", () => { it("parks links in one paused job without fetching anything", async () => { const first = await queue(member, { url: modelUrl(base), title: "Phoenix A" }); expect(first.status).toBe(201); expect(first.body).toMatchObject({ duplicate: false, waiting: 1 }); const second = await queue(member, { url: modelUrl(base + 1) }); expect(second.body.job_id).toBe(first.body.job_id); expect(second.body.waiting).toBe(2); const parked = await job(first.body.job_id); expect(parked.status).toBe("PAUSED"); expect(parked.total).toBe(2); expect((await items(parked.id)).map((i) => i.status)).toEqual(["PENDING", "PENDING"]); expect(designCalls.size).toBe(0); // Wait a beat: nothing starts on its own. await new Promise((resolve) => setTimeout(resolve, 300)); expect((await job(parked.id)).status).toBe("PAUSED"); }); it("notifies once when a queue starts, and leaves the notification alone after that", async () => { await prisma.importJob.deleteMany({ where: { userId: member.id, status: "PAUSED" } }); await prisma.notification.deleteMany({ where: { userId: member.id } }); const first = await queue(member, { url: modelUrl(base + 2) }); await queue(member, { url: modelUrl(base + 3) }); const notifications = await prisma.notification.findMany({ where: { userId: member.id, type: "queue" } }); expect(notifications).toHaveLength(1); expect(notifications[0].title).toBe("Links are waiting in the import queue"); // A member can't open the admin page, so the notification doesn't link there. expect(notifications[0].internalPath).toBeNull(); // The notification isn't tied to the queue: deleting it leaves the queue as it was. await prisma.notification.deleteMany({ where: { userId: member.id } }); expect((await job(first.body.job_id)).status).toBe("PAUSED"); }); it("links an admin's notification to the queue page", async () => { await prisma.notification.deleteMany({ where: { userId: admin.id } }); await queue(admin, { url: modelUrl(base + 4) }); const notification = await prisma.notification.findFirst({ where: { userId: admin.id, type: "queue" } }); expect(notification!.internalPath).toBe("/admin-queue"); }); it("doesn't add a link that's already waiting", async () => { await queue(member, { url: modelUrl(base + 5) }); const again = await queue(member, { url: modelUrl(base + 5) }); expect(again.status).toBe(200); expect(again.body.duplicate).toBe(true); expect(await items(again.body.job_id)).toHaveLength(1); }); it("collects quick back-to-back sends into one job", async () => { const sends = await Promise.all( Array.from({ length: 6 }, (_, i) => queue(member, { url: modelUrl(base + 10 + i) })), ); expect(sends.every((r) => r.status === 201)).toBe(true); expect(new Set(sends.map((r) => r.body.job_id)).size).toBe(1); const paused = await prisma.importJob.findMany({ where: { userId: member.id, status: "PAUSED" } }); expect(paused).toHaveLength(1); expect(await items(paused[0].id)).toHaveLength(6); }); it("keeps each user's queue separate", async () => { const a = await queue(member, { url: modelUrl(base + 20) }); const b = await queue(other, { url: modelUrl(base + 20) }); expect(a.body.job_id).not.toBe(b.body.job_id); }); it("rejects bad input", async () => { expect((await queue(member, { url: "" })).status).toBe(400); expect((await queue(member, {})).status).toBe(400); expect((await queue(member, { url: modelUrl(base), scope: "everything" })).status).toBe(400); expect((await queue(member, { url: "not a url" })).status).toBe(400); expect( ( await request(app) .post("/api/import/queue") .send({ url: modelUrl(base) }) ).status, ).toBe(401); }); it("refuses someone else's collection", async () => { const theirs = await request(app) .post("/api/collections") .set(auth(other)) .send({ name: `Theirs ${stamp}` }); const res = await queue(member, { url: modelUrl(base + 21), collection_id: theirs.body.id }); expect(res.status).toBe(404); expect(await prisma.importJob.count({ where: { userId: member.id } })).toBe(0); }); }); describe("the admin queue page", () => { it("is admin-only", async () => { expect((await request(app).get("/api/admin/import-queue").set(auth(member))).status).toBe(403); expect((await request(app).post("/api/admin/import-queue/start-all").set(auth(member))).status).toBe(403); }); it("lists every user's jobs with their owner and link counts", async () => { await queue(member, { url: modelUrl(base + 30), title: "Phoenix Thirty" }); await queue(other, { url: modelUrl(base + 31) }); const res = await request(app).get("/api/admin/import-queue").set(auth(admin)); expect(res.status).toBe(200); const mine = res.body.jobs.find((j: { owner: { id: string } }) => j.owner.id === member.id); expect(mine).toMatchObject({ status: "PAUSED", pending_count: 1, runner_live: false, total: 1 }); expect(res.body.jobs.some((j: { owner: { id: string } }) => j.owner.id === other.id)).toBe(true); const listed = await request(app).get(`/api/admin/import-queue/jobs/${mine.id}/items`).set(auth(admin)); expect(listed.body[0]).toMatchObject({ title: "Phoenix Thirty", scope: "url", status: "PENDING" }); }); it("starts a paused job as its owner, with each link's collection and profiles", async () => { const collection = await request(app) .post("/api/collections") .set(auth(member)) .send({ name: `Queued ${stamp}` }); const res = await queue(member, { url: modelUrl(base + 40), collection_id: collection.body.id, scope: "all", }); await queue(member, { url: modelUrl(base + 41) }); const start = await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/start`).set(auth(admin)); expect(start.status).toBe(202); const finished = await waitStatus(res.body.job_id, "DONE"); expect(finished.status).toBe("DONE"); expect(finished.failedCount).toBe(0); // The member owns the models, not the admin who pressed start. const withProfiles = await prisma.print.findFirst({ where: { userId: member.id, sourceExternalId: String(base + 40) }, include: { plates: true, collectionItems: true }, }); expect(withProfiles!.plates).toHaveLength(2); expect(withProfiles!.collectionItems.map((c) => c.collectionId)).toEqual([collection.body.id]); const plain = await prisma.print.findFirst({ where: { userId: member.id, sourceExternalId: String(base + 41) }, include: { plates: true, collectionItems: true }, }); expect(plain!.plates).toHaveLength(1); expect(plain!.collectionItems).toHaveLength(0); expect(await prisma.print.count({ where: { userId: admin.id } })).toBe(0); }); it("still imports a link whose collection was deleted while it waited", async () => { const collection = await request(app) .post("/api/collections") .set(auth(member)) .send({ name: `Doomed ${stamp}` }); const res = await queue(member, { url: modelUrl(base + 42), collection_id: collection.body.id }); await prisma.collection.delete({ where: { id: collection.body.id } }); await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/start`).set(auth(admin)); const finished = await waitStatus(res.body.job_id, "DONE"); expect(finished.failedCount).toBe(0); expect(finished.imported).toBe(1); }); it("sends new links to a fresh queue once the old one started", async () => { const first = await queue(member, { url: modelUrl(base + 43) }); await request(app).post(`/api/admin/import-queue/jobs/${first.body.job_id}/start`).set(auth(admin)); const second = await queue(member, { url: modelUrl(base + 44) }); expect(second.body.job_id).not.toBe(first.body.job_id); expect((await job(second.body.job_id)).status).toBe("PAUSED"); await waitStatus(first.body.job_id, "DONE"); }); it("refuses to start a job that isn't paused, or while the owner has another import running", async () => { const release = gate(String(base + 50)); const a = await queue(member, { url: modelUrl(base + 50) }); await request(app).post(`/api/admin/import-queue/jobs/${a.body.job_id}/start`).set(auth(admin)); await waitFor( async () => designCalls.get(String(base + 50)) ?? 0, (n) => n > 0, ); const again = await request(app).post(`/api/admin/import-queue/jobs/${a.body.job_id}/start`).set(auth(admin)); expect(again.status).toBe(409); // A second queue of the same owner can't run next to the first. const b = await queue(member, { url: modelUrl(base + 51) }); const blocked = await request(app).post(`/api/admin/import-queue/jobs/${b.body.job_id}/start`).set(auth(admin)); expect(blocked.status).toBe(409); expect((await job(b.body.job_id)).status).toBe("PAUSED"); release(); await waitStatus(a.body.job_id, "DONE"); const done = await request(app).post(`/api/admin/import-queue/jobs/${a.body.job_id}/start`).set(auth(admin)); expect(done.status).toBe(400); }); it("pauses after the current link and resumes where it stopped", async () => { const ids = [base + 60, base + 61, base + 62]; const release = gate(String(ids[0])); let res!: request.Response; for (const id of ids) res = await queue(member, { url: modelUrl(id) }); const jobId = res.body.job_id; await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); await waitFor( async () => designCalls.get(String(ids[0])) ?? 0, (n) => n > 0, ); const pause = await request(app).post(`/api/admin/import-queue/jobs/${jobId}/pause`).set(auth(admin)); expect(pause.status).toBe(200); // Its runner is still on the first link, so starting now would run the job twice. const early = await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); expect(early.status).toBe(409); release(); await waitFor( async () => (await request(app).get("/api/admin/import-queue").set(auth(admin))).body, (body) => !body.jobs.find((j: { id: string }) => j.id === jobId).runner_live, ); expect((await job(jobId)).status).toBe("PAUSED"); expect((await items(jobId)).map((i) => i.status)).toEqual(["DONE", "PENDING", "PENDING"]); expect(designCalls.get(String(ids[1]))).toBeUndefined(); const resume = await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); expect(resume.status).toBe(202); const finished = await waitStatus(jobId, "DONE"); expect(finished.imported).toBe(3); expect(finished.processed).toBe(3); // Nothing was downloaded twice. for (const id of ids) expect(designCalls.get(String(id))).toBe(1); }); it("recovers a job left RUNNING by a restart: pause, then start", async () => { const res = await queue(member, { url: modelUrl(base + 70) }); const jobId = res.body.job_id; // What a crash mid-link leaves behind: no runner, the job and its link still RUNNING. await prisma.importJob.update({ where: { id: jobId }, data: { status: "RUNNING" } }); await prisma.importJobItem.updateMany({ where: { jobId }, data: { status: "RUNNING" } }); const listed = await request(app).get("/api/admin/import-queue").set(auth(admin)); expect(listed.body.jobs.find((j: { id: string }) => j.id === jobId).runner_live).toBe(false); expect((await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin))).status).toBe( 400, ); expect((await request(app).post(`/api/admin/import-queue/jobs/${jobId}/pause`).set(auth(admin))).status).toBe( 200, ); expect((await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin))).status).toBe( 202, ); const finished = await waitStatus(jobId, "DONE"); expect(finished.imported).toBe(1); }); it("starts a job a restart cut off, which has links waiting but none failed", async () => { const res = await queue(member, { url: modelUrl(base + 86) }); await queue(member, { url: modelUrl(base + 87) }); const jobId = res.body.job_id; // What the boot recovery in server.ts leaves: the link that was importing still RUNNING. const [first] = await items(jobId); await prisma.importJobItem.update({ where: { id: first.id }, data: { status: "RUNNING" } }); await prisma.importJob.update({ where: { id: jobId }, data: { status: "ERROR", errorMessage: "Interrupted by server restart" }, }); const start = await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); expect(start.status).toBe(202); const finished = await waitStatus(jobId, "DONE"); expect(finished.imported).toBe(2); expect(finished.errorMessage).toBeNull(); expect((await items(jobId)).map((item) => item.status)).toEqual(["DONE", "DONE"]); }); it("lets a retry finish a job a restart cut off, too, though none of its links failed", async () => { const res = await queue(member, { url: modelUrl(base + 88) }); await queue(member, { url: modelUrl(base + 89) }); const jobId = res.body.job_id; const [first] = await items(jobId); await prisma.importJobItem.update({ where: { id: first.id }, data: { status: "RUNNING" } }); await prisma.importJob.update({ where: { id: jobId }, data: { status: "ERROR" } }); const retry = await request(app).post(`/api/admin/import-queue/jobs/${jobId}/retry`).set(auth(admin)); expect(retry.status).toBe(202); const finished = await waitFor( () => job(jobId), (j) => j.status === "DONE" && j.imported === 2, ); expect(finished.failedCount).toBe(0); }); it("still won't start or retry a finished job with nothing left", async () => { const res = await queue(member, { url: modelUrl(base + 79) }); const jobId = res.body.job_id; await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); await waitStatus(jobId, "DONE"); await waitFor( async () => (await request(app).get("/api/admin/import-queue").set(auth(admin))).body, (body) => !body.jobs.some((j: { runner_live: boolean }) => j.runner_live), ); expect((await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin))).status).toBe( 400, ); expect((await request(app).post(`/api/admin/import-queue/jobs/${jobId}/retry`).set(auth(admin))).status).toBe( 400, ); }); it("retries just the failed links of a job", async () => { gone.add(String(base + 81)); await queue(member, { url: modelUrl(base + 80) }); const res = await queue(member, { url: modelUrl(base + 81) }); await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/start`).set(auth(admin)); const first = await waitStatus(res.body.job_id, "DONE"); expect(first.failedCount).toBe(1); gone.clear(); const retry = await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/retry`).set(auth(admin)); expect(retry.status).toBe(202); const second = await waitFor( () => job(res.body.job_id), (j) => j.status === "DONE" && j.failedCount === 0, ); expect(second.imported).toBe(2); expect(designCalls.get(String(base + 80))).toBe(1); }); it("retries a CAPTCHA'd link for real once someone solved the puzzle, instead of failing it on the cooloff", async () => { challenged.add(String(base + 83)); const res = await queue(member, { url: modelUrl(base + 83) }); await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/start`).set(auth(admin)); const first = await waitStatus(res.body.job_id, "DONE"); expect(first.failedCount).toBe(1); expect(makerworldCaptchaCooloffActive()).toBe(true); // Solved in the browser: the cooloff is still on, but the retry has to ask MakerWorld again. challenged.clear(); const callsBefore = designCalls.get(String(base + 83)) ?? 0; await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/retry`).set(auth(admin)); const second = await waitFor( () => job(res.body.job_id), (j) => j.status === "DONE" && j.failedCount === 0, ); expect(second.imported).toBe(1); expect(designCalls.get(String(base + 83))).toBeGreaterThan(callsBefore); }); it("imports a model whose title talks about robots instead of calling it a CAPTCHA", async () => { const res = await queue(member, { url: modelUrl(base + 84) }); const realFetch = global.fetch; global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const response = await realFetch(input, init); if (!String(input).includes(`/design-service/design/${base + 84}`)) return response; return json({ ...(await response.json()), title: "Robot vacuum dock", summary: "

Not a robot.

" }); }) as typeof fetch; await request(app).post(`/api/admin/import-queue/jobs/${res.body.job_id}/start`).set(auth(admin)); const finished = await waitStatus(res.body.job_id, "DONE"); expect(finished.failedCount).toBe(0); expect(finished.imported).toBe(1); expect(makerworldCaptchaCooloffActive()).toBe(false); }); it("retries a finished job right away, while its runner is still writing the notification", async () => { gone.add(String(base + 85)); const res = await queue(member, { url: modelUrl(base + 85) }); const jobId = res.body.job_id; notifyDelay.ms = 500; await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); // Poll the DB directly, so the retry lands as soon as the job reads DONE. await waitFor( () => job(jobId), (j) => j.status === "DONE", ); expect( (await request(app).get("/api/admin/import-queue").set(auth(admin))).body.jobs.find( (j: { id: string }) => j.id === jobId, ).runner_live, ).toBe(true); notifyDelay.ms = 0; gone.clear(); const retry = await request(app).post(`/api/admin/import-queue/jobs/${jobId}/retry`).set(auth(admin)); expect(retry.status).toBe(202); const finished = await waitFor( () => job(jobId), (j) => j.status === "DONE" && j.failedCount === 0, ); expect(finished.imported).toBe(1); }); it("start all runs every paused queue, one job at a time across users", async () => { const a = await queue(member, { url: modelUrl(base + 90) }); await queue(member, { url: modelUrl(base + 91) }); const b = await queue(other, { url: modelUrl(base + 92) }); const c = await queue(admin, { url: modelUrl(base + 93) }); const res = await request(app).post("/api/admin/import-queue/start-all").set(auth(admin)); expect(res.status).toBe(202); expect(res.body.queued).toBe(3); // Pressing it again while it runs doesn't queue the same jobs twice. expect((await request(app).post("/api/admin/import-queue/start-all").set(auth(admin))).body.queued).toBe(0); for (const id of [a.body.job_id, b.body.job_id, c.body.job_id]) await waitStatus(id, "DONE"); expect(maxInFlight).toBe(1); expect(await prisma.print.count({ where: { userId: other.id, sourceExternalId: String(base + 92) } })).toBe(1); const listed = await waitFor( async () => (await request(app).get("/api/admin/import-queue").set(auth(admin))).body, (body) => !body.bulk.running, ); expect(listed.bulk).toMatchObject({ running: false, remaining: 0 }); }); it("retry all failed reruns the failed links of every finished job", async () => { gone.add(String(base + 100)); gone.add(String(base + 101)); const a = await queue(member, { url: modelUrl(base + 100) }); const b = await queue(other, { url: modelUrl(base + 101) }); await request(app).post("/api/admin/import-queue/start-all").set(auth(admin)); for (const id of [a.body.job_id, b.body.job_id]) await waitStatus(id, "DONE"); await waitFor( async () => (await request(app).get("/api/admin/import-queue").set(auth(admin))).body, (body) => !body.bulk.running, ); gone.clear(); const res = await request(app).post("/api/admin/import-queue/retry-failed").set(auth(admin)); expect(res.body.queued).toBe(2); for (const id of [a.body.job_id, b.body.job_id]) { const finished = await waitFor( () => job(id), (j) => j.status === "DONE" && j.failedCount === 0, ); expect(finished.imported).toBe(1); } }); it("pause all stops a bulk run before its next job", async () => { const release = gate(String(base + 110)); const a = await queue(member, { url: modelUrl(base + 110) }); const b = await queue(other, { url: modelUrl(base + 111) }); await request(app).post("/api/admin/import-queue/start-all").set(auth(admin)); await waitFor( async () => designCalls.get(String(base + 110)) ?? 0, (n) => n > 0, ); const res = await request(app).post("/api/admin/import-queue/pause-all").set(auth(admin)); expect(res.body.paused).toBe(1); release(); await waitFor( async () => (await request(app).get("/api/admin/import-queue").set(auth(admin))).body, (body) => !body.bulk.running && !body.jobs.some((j: { runner_live: boolean }) => j.runner_live), ); // The link that was importing finishes; the job stays paused and the next one never starts. expect((await job(a.body.job_id)).status).toBe("DONE"); expect((await job(b.body.job_id)).status).toBe("PAUSED"); expect(designCalls.get(String(base + 111))).toBeUndefined(); }); it("removes a waiting link, and the job with its last one", async () => { await queue(member, { url: modelUrl(base + 120) }); const res = await queue(member, { url: modelUrl(base + 121) }); const jobId = res.body.job_id; const [first, second] = await items(jobId); const removed = await request(app).delete(`/api/admin/import-queue/items/${first.id}`).set(auth(admin)); expect(removed.body).toEqual({ jobDeleted: false }); expect((await job(jobId)).total).toBe(1); const notificationsBefore = await prisma.notification.count({ where: { userId: member.id } }); const last = await request(app).delete(`/api/admin/import-queue/items/${second.id}`).set(auth(admin)); expect(last.body).toEqual({ jobDeleted: true }); expect(await prisma.importJob.findUnique({ where: { id: jobId } })).toBeNull(); // Deleting the queue leaves notifications alone. expect(await prisma.notification.count({ where: { userId: member.id } })).toBe(notificationsBefore); expect((await request(app).delete(`/api/admin/import-queue/items/${second.id}`).set(auth(admin))).status).toBe( 404, ); }); it("won't remove links or delete a job while it runs", async () => { const release = gate(String(base + 130)); await queue(member, { url: modelUrl(base + 130) }); const res = await queue(member, { url: modelUrl(base + 131) }); const jobId = res.body.job_id; await request(app).post(`/api/admin/import-queue/jobs/${jobId}/start`).set(auth(admin)); await waitFor( async () => designCalls.get(String(base + 130)) ?? 0, (n) => n > 0, ); const [, waiting] = await items(jobId); expect((await request(app).delete(`/api/admin/import-queue/items/${waiting.id}`).set(auth(admin))).status).toBe( 409, ); expect((await request(app).delete(`/api/admin/import-queue/jobs/${jobId}`).set(auth(admin))).status).toBe(409); release(); await waitStatus(jobId, "DONE"); expect((await request(app).delete(`/api/admin/import-queue/jobs/${jobId}`).set(auth(admin))).status).toBe(200); expect(await prisma.importJobItem.count({ where: { jobId } })).toBe(0); }); it("clears finished jobs but keeps ones with failed links to retry", async () => { gone.add(String(base + 141)); const clean = await queue(member, { url: modelUrl(base + 140) }); const failing = await queue(other, { url: modelUrl(base + 141) }); const waiting = await queue(admin, { url: modelUrl(base + 142) }); await request(app).post(`/api/admin/import-queue/jobs/${clean.body.job_id}/start`).set(auth(admin)); await request(app).post(`/api/admin/import-queue/jobs/${failing.body.job_id}/start`).set(auth(admin)); await waitStatus(clean.body.job_id, "DONE"); await waitStatus(failing.body.job_id, "DONE"); const res = await request(app).post("/api/admin/import-queue/clear-finished").set(auth(admin)); expect(res.body.deleted).toBeGreaterThanOrEqual(1); expect(await prisma.importJob.findUnique({ where: { id: clean.body.job_id } })).toBeNull(); expect(await prisma.importJob.findUnique({ where: { id: failing.body.job_id } })).not.toBeNull(); expect(await prisma.importJob.findUnique({ where: { id: waiting.body.job_id } })).not.toBeNull(); }); it("only starts, pauses or retries link-list jobs", async () => { const zip = await prisma.importJob.create({ data: { userId: member.id, type: "ZIP", status: "DONE", sourceUrl: "https://example.com/a.zip" }, }); try { for (const action of ["start", "retry", "pause"]) { const res = await request(app).post(`/api/admin/import-queue/jobs/${zip.id}/${action}`).set(auth(admin)); expect(res.status).toBe(400); } expect((await request(app).post("/api/admin/import-queue/jobs/nope/start").set(auth(admin))).status).toBe(404); } finally { await prisma.importJob.delete({ where: { id: zip.id } }); } }); }); });