mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import request from "supertest";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { createApp } from "../src/app";
|
|
import { prisma } from "../src/db";
|
|
import { listZipEntries } from "../src/utils/zipReader";
|
|
|
|
const app = createApp();
|
|
|
|
let token: string;
|
|
const createdPrintIds: string[] = [];
|
|
const createdCategoryIds: string[] = [];
|
|
|
|
function tmpFile(name: string, contents: string): string {
|
|
// supertest uses the basename as the upload filename, so each file gets its own directory.
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-test-"));
|
|
const p = path.join(dir, name);
|
|
fs.writeFileSync(p, contents);
|
|
return p;
|
|
}
|
|
|
|
async function trackPrint(id: string) {
|
|
createdPrintIds.push(id);
|
|
}
|
|
|
|
async function cleanupPrint(id: string) {
|
|
await request(app).delete(`/api/print/${id}`).set("Authorization", `Bearer ${token}`);
|
|
}
|
|
|
|
const TEST_EMAIL = "test@example.com";
|
|
const TEST_PASSWORD = "test-password-123";
|
|
|
|
beforeAll(async () => {
|
|
// Log in instead if a persistent test DB already has the account.
|
|
let res = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "Test User", email: TEST_EMAIL, password: TEST_PASSWORD });
|
|
if (res.status === 409) {
|
|
res = await request(app).post("/api/login").send({ email: TEST_EMAIL, password: TEST_PASSWORD });
|
|
}
|
|
if (res.status !== 200) {
|
|
throw new Error(`Failed to authenticate during test setup: ${res.status} ${JSON.stringify(res.body)}`);
|
|
}
|
|
token = res.body.token;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const id of createdPrintIds) {
|
|
await cleanupPrint(id).catch(() => undefined);
|
|
}
|
|
for (const id of createdCategoryIds) {
|
|
await request(app)
|
|
.delete(`/api/category/${id}`)
|
|
.set("Authorization", `Bearer ${token}`)
|
|
.catch(() => undefined);
|
|
}
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
function auth(req: request.Test): request.Test {
|
|
return req.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
|
|
describe("auth", () => {
|
|
it("rejects requests without a token", async () => {
|
|
const res = await request(app).get("/api/prints");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects a bad login", async () => {
|
|
const res = await request(app).post("/api/login").send({ email: TEST_EMAIL, password: "wrong" });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects login for an email that doesn't exist with the same generic message", async () => {
|
|
const res = await request(app).post("/api/login").send({ email: "nobody@example.com", password: "whatever123" });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.detail).toBe("Invalid email or password");
|
|
});
|
|
|
|
it("rejects registration with a password under 8 characters", async () => {
|
|
const res = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "X", email: `short-${Date.now()}@example.com`, password: "short" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects registration with an invalid email", async () => {
|
|
const res = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "X", email: "not-an-email", password: "validpass123" });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects a duplicate email registration", async () => {
|
|
const res = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "Dup", email: TEST_EMAIL, password: TEST_PASSWORD });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it("scopes prints per user -- a second account cannot see or modify the first's", async () => {
|
|
const otherEmail = `other-${Date.now()}@example.com`;
|
|
const registerRes = await request(app)
|
|
.post("/api/register")
|
|
.send({ displayName: "Other", email: otherEmail, password: "other-password-123" });
|
|
expect(registerRes.status).toBe(200);
|
|
const otherToken = registerRes.body.token as string;
|
|
|
|
const f = tmpFile("cross-tenant.stl", "solid x endsolid");
|
|
const uploadRes = await auth(request(app).post("/api/upload")).attach("files", f);
|
|
fs.rmSync(f, { force: true });
|
|
expect(uploadRes.status).toBe(200);
|
|
const printId = uploadRes.body.prints[0].id as string;
|
|
await trackPrint(printId);
|
|
|
|
const listRes = await request(app).get("/api/prints").set("Authorization", `Bearer ${otherToken}`);
|
|
expect(listRes.body.find((p: { id: string }) => p.id === printId)).toBeUndefined();
|
|
|
|
const tagRes = await request(app)
|
|
.post(`/api/print/${printId}/tags`)
|
|
.set("Authorization", `Bearer ${otherToken}`)
|
|
.send({ tags: ["hijacked"] });
|
|
expect(tagRes.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("tag filtering is case-insensitive", () => {
|
|
it("GET /prints?tags= matches regardless of casing on either side", async () => {
|
|
const f = tmpFile("cased-tag.stl", "solid cased endsolid");
|
|
const uploadRes = await auth(request(app).post("/api/upload")).attach("files", f);
|
|
fs.rmSync(f, { force: true });
|
|
expect(uploadRes.status).toBe(200);
|
|
const printId = uploadRes.body.prints[0].id as string;
|
|
await trackPrint(printId);
|
|
|
|
const tagRes = await auth(request(app).post(`/api/print/${printId}/tags`)).send({ tags: ["Fun"] });
|
|
expect(tagRes.status).toBe(200);
|
|
|
|
const lower = await auth(request(app).get("/api/prints")).query({ tags: "fun" });
|
|
expect(lower.body.some((p: { id: string }) => p.id === printId)).toBe(true);
|
|
|
|
const upper = await auth(request(app).get("/api/prints")).query({ tags: "FUN" });
|
|
expect(upper.body.some((p: { id: string }) => p.id === printId)).toBe(true);
|
|
|
|
const tagsList = await auth(request(app).get("/api/tags")).query({ tags: "fun" });
|
|
expect(tagsList.body).toContain("Fun");
|
|
});
|
|
});
|
|
|
|
describe("upload: separate vs multiplate", () => {
|
|
it("uploading a single file creates a one-plate print", async () => {
|
|
const f = tmpFile("solo.stl", "solid solo endsolid");
|
|
const res = await auth(request(app).post("/api/upload")).attach("files", f);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.prints).toHaveLength(1);
|
|
expect(res.body.prints[0].plates).toHaveLength(1);
|
|
expect(res.body.prints[0].plates[0].filename).toBe("solo.stl");
|
|
await trackPrint(res.body.prints[0].id);
|
|
fs.rmSync(f, { force: true });
|
|
});
|
|
|
|
it("mode=separate creates one print per file", async () => {
|
|
const f1 = tmpFile("sep1.stl", "solid sep1 endsolid");
|
|
const f2 = tmpFile("sep2.stl", "solid sep2 endsolid");
|
|
const res = await auth(request(app).post("/api/upload"))
|
|
.field("mode", "separate")
|
|
.attach("files", f1)
|
|
.attach("files", f2);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.prints).toHaveLength(2);
|
|
for (const p of res.body.prints) {
|
|
expect(p.plates).toHaveLength(1);
|
|
await trackPrint(p.id);
|
|
}
|
|
fs.rmSync(f1, { force: true });
|
|
fs.rmSync(f2, { force: true });
|
|
});
|
|
|
|
it("mode=multiplate creates one print with several plates in submitted order", async () => {
|
|
const f1 = tmpFile("multi-a.stl", "solid a endsolid");
|
|
const f2 = tmpFile("multi-b.stl", "solid b endsolid");
|
|
const f3 = tmpFile("multi-c.stl", "solid c endsolid");
|
|
const res = await auth(request(app).post("/api/upload"))
|
|
.field("mode", "multiplate")
|
|
.field("title", "Multi Test Print")
|
|
.attach("files", f1)
|
|
.attach("files", f2)
|
|
.attach("files", f3);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.prints).toHaveLength(1);
|
|
const print = res.body.prints[0];
|
|
expect(print.plates.map((p: any) => p.filename)).toEqual(["multi-a.stl", "multi-b.stl", "multi-c.stl"]);
|
|
expect(print.plates.map((p: any) => p.position)).toEqual([0, 1, 2]);
|
|
await trackPrint(print.id);
|
|
fs.rmSync(f1, { force: true });
|
|
fs.rmSync(f2, { force: true });
|
|
fs.rmSync(f3, { force: true });
|
|
});
|
|
|
|
it("requires mode when uploading more than one file without specifying it", async () => {
|
|
const f1 = tmpFile("nomodeA.stl", "solid a endsolid");
|
|
const f2 = tmpFile("nomodeB.stl", "solid b endsolid");
|
|
const res = await auth(request(app).post("/api/upload")).attach("files", f1).attach("files", f2);
|
|
expect(res.status).toBe(400);
|
|
fs.rmSync(f1, { force: true });
|
|
fs.rmSync(f2, { force: true });
|
|
});
|
|
});
|
|
|
|
describe("plate add/remove/reorder/rename", () => {
|
|
let printId: string;
|
|
let plateIds: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const f1 = tmpFile("plate-x.stl", "solid x endsolid");
|
|
const f2 = tmpFile("plate-y.stl", "solid y endsolid");
|
|
const res = await auth(request(app).post("/api/upload"))
|
|
.field("mode", "multiplate")
|
|
.field("title", "Plate Ops Print")
|
|
.attach("files", f1)
|
|
.attach("files", f2);
|
|
printId = res.body.prints[0].id;
|
|
plateIds = res.body.prints[0].plates.map((p: any) => p.id);
|
|
await trackPrint(printId);
|
|
fs.rmSync(f1, { force: true });
|
|
fs.rmSync(f2, { force: true });
|
|
});
|
|
|
|
it("adds a plate to an existing print", async () => {
|
|
const f = tmpFile("plate-z.stl", "solid z endsolid");
|
|
const res = await auth(request(app).post(`/api/print/${printId}/plates`)).attach("files", f);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.plates).toHaveLength(3);
|
|
expect(res.body.print.plates[2].filename).toBe("plate-z.stl");
|
|
plateIds = res.body.print.plates.map((p: any) => p.id);
|
|
fs.rmSync(f, { force: true });
|
|
});
|
|
|
|
it("reorders plates", async () => {
|
|
const reversed = plateIds.toReversed();
|
|
const res = await auth(request(app).post(`/api/print/${printId}/plates/reorder`)).send({ plate_ids: reversed });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.plates.map((p: any) => p.id)).toEqual(reversed);
|
|
expect(res.body.print.plates.map((p: any) => p.position)).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
it("renames a plate's filename", async () => {
|
|
const target = plateIds[0];
|
|
const res = await auth(request(app).post(`/api/print/${printId}/plate/${target}/rename`)).send({
|
|
filename: "renamed.stl",
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const renamed = res.body.print.plates.find((p: any) => p.id === target);
|
|
expect(renamed.filename).toBe("renamed.stl");
|
|
});
|
|
|
|
it("removes plates down to one, then 409s on the last one", async () => {
|
|
let res = await auth(request(app).delete(`/api/print/${printId}/plates/${plateIds[0]}`));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.plates).toHaveLength(2);
|
|
expect(res.body.print.plates.map((p: any) => p.position)).toEqual([0, 1]);
|
|
|
|
res = await auth(request(app).delete(`/api/print/${printId}/plates/${plateIds[1]}`));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.plates).toHaveLength(1);
|
|
|
|
const lastPlateId = res.body.print.plates[0].id;
|
|
res = await auth(request(app).delete(`/api/print/${printId}/plates/${lastPlateId}`));
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
// The Edit dialog's order for swapping a model's only file for an edited copy of the same name.
|
|
it("replaces the only plate with a same-named file: upload, delete the old one, rename back", async () => {
|
|
let res = await auth(request(app).get(`/api/print/${printId}`));
|
|
const [original] = res.body.plates;
|
|
|
|
res = await auth(request(app).post(`/api/print/${printId}/plates`)).attach(
|
|
"files",
|
|
tmpFile(original.filename, "solid replacement endsolid"),
|
|
);
|
|
const added = res.body.print.plates.find((p: any) => p.id !== original.id);
|
|
expect(added.filename).not.toBe(original.filename); // suffixed while the old one exists
|
|
|
|
res = await auth(request(app).delete(`/api/print/${printId}/plates/${original.id}`));
|
|
expect(res.status).toBe(200);
|
|
res = await auth(request(app).post(`/api/print/${printId}/plate/${added.id}/rename`)).send({
|
|
filename: original.filename,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.plates).toEqual([expect.objectContaining({ id: added.id, filename: original.filename })]);
|
|
});
|
|
});
|
|
|
|
describe("category CRUD + cycle rejection", () => {
|
|
it("creates, lists, updates, and deletes a category", async () => {
|
|
const create = await auth(request(app).post("/api/categories")).send({ name: "Test Category A", tags: ["x"] });
|
|
expect(create.status).toBe(200);
|
|
const categoryId = create.body.id;
|
|
|
|
const list = await auth(request(app).get("/api/categories"));
|
|
expect(list.status).toBe(200);
|
|
expect(list.body.some((f: any) => f.id === categoryId)).toBe(true);
|
|
|
|
const update = await auth(request(app).patch(`/api/category/${categoryId}`)).send({
|
|
name: "Test Category A Renamed",
|
|
tags: [],
|
|
});
|
|
expect(update.status).toBe(200);
|
|
expect(update.body.name).toBe("Test Category A Renamed");
|
|
|
|
const del = await auth(request(app).delete(`/api/category/${categoryId}`));
|
|
expect(del.status).toBe(200);
|
|
});
|
|
|
|
it("rejects a parent that does not exist", async () => {
|
|
const res = await auth(request(app).post("/api/categories")).send({
|
|
name: "Orphan",
|
|
tags: [],
|
|
parent_id: "does-not-exist",
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("rejects creating a cycle", async () => {
|
|
const parent = await auth(request(app).post("/api/categories")).send({ name: "Cycle Parent", tags: [] });
|
|
const child = await auth(request(app).post("/api/categories")).send({
|
|
name: "Cycle Child",
|
|
tags: [],
|
|
parent_id: parent.body.id,
|
|
});
|
|
const attempt = await auth(request(app).patch(`/api/category/${parent.body.id}`)).send({
|
|
name: "Cycle Parent",
|
|
tags: [],
|
|
parent_id: child.body.id,
|
|
});
|
|
expect(attempt.status).toBe(400);
|
|
|
|
await auth(request(app).delete(`/api/category/${child.body.id}`));
|
|
await auth(request(app).delete(`/api/category/${parent.body.id}`));
|
|
});
|
|
});
|
|
|
|
describe("prepared-print attach/detect/remove", () => {
|
|
let printId: string;
|
|
|
|
beforeAll(async () => {
|
|
const f = tmpFile("bare.stl", "solid bare endsolid");
|
|
const res = await auth(request(app).post("/api/upload")).attach("files", f);
|
|
printId = res.body.prints[0].id;
|
|
await trackPrint(printId);
|
|
fs.rmSync(f, { force: true });
|
|
});
|
|
|
|
it("starts with no prepared print", async () => {
|
|
const res = await auth(request(app).get("/api/prints"));
|
|
const print = res.body.find((p: any) => p.id === printId);
|
|
expect(print.prepared_print).toBeNull();
|
|
});
|
|
|
|
it("attaches an explicit prepared gcode file", async () => {
|
|
const gcode = tmpFile(
|
|
"sliced.gcode",
|
|
"; printer_model = Bambu Lab X1 Carbon\n; filament_type = PETG\n; nozzle_diameter = 0.4\n; layer_height = 0.16\n; estimated printing time (normal mode) = 1h 5m\nG28\n",
|
|
);
|
|
const res = await auth(request(app).post(`/api/print/${printId}/files`)).attach("file", gcode);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.prepared_print).toMatchObject({
|
|
printer: "Bambu Lab X1 Carbon",
|
|
material: "PETG",
|
|
nozzle_mm: 0.4,
|
|
layer_height_mm: 0.16,
|
|
removable: true,
|
|
});
|
|
expect(res.body.print.slicer_filename).toBe("sliced.gcode");
|
|
fs.rmSync(gcode, { force: true });
|
|
});
|
|
|
|
it("removes the prepared file and falls back to null (bare .stl isn't sniffable)", async () => {
|
|
const res = await auth(request(app).delete(`/api/print/${printId}/prepared-print`));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.print.prepared_print).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("zip download arcname structure", () => {
|
|
it("nests plates under {category}/{print.name}/ and supporting files under .../supporting/", async () => {
|
|
const category = await auth(request(app).post("/api/categories")).send({ name: "Zip Category Test", tags: [] });
|
|
createdCategoryIds.push(category.body.id);
|
|
|
|
const f = tmpFile("zippy.stl", "solid zippy endsolid");
|
|
const upload = await auth(request(app).post("/api/upload"))
|
|
.field("title", "Zippy Print")
|
|
.field("category_id", category.body.id)
|
|
.attach("files", f);
|
|
const printId = upload.body.prints[0].id;
|
|
createdPrintIds.push(printId);
|
|
fs.rmSync(f, { force: true });
|
|
|
|
const note = tmpFile("readme.txt", "hello");
|
|
await auth(request(app).post(`/api/print/${printId}/files`)).attach("file", note);
|
|
fs.rmSync(note, { force: true });
|
|
|
|
const zipRes = await auth(request(app).post("/api/download/zip"))
|
|
.send({ print_ids: [printId] })
|
|
.buffer(true)
|
|
.parse((res, cb) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => cb(null, Buffer.concat(chunks)));
|
|
});
|
|
expect(zipRes.status).toBe(200);
|
|
|
|
const tmpZip = path.join(os.tmpdir(), `test-${Date.now()}.zip`);
|
|
fs.writeFileSync(tmpZip, zipRes.body as Buffer);
|
|
const entries = await listZipEntries(tmpZip);
|
|
const names = entries
|
|
.filter((e) => !e.isDirectory)
|
|
.map((e) => e.name)
|
|
.toSorted();
|
|
expect(names).toEqual([
|
|
"Zip Category Test/Zippy Print/supporting/readme.txt",
|
|
"Zip Category Test/Zippy Print/zippy.stl",
|
|
]);
|
|
fs.rmSync(tmpZip, { force: true });
|
|
});
|
|
});
|