Files
Thingport/backend/tests/authorLinking.test.ts

384 lines
15 KiB
TypeScript

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
// No pacing in tests. config.ts reads this on load, so it's hoisted.
vi.hoisted(() => {
process.env.IMPORT_MAKERWORLD_CALL_DELAY_MS = "0";
process.env.IMPORT_COLLECTION_DELAY_MS = "0";
});
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { linkUnattributedPrints, upsertAuthorFromImport } from "../src/services/authorService";
import { runAuthorLinking, type AuthorLinkingRun } from "../src/services/authorLinkingService";
import { getThingiverseAccessToken, setThingiverseAccessToken } from "../src/services/settingsService";
import type { ImportedAuthorInfo } from "../src/services/importResolvers";
// Name-only models link to an author record, but only when the match is unambiguous.
const app = createApp();
const stamp = Date.now();
let userId: string;
let memberToken: string;
let adminToken: string;
beforeAll(async () => {
const res = await request(app)
.post("/api/register")
.send({
displayName: "Author Linking Test",
email: `author-linking-${stamp}@example.com`,
password: "password123",
});
userId = res.body.user.id;
memberToken = res.body.token;
const admin = await request(app)
.post("/api/register")
.send({
displayName: "Author Linking Admin",
email: `author-linking-admin-${stamp}@example.com`,
password: "password123",
});
await prisma.user.update({ where: { id: admin.body.user.id }, data: { role: "ADMIN" } });
adminToken = (
await request(app)
.post("/api/login")
.send({ email: `author-linking-admin-${stamp}@example.com`, password: "password123" })
).body.token;
});
async function unattributedPrint(label: string, provider: string, creator: string | null) {
const name = `${label} ${stamp}`;
return prisma.print.create({
data: {
userId,
name,
nameNormalized: name.toLowerCase(),
creator,
sourceProvider: provider,
sourceExternalId: `${label}-${stamp}`,
},
});
}
function authorInfo(
provider: string,
externalId: string,
name: string | null,
handle: string | null = null,
): ImportedAuthorInfo {
return {
provider,
externalId: `${externalId}-${stamp}`,
name,
handle,
bio: null,
bioTranslated: null,
links: [],
avatarUrl: null,
backgroundUrl: null,
};
}
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const authorOf = async (printId: string) => (await prisma.print.findUniqueOrThrow({ where: { id: printId } })).authorId;
describe("linking earlier imports to their author", () => {
it("links a model once its author is imported, matching the name regardless of case", async () => {
const print = await unattributedPrint("case", "makerworld", `mclanesmemories${stamp}`);
const author = await upsertAuthorFromImport(authorInfo("makerworld", "case", `McLanesMemories${stamp}`));
expect(await authorOf(print.id)).toBe(author!.id);
});
it("matches the handle too, with or without a leading @", async () => {
const print = await unattributedPrint("handle", "printables", `@maker_${stamp}`);
const author = await upsertAuthorFromImport(authorInfo("printables", "handle", "Display Name", `maker_${stamp}`));
expect(await authorOf(print.id)).toBe(author!.id);
});
it("never links across providers", async () => {
const print = await unattributedPrint("provider", "thingiverse", `SameName${stamp}`);
await upsertAuthorFromImport(authorInfo("makerworld", "provider", `SameName${stamp}`));
expect(await authorOf(print.id)).toBeNull();
});
it("leaves a name two known authors share alone rather than guessing", async () => {
await upsertAuthorFromImport(authorInfo("makerworld", "twin-a", `Twin${stamp}`));
await upsertAuthorFromImport(authorInfo("makerworld", "twin-b", `twin${stamp}`));
const print = await unattributedPrint("ambiguous", "makerworld", `Twin${stamp}`);
await linkUnattributedPrints();
expect(await authorOf(print.id)).toBeNull();
});
it("doesn't touch a model already linked to someone, or one whose author was reset", async () => {
const other = await upsertAuthorFromImport(authorInfo("makerworld", "other", `Someone Else ${stamp}`));
const linked = await unattributedPrint("linked", "makerworld", `Taken${stamp}`);
await prisma.print.update({ where: { id: linked.id }, data: { authorId: other!.id } });
const reset = await unattributedPrint("reset", "makerworld", null);
await upsertAuthorFromImport(authorInfo("makerworld", "taken", `Taken${stamp}`));
expect(await authorOf(linked.id)).toBe(other!.id);
expect(await authorOf(reset.id)).toBeNull();
});
it("links every model an existing author matches when run for all", async () => {
const author = await prisma.author.create({
data: {
id: `makerworld:startup-${stamp}`,
provider: "makerworld",
externalId: `startup-${stamp}`,
name: `Startup Author ${stamp}`,
},
});
const print = await unattributedPrint("startup", "makerworld", `Startup Author ${stamp}`);
expect(await linkUnattributedPrints()).toBeGreaterThanOrEqual(1);
expect(await authorOf(print.id)).toBe(author.id);
});
});
const originalFetch = global.fetch;
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
/** Answers the listed URLs (by prefix); anything else gets a 404. */
function mockFetch(routes: Record<string, () => Response>) {
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
const match = Object.keys(routes).find((prefix) => url.startsWith(prefix));
return match ? routes[match]() : new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
}
function newRun(): AuthorLinkingRun {
return {
running: true,
startedAt: new Date().toISOString(),
finishedAt: null,
toLookUp: 0,
lookedUp: 0,
linked: 0,
notFound: 0,
problems: [],
};
}
describe("looking up authors no known author matches", () => {
let previousThingiverseToken: string | null;
beforeAll(async () => {
previousThingiverseToken = await getThingiverseAccessToken();
});
afterEach(() => {
global.fetch = originalFetch;
});
afterAll(async () => {
await setThingiverseAccessToken(previousThingiverseToken);
});
it("fetches a Printables model's author once, linking every model of that creator", async () => {
const modelId = String(stamp % 1_000_000_000);
const creator = `PrintablesPerson${stamp}`;
const first = await unattributedPrint("lookup-p1", "printables", creator);
const second = await prisma.print.update({
where: { id: (await unattributedPrint("lookup-p2", "printables", creator)).id },
data: { sourceExternalId: `other-${stamp}` },
});
await prisma.print.update({
where: { id: first.id },
data: { sourceExternalId: modelId, createdAt: new Date(Date.now() + 60_000) },
});
let modelQueries = 0;
mockFetch({
"https://api.printables.com/graphql/": () => {
modelQueries++;
return json({
data: {
print: {
id: modelId,
name: "Anything",
user: {
id: `p-${stamp}`,
handle: `pperson${stamp}`,
publicUsername: `Printables Person ${stamp}`,
avatarFilePath: "media/a.png",
},
stls: [],
},
},
});
},
});
const run = newRun();
await runAuthorLinking(run);
const authorId = `printables:p-${stamp}`;
// The site's display name differs from the stored name; linked anyway.
expect(await authorOf(first.id)).toBe(authorId);
expect(await authorOf(second.id)).toBe(authorId);
expect(modelQueries).toBe(1);
expect(run.linked).toBeGreaterThanOrEqual(2);
expect((await prisma.author.findUniqueOrThrow({ where: { id: authorId } })).avatarUrl).toBe(
"https://media.printables.com/media/a.png",
);
});
it("uses the model owner's MakerWorld login to read MakerWorld's API", async () => {
const designId = String((stamp % 1_000_000_000) + 7);
await prisma.user.update({ where: { id: userId }, data: { makerworldCookie: "token=owner-bearer" } });
const print = await unattributedPrint("lookup-mw", "makerworld", `mw-nick-${stamp}`);
await prisma.print.update({ where: { id: print.id }, data: { sourceExternalId: designId } });
let authorization: string | null = null;
global.fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
const url = String(input);
if (url === `https://api.bambulab.com/v1/design-service/design/${designId}`) {
authorization = new Headers(init?.headers).get("authorization");
return json({
id: Number(designId),
designCreator: {
uid: 777000 + (stamp % 1000),
name: `MW Person ${stamp}`,
avatar: "https://public-cdn.bblmw.com/a.png",
},
});
}
return new Response("not found", { status: 404 }); // the author profile: behind Cloudflare
}) as unknown as typeof fetch;
await runAuthorLinking(newRun());
expect(authorization).toBe("Bearer owner-bearer");
expect(await authorOf(print.id)).toBe(`makerworld:${777000 + (stamp % 1000)}`);
await prisma.user.update({ where: { id: userId }, data: { makerworldCookie: null } });
});
it("skips Thingiverse without an App Token, and says so", async () => {
await setThingiverseAccessToken(null);
const print = await unattributedPrint("lookup-tv", "thingiverse", `TvPerson${stamp}`);
mockFetch({});
const run = newRun();
await runAuthorLinking(run);
expect(run.problems).toContain("thingiverse_no_token");
expect(await authorOf(print.id)).toBeNull();
});
it("reads Cults3D with the key of an owner who has one, even when another owner's model is newer", async () => {
const keyed = await request(app)
.post("/api/register")
.send({
displayName: "Cults3D Key Owner",
email: `author-linking-c3d-${stamp}@example.com`,
password: "password123",
});
const keyedUserId: string = keyed.body.user.id;
await prisma.user.update({
where: { id: keyedUserId },
data: { cults3dNick: `keyed${stamp}`, cults3dApiKey: "secret" },
});
const creator = `c3d-creator-${stamp}`;
const older = await prisma.print.create({
data: {
userId: keyedUserId,
name: `c3d-keyed ${stamp}`,
nameNormalized: `c3d-keyed ${stamp}`,
creator,
sourceProvider: "cults3d",
sourceExternalId: `c3d-keyed-${stamp}`,
},
});
const newer = await unattributedPrint("c3d-unkeyed", "cults3d", creator);
await prisma.print.update({ where: { id: newer.id }, data: { createdAt: new Date(Date.now() + 60_000) } });
let authorization: string | null = null;
global.fetch = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
if (String(input) !== "https://cults3d.com/graphql") return new Response("not found", { status: 404 });
authorization = new Headers(init?.headers).get("authorization");
return json({ data: { creation: { slug: `c3d-keyed-${stamp}`, name: "Anything", creator: { nick: creator } } } });
}) as unknown as typeof fetch;
await runAuthorLinking(newRun());
expect(authorization).toBe(`Basic ${Buffer.from(`keyed${stamp}:secret`).toString("base64")}`);
expect(await authorOf(older.id)).toBe(`cults3d:${creator}`);
expect(await authorOf(newer.id)).toBe(`cults3d:${creator}`);
});
it("skips a Cults3D creator no owner has a key for, and says a key is missing", async () => {
const print = await unattributedPrint("c3d-nokey", "cults3d", `c3d-nokey-${stamp}`);
mockFetch({});
const run = newRun();
await runAuthorLinking(run);
expect(run.problems).toContain("cults3d_no_key");
// Other files' leftover models may be looked up too; none of it is Cults3D's.
const urls = vi.mocked(global.fetch).mock.calls.map(([input]) => String(input));
expect(urls.filter((url) => url.startsWith("https://cults3d.com"))).toEqual([]);
expect(await authorOf(print.id)).toBeNull();
});
});
describe("Administration > Triggers > Link missing authors", () => {
// Files share one database; leave the token as this file found it, even when a test fails.
let previousToken: string | null;
// Lets a run held open by a test finish, even when the test fails before letting it.
let answer: (() => void) | null = null;
beforeAll(async () => {
previousToken = await getThingiverseAccessToken();
});
afterEach(() => {
answer?.();
answer = null;
global.fetch = originalFetch;
});
afterAll(async () => {
await setThingiverseAccessToken(previousToken);
});
it("is admin-only", async () => {
expect((await request(app).get("/api/admin/triggers/link-authors").set(auth(memberToken))).status).toBe(403);
expect((await request(app).post("/api/admin/triggers/link-authors").set(auth(memberToken))).status).toBe(403);
});
it("says what it can link -- which decides whether it's shown -- then links it in the background", async () => {
// One author to look up on Thingiverse, whose answer waits until the second start has been tried: the run
// otherwise finishes in milliseconds, and whether it's still going when asked again is down to timing.
const answered = new Promise<void>((resolve) => (answer = resolve));
global.fetch = vi.fn<() => Promise<Response>>(async () => {
await answered;
return new Response("not found", { status: 404 });
}) as unknown as typeof fetch;
await setThingiverseAccessToken("test-token");
await unattributedPrint("trigger-wait", "thingiverse", `Somebody Else ${stamp}`);
const author = await prisma.author.create({
data: {
id: `thingiverse:trigger-${stamp}`,
provider: "thingiverse",
externalId: `trigger-${stamp}`,
name: `Trigger Author ${stamp}`,
},
});
const first = await unattributedPrint("trigger-a", "thingiverse", `Trigger Author ${stamp}`);
const second = await unattributedPrint("trigger-b", "thingiverse", `trigger author ${stamp}`);
const status = await request(app).get("/api/admin/triggers/link-authors").set(auth(adminToken));
expect(status.body.linkable).toBeGreaterThanOrEqual(2);
expect(status.body).toHaveProperty("lookup");
const started = await request(app).post("/api/admin/triggers/link-authors").set(auth(adminToken));
expect(started.status).toBe(200);
expect(started.body.run.running).toBe(true);
expect((await request(app).post("/api/admin/triggers/link-authors").set(auth(adminToken))).status).toBe(409);
answer?.();
let run = started.body.run;
for (let i = 0; i < 100 && run.running; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
run = (await request(app).get("/api/admin/triggers/link-authors").set(auth(adminToken))).body.run;
}
expect(run.running).toBe(false);
expect(run.linked).toBeGreaterThanOrEqual(2);
expect(await authorOf(first.id)).toBe(author.id);
expect(await authorOf(second.id)).toBe(author.id);
});
});