mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
481 lines
18 KiB
TypeScript
481 lines
18 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import request from "supertest";
|
|
import { createApp } from "../src/app";
|
|
import { createJob, getJob } from "../src/services/importJobService";
|
|
import { listNotifications } from "../src/services/notificationService";
|
|
import { HttpError } from "../src/utils/fileUtils";
|
|
|
|
// Mock only importPrintFromUrl so this runs offline.
|
|
vi.mock("../src/services/importService", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/services/importService")>();
|
|
return { ...actual, importPrintFromUrl: vi.fn<typeof actual.importPrintFromUrl>() };
|
|
});
|
|
|
|
// No real requests happen, so skip the pacing delay.
|
|
vi.mock("../src/config", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/config")>();
|
|
return { ...actual, IMPORT_COLLECTION_DELAY_MS: 0 };
|
|
});
|
|
|
|
vi.mock("../src/services/thingiverseApi", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../src/services/thingiverseApi")>();
|
|
return { ...actual, fetchThingiverseCollectionTitle: vi.fn<typeof actual.fetchThingiverseCollectionTitle>() };
|
|
});
|
|
|
|
import { importPrintFromUrl } from "../src/services/importService";
|
|
import {
|
|
runCollectionImportJob,
|
|
runThingiverseCollectionImportJob,
|
|
runThingiverseLikesImportJob,
|
|
} from "../src/services/importJobRunner";
|
|
import { fetchThingiverseCollectionTitle } from "../src/services/thingiverseApi";
|
|
import { setThingiverseAccessToken } from "../src/services/settingsService";
|
|
import { prisma } from "../src/db";
|
|
|
|
const app = createApp();
|
|
let userId: string;
|
|
|
|
beforeAll(async () => {
|
|
const email = `import-job-runner-test-${Date.now()}@example.com`;
|
|
const res = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "Job Runner Test", email, password: "password123" });
|
|
if (res.status !== 200) {
|
|
throw new Error(`Failed to register during test setup: ${res.status} ${JSON.stringify(res.body)}`);
|
|
}
|
|
userId = res.body.user.id;
|
|
});
|
|
|
|
describe("runCollectionImportJob", () => {
|
|
it("processes MakerWorld collection designs strictly one at a time, never in a concurrent burst", async () => {
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => {
|
|
active++;
|
|
maxActive = Math.max(maxActive, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
active--;
|
|
return {
|
|
print: { id: `print-${url}` } as never,
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
};
|
|
});
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://makerworld.com/en/models/does-not-matter",
|
|
provider: "makerworld",
|
|
total: 4,
|
|
});
|
|
|
|
await runCollectionImportJob(job.id, userId, {
|
|
url: "https://makerworld.com/en/models/does-not-matter",
|
|
design_ids: ["30", "31", "32", "33"],
|
|
tags: [],
|
|
});
|
|
|
|
expect(maxActive).toBe(1);
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.imported).toBe(4);
|
|
});
|
|
|
|
it("skips unavailable/failed models instead of failing the whole batch, and reports them separately", async () => {
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => {
|
|
if (url.endsWith("/1")) {
|
|
return {
|
|
print: { id: "print-1" } as never,
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
};
|
|
}
|
|
// Hidden/deleted models surface as 403/404.
|
|
if (url.endsWith("/2")) throw new HttpError(404, "Not found");
|
|
throw new HttpError(500, "Boom");
|
|
});
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://makerworld.com/en/models/does-not-matter",
|
|
provider: "makerworld",
|
|
total: 3,
|
|
});
|
|
|
|
await runCollectionImportJob(job.id, userId, {
|
|
url: "https://makerworld.com/en/models/does-not-matter",
|
|
design_ids: ["1", "2", "3"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.imported).toBe(1);
|
|
expect(finished?.failedCount).toBe(2);
|
|
|
|
const { items } = await listNotifications(userId);
|
|
const notification = items.find((n) => n.title.startsWith("Imported 1 of 3"));
|
|
expect(notification).toBeTruthy();
|
|
expect(notification!.body).toBe(
|
|
"From a MakerWorld collection — 1 unavailable (private, deleted, or hidden), 1 failed.",
|
|
);
|
|
});
|
|
|
|
it("logs the job's start, each failed model with why, and the totals", async () => {
|
|
vi.mocked(importPrintFromUrl).mockImplementation(async (_userId: string, url: string) => {
|
|
if (url.endsWith("/41")) throw new HttpError(404, "Not found");
|
|
if (url.endsWith("/42")) throw new HttpError(429, "MakerWorld is challenging this account with a CAPTCHA");
|
|
return {
|
|
print: { id: "print-40" } as never,
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
};
|
|
});
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://makerworld.com/en/models/does-not-matter",
|
|
provider: "makerworld",
|
|
total: 3,
|
|
});
|
|
const lines: string[] = [];
|
|
for (const stream of ["log", "warn", "error"] as const) {
|
|
vi.spyOn(console, stream).mockImplementation((line: unknown) => void lines.push(String(line)));
|
|
}
|
|
try {
|
|
await runCollectionImportJob(job.id, userId, {
|
|
url: "https://makerworld.com/en/models/does-not-matter",
|
|
design_ids: ["40", "41", "42"],
|
|
tags: [],
|
|
});
|
|
} finally {
|
|
vi.mocked(console.log).mockRestore();
|
|
vi.mocked(console.warn).mockRestore();
|
|
vi.mocked(console.error).mockRestore();
|
|
}
|
|
|
|
const ofJob = lines.filter((line) => line.includes(`job=${job.id}`));
|
|
expect(ofJob[0]).toMatch(/INFO \[import\] makerworld collection job started .*models=3/);
|
|
const failures = ofJob.filter((line) => line.includes("model import failed"));
|
|
expect(failures).toHaveLength(2);
|
|
expect(failures[0]).toMatch(
|
|
/WARN .*url=https:\/\/makerworld\.com\/en\/models\/41 reason=unavailable error="Not found"/,
|
|
);
|
|
expect(failures[1]).toContain("reason=rateLimited");
|
|
expect(ofJob.at(-1)).toMatch(/collection job done .*imported=1 already_in_library=0 failed=2/);
|
|
});
|
|
|
|
it("still marks the job DONE even if every model in the batch is unavailable", async () => {
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async () => {
|
|
throw new HttpError(403, "Forbidden");
|
|
});
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://makerworld.com/en/models/does-not-matter",
|
|
provider: "makerworld",
|
|
total: 2,
|
|
});
|
|
|
|
await runCollectionImportJob(job.id, userId, {
|
|
url: "https://makerworld.com/en/models/does-not-matter",
|
|
design_ids: ["10", "11"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.imported).toBe(0);
|
|
expect(finished?.failedCount).toBe(2);
|
|
|
|
const { items } = await listNotifications(userId);
|
|
const notification = items.find((n) => n.title.startsWith("Imported 0 of 2"));
|
|
expect(notification).toBeTruthy();
|
|
expect(notification!.body).toBe("From a MakerWorld collection — 2 unavailable (private, deleted, or hidden).");
|
|
});
|
|
|
|
it("labels a mid-batch CAPTCHA cooloff distinctly instead of an opaque wall of generic failures", async () => {
|
|
// A few succeed, then the CAPTCHA trips and every remaining item fails the same way.
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => {
|
|
if (url.endsWith("/1") || url.endsWith("/2")) {
|
|
return {
|
|
print: { id: `print-${url}` } as never,
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
};
|
|
}
|
|
throw new HttpError(429, "MakerWorld is challenging this account with a CAPTCHA...");
|
|
});
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://makerworld.com/en/models/does-not-matter",
|
|
provider: "makerworld",
|
|
total: 5,
|
|
});
|
|
|
|
await runCollectionImportJob(job.id, userId, {
|
|
url: "https://makerworld.com/en/models/does-not-matter",
|
|
design_ids: ["1", "2", "3", "4", "5"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.imported).toBe(2);
|
|
expect(finished?.failedCount).toBe(3);
|
|
|
|
const { items } = await listNotifications(userId);
|
|
const notification = items.find((n) => n.title.startsWith("Imported 2 of 5"));
|
|
expect(notification).toBeTruthy();
|
|
expect(notification!.body).toBe(
|
|
"From a MakerWorld collection — 3 blocked by a MakerWorld CAPTCHA challenge (too many requests at once) — this usually clears in 1-4 hours, then retry the same collection.",
|
|
);
|
|
});
|
|
|
|
it("labels an expired MakerWorld session distinctly from a per-model failure", async () => {
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async () => {
|
|
throw new HttpError(401, "Your MakerWorld session has expired or was rejected.");
|
|
});
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://makerworld.com/en/models/does-not-matter",
|
|
provider: "makerworld",
|
|
total: 1,
|
|
});
|
|
|
|
await runCollectionImportJob(job.id, userId, {
|
|
url: "https://makerworld.com/en/models/does-not-matter",
|
|
design_ids: ["20"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.failedCount).toBe(1);
|
|
|
|
const { items } = await listNotifications(userId);
|
|
const notification = items.find((n) => n.title.startsWith("Imported 0 of 1"));
|
|
expect(notification).toBeTruthy();
|
|
expect(notification!.body).toBe(
|
|
"From a MakerWorld collection — 1 failed because your MakerWorld session expired — update the cookie in Settings and retry.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("runThingiverseLikesImportJob", () => {
|
|
afterEach(async () => {
|
|
await setThingiverseAccessToken(null);
|
|
});
|
|
|
|
it("fails clearly when no App Token is configured, without touching the job's other bookkeeping", async () => {
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://www.thingiverse.com/someuser/likes",
|
|
provider: "thingiverse",
|
|
total: 1,
|
|
});
|
|
|
|
await runThingiverseLikesImportJob(job.id, userId, {
|
|
url: "https://www.thingiverse.com/someuser/likes",
|
|
username: "someuser",
|
|
thing_ids: ["1"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("ERROR");
|
|
expect(finished?.errorMessage).toMatch(/isn't configured/i);
|
|
});
|
|
|
|
it("imports every liked Thing into the user's 'Thingiverse likes (@user)' collection, one at a time with pacing", async () => {
|
|
await setThingiverseAccessToken("test-token");
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => {
|
|
active++;
|
|
maxActive = Math.max(maxActive, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
active--;
|
|
// addPrintsToCollection needs a real Print row for its FK.
|
|
const print = await prisma.print.create({
|
|
data: { userId, name: `Liked ${url}`, nameNormalized: `liked ${url}`.toLowerCase() },
|
|
});
|
|
return {
|
|
print,
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
};
|
|
});
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://www.thingiverse.com/someuser/likes",
|
|
provider: "thingiverse",
|
|
total: 3,
|
|
});
|
|
|
|
await runThingiverseLikesImportJob(job.id, userId, {
|
|
url: "https://www.thingiverse.com/someuser/likes",
|
|
username: "someuser",
|
|
thing_ids: ["101", "102", "103"],
|
|
tags: [],
|
|
});
|
|
|
|
expect(maxActive).toBe(1);
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.imported).toBe(3);
|
|
expect(finished?.resultCollectionId).toBeTruthy();
|
|
|
|
const collection = await prisma.collection.findFirst({ where: { userId, name: "Thingiverse likes (@someuser)" } });
|
|
expect(collection?.id).toBe(finished?.resultCollectionId);
|
|
const itemCount = await prisma.collectionItem.count({ where: { collectionId: collection!.id } });
|
|
expect(itemCount).toBe(3);
|
|
|
|
const { items } = await listNotifications(userId);
|
|
const notification = items.find((n) => n.title === "Imported 3 of 3 models from Thingiverse");
|
|
expect(notification).toBeTruthy();
|
|
expect(notification!.body).toBe("From @someuser's Likes.");
|
|
});
|
|
|
|
it("gives each user's Likes their own collection, and reuses it on the next import", async () => {
|
|
await setThingiverseAccessToken("test-token");
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => ({
|
|
print: await prisma.print.create({
|
|
data: { userId, name: `Liked again ${url}`, nameNormalized: `liked again ${url}`.toLowerCase() },
|
|
}),
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
}));
|
|
|
|
const importLikes = async (username: string, thingId: string) => {
|
|
const url = `https://www.thingiverse.com/${username}/likes`;
|
|
const job = await createJob(userId, "COLLECTION", { sourceUrl: url, provider: "thingiverse", total: 1 });
|
|
await runThingiverseLikesImportJob(job.id, userId, { url, username, thing_ids: [thingId], tags: [] });
|
|
return (await getJob(job.id, userId))?.resultCollectionId;
|
|
};
|
|
|
|
const a1 = await importLikes("userA", "201");
|
|
const a2 = await importLikes("userA", "202");
|
|
const b = await importLikes("userB", "203");
|
|
expect(a2).toBe(a1);
|
|
expect(b).not.toBe(a1);
|
|
|
|
const names = (await prisma.collection.findMany({ where: { id: { in: [a1!, b!] } } }))
|
|
.map((c) => c.name)
|
|
.toSorted();
|
|
expect(names).toEqual(["Thingiverse likes (@userA)", "Thingiverse likes (@userB)"]);
|
|
});
|
|
});
|
|
|
|
describe("runThingiverseCollectionImportJob", () => {
|
|
afterEach(async () => {
|
|
await setThingiverseAccessToken(null);
|
|
vi.mocked(fetchThingiverseCollectionTitle).mockReset();
|
|
});
|
|
|
|
it("files successful imports into a Thingport Collection named after the real Thingiverse Collection", async () => {
|
|
await setThingiverseAccessToken("test-token");
|
|
vi.mocked(fetchThingiverseCollectionTitle).mockResolvedValue("Things to Make");
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => ({
|
|
print: await prisma.print.create({
|
|
data: { userId, name: `Collected ${url}`, nameNormalized: `collected ${url}`.toLowerCase() },
|
|
}),
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
}));
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://www.thingiverse.com/Derzinskas/collections/40781574/things",
|
|
provider: "thingiverse",
|
|
total: 2,
|
|
});
|
|
|
|
await runThingiverseCollectionImportJob(job.id, userId, {
|
|
url: "https://www.thingiverse.com/Derzinskas/collections/40781574/things",
|
|
collectionId: "40781574",
|
|
thing_ids: ["301", "302"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.imported).toBe(2);
|
|
expect(finished?.sourceLabel).toBe("Things to Make");
|
|
|
|
const collection = await prisma.collection.findFirst({ where: { userId, name: "Things to Make" } });
|
|
expect(collection?.id).toBe(finished?.resultCollectionId);
|
|
|
|
const { items } = await listNotifications(userId);
|
|
const notification = items.find((n) => n.title === "Imported 2 of 2 models from Thingiverse");
|
|
expect(notification).toBeTruthy();
|
|
expect(notification!.body).toBe('From "Things to Make".');
|
|
});
|
|
|
|
it("falls back to a generic name when the Collection's real name can't be resolved", async () => {
|
|
await setThingiverseAccessToken("test-token");
|
|
vi.mocked(fetchThingiverseCollectionTitle).mockResolvedValue(null);
|
|
const mockedImport = vi.mocked(importPrintFromUrl);
|
|
mockedImport.mockImplementation(async (_userId: string, url: string) => ({
|
|
print: await prisma.print.create({
|
|
data: { userId, name: `Collected fallback ${url}`, nameNormalized: `collected fallback ${url}`.toLowerCase() },
|
|
}),
|
|
plates: [],
|
|
author: null,
|
|
previewImages: [],
|
|
alreadyImported: false,
|
|
}));
|
|
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://www.thingiverse.com/someone/collections/40781575/things",
|
|
provider: "thingiverse",
|
|
total: 1,
|
|
});
|
|
|
|
await runThingiverseCollectionImportJob(job.id, userId, {
|
|
url: "https://www.thingiverse.com/someone/collections/40781575/things",
|
|
collectionId: "40781575",
|
|
thing_ids: ["401"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("DONE");
|
|
expect(finished?.sourceLabel).toBe("Thingiverse Collection 40781575");
|
|
});
|
|
|
|
it("fails clearly when no App Token is configured", async () => {
|
|
const job = await createJob(userId, "COLLECTION", {
|
|
sourceUrl: "https://www.thingiverse.com/someone/collections/1/things",
|
|
provider: "thingiverse",
|
|
total: 1,
|
|
});
|
|
|
|
await runThingiverseCollectionImportJob(job.id, userId, {
|
|
url: "https://www.thingiverse.com/someone/collections/1/things",
|
|
collectionId: "1",
|
|
thing_ids: ["1"],
|
|
tags: [],
|
|
});
|
|
|
|
const finished = await getJob(job.id, userId);
|
|
expect(finished?.status).toBe("ERROR");
|
|
expect(finished?.errorMessage).toMatch(/isn't configured/i);
|
|
expect(fetchThingiverseCollectionTitle).not.toHaveBeenCalled();
|
|
});
|
|
});
|