mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
173 lines
7.5 KiB
TypeScript
173 lines
7.5 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import request from "supertest";
|
|
import { createApp } from "../src/app";
|
|
import { prisma } from "../src/db";
|
|
import { setThingiverseAccessToken } from "../src/services/settingsService";
|
|
|
|
// Administration > Triggers: refetching every imported model's description for one user.
|
|
|
|
const STEP_IMAGE = "https://media.printables.com/media/prints/refetch-step.gif";
|
|
const ONE_PIXEL_GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
|
|
function json(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
|
|
function mockPrintables(descriptions: Record<string, string>) {
|
|
return vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(async (input, init) => {
|
|
const url = String(input);
|
|
if (url === "https://api.printables.com/graphql/") {
|
|
const { variables } = JSON.parse(String(init?.body ?? "{}")) as { variables?: { id?: string } };
|
|
const id = variables?.id ?? "";
|
|
if (!(id in descriptions)) return json({ data: { print: null } });
|
|
return json({
|
|
data: { print: { id, name: `Model ${id}`, description: descriptions[id], tags: [], images: [] } },
|
|
});
|
|
}
|
|
if (url === STEP_IMAGE) return new Response(Buffer.from(ONE_PIXEL_GIF_BASE64, "base64"), { status: 200 });
|
|
throw new Error(`Unexpected fetch to ${url}`);
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
describe("refetching a user's descriptions", () => {
|
|
const app = createApp();
|
|
const originalFetch = global.fetch;
|
|
let adminToken: string;
|
|
let memberToken: string;
|
|
let memberId: string;
|
|
let memberEmail: string;
|
|
|
|
beforeAll(async () => {
|
|
const stamp = Date.now();
|
|
const admin = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "Refetch Admin", email: `refetch-admin-${stamp}@example.com`, password: "password123" });
|
|
await prisma.user.update({ where: { id: admin.body.user.id }, data: { role: "ADMIN" } });
|
|
// Tokens carry the role from when they were issued.
|
|
const login = await request(app)
|
|
.post("/api/login")
|
|
.send({ email: `refetch-admin-${stamp}@example.com`, password: "password123" });
|
|
adminToken = login.body.token;
|
|
const member = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "Refetch Member", email: `refetch-member-${stamp}@example.com`, password: "password123" });
|
|
memberToken = member.body.token;
|
|
memberId = member.body.user.id;
|
|
memberEmail = member.body.user.email;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
global.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
await setThingiverseAccessToken(null);
|
|
});
|
|
|
|
async function createPrint(name: string, notes: string, externalId: string | null, provider = "printables") {
|
|
return prisma.print.create({
|
|
data: {
|
|
userId: memberId,
|
|
name,
|
|
nameNormalized: name.toLowerCase(),
|
|
notes,
|
|
sourceProvider: externalId ? provider : null,
|
|
sourceExternalId: externalId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function waitForRun() {
|
|
for (;;) {
|
|
const res = await request(app)
|
|
.get("/api/admin/triggers/refetch-descriptions")
|
|
.set("Authorization", `Bearer ${adminToken}`);
|
|
if (!res.body.run?.running) return res.body;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
it("is admin-only", async () => {
|
|
const res = await request(app)
|
|
.post("/api/admin/triggers/refetch-descriptions")
|
|
.set("Authorization", `Bearer ${memberToken}`)
|
|
.send({ user_id: memberId });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("replaces imported descriptions with the source's, formatting and images included", async () => {
|
|
const imported = await createPrint("Imported", "Old plain text", "9100001");
|
|
const gone = await createPrint("Gone from source", "Kept as it is", "9100002");
|
|
const uploaded = await createPrint("Uploaded", "Not from a source", null);
|
|
global.fetch = mockPrintables({ "9100001": `<p>Now <strong>formatted</strong></p><img src="${STEP_IMAGE}">` });
|
|
|
|
const counts = await request(app)
|
|
.get("/api/admin/triggers/refetch-descriptions")
|
|
.set("Authorization", `Bearer ${adminToken}`);
|
|
expect(counts.body.counts[memberId]).toBe(2);
|
|
|
|
const start = await request(app)
|
|
.post("/api/admin/triggers/refetch-descriptions")
|
|
.set("Authorization", `Bearer ${adminToken}`)
|
|
.send({ user_id: memberId });
|
|
expect(start.status).toBe(200);
|
|
const { run } = await waitForRun();
|
|
expect(run).toMatchObject({ userId: memberId, total: 2, done: 2, updated: 1, failed: 1, problems: [] });
|
|
|
|
const image = await prisma.descriptionImage.findFirstOrThrow({ where: { printId: imported.id } });
|
|
const after = await prisma.print.findMany({ where: { id: { in: [imported.id, gone.id, uploaded.id] } } });
|
|
const notesOf = (id: string) => after.find((p) => p.id === id)?.notes;
|
|
expect(notesOf(imported.id)).toBe(`Now **formatted**\n\n`);
|
|
expect(notesOf(gone.id)).toBe("Kept as it is");
|
|
expect(notesOf(uploaded.id)).toBe("Not from a source");
|
|
});
|
|
|
|
it("logs whose descriptions were refetched, not just the admin who ran it", async () => {
|
|
// The entry is written fire-and-forget as the run ends, so it may land a moment later.
|
|
const findLog = () => prisma.log.findFirst({ where: { action: "descriptions_refetched", targetId: memberId } });
|
|
let log = await findLog();
|
|
for (let attempt = 0; !log && attempt < 40; attempt++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
log = await findLog();
|
|
}
|
|
expect(log?.details).toMatchObject({ email: memberEmail, total: 2, updated: 1, failed: 1 });
|
|
});
|
|
|
|
it("doesn't download the images again on a second run", async () => {
|
|
const fetchMock = mockPrintables({ "9100001": `<p>Now <strong>formatted</strong></p><img src="${STEP_IMAGE}">` });
|
|
global.fetch = fetchMock;
|
|
await request(app)
|
|
.post("/api/admin/triggers/refetch-descriptions")
|
|
.set("Authorization", `Bearer ${adminToken}`)
|
|
.send({ user_id: memberId });
|
|
const { run } = await waitForRun();
|
|
// Same description as last time, so nothing changed.
|
|
expect(run.updated).toBe(0);
|
|
expect(fetchMock.mock.calls.map(([input]) => String(input))).not.toContain(STEP_IMAGE);
|
|
});
|
|
|
|
it("stops asking Thingiverse once it rate-limits, and says so", async () => {
|
|
await setThingiverseAccessToken("test-token");
|
|
await createPrint("Thing one", "One", "9200001", "thingiverse");
|
|
await createPrint("Thing two", "Two", "9200002", "thingiverse");
|
|
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(
|
|
async (input, init) => {
|
|
if (String(input).startsWith("https://api.thingiverse.com/")) return new Response("", { status: 429 });
|
|
return mockPrintables({ "9100001": "<p>Now <strong>formatted</strong></p>" })(input, init);
|
|
},
|
|
);
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
await request(app)
|
|
.post("/api/admin/triggers/refetch-descriptions")
|
|
.set("Authorization", `Bearer ${adminToken}`)
|
|
.send({ user_id: memberId });
|
|
const { run } = await waitForRun();
|
|
|
|
expect(run.problems).toEqual(["thingiverse_rate_limited"]);
|
|
const thingiverseCalls = fetchMock.mock.calls.filter(([input]) =>
|
|
String(input).startsWith("https://api.thingiverse.com/"),
|
|
);
|
|
expect(thingiverseCalls).toHaveLength(1);
|
|
expect(run.failed).toBe(3); // both Things, plus the Printables model the source no longer has
|
|
});
|
|
});
|