mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
- A links import with a profile scope counted print profiles against the
number of links ("Imported 11 of 3 links"). Each link now counts as one
model, and the profile totals go in the notification body.
- Opening a model while its import was still downloading images rendered a
3D thumbnail that then stuck as the card image and first gallery image.
Renders are now flagged, imported images replace them, and a late render
no longer overwrites an imported cover. "Update from source" treats a
model with only renders as having no images.
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
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: "<p>A phoenix.</p>",
|
|
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<string, Behavior>();
|
|
const designCalls = new Map<string, number>();
|
|
|
|
function mockFetch() {
|
|
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(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<string, unknown> = {}) {
|
|
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);
|
|
});
|
|
});
|