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

148 lines
5.8 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createApp } from "../src/app";
const app = createApp();
let token: string;
let printId: string;
let fileUrl: string;
function auth(req: request.Test) {
return req.set("Authorization", `Bearer ${token}`);
}
async function bambuProject(): Promise<Buffer> {
const zip = new JSZip();
zip.file(
"3D/3dmodel.model",
`<?xml version="1.0"?>
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p">
<metadata name="Application">BambuStudio-02.07.01.57</metadata>
<resources>
<object id="2" type="model"><components><component p:path="/3D/Objects/object_1.model" objectid="1"/></components></object>
</resources>
<build><item objectid="2" transform="1 0 0 0 1 0 0 0 1 10 10 0" printable="1"/></build>
</model>`,
);
zip.file(
"3D/Objects/object_1.model",
`<?xml version="1.0"?>
<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
<resources>
<object id="1" type="model"><mesh>
<vertices><vertex x="0" y="0" z="0"/><vertex x="1" y="0" z="0"/><vertex x="0" y="1" z="0"/><vertex x="0" y="0" z="1"/></vertices>
<triangles><triangle v1="0" v2="1" v3="2"/><triangle v1="0" v2="1" v3="3"/><triangle v1="0" v2="2" v3="3"/><triangle v1="1" v2="2" v3="3"/></triangles>
</mesh></object>
</resources>
<build/>
</model>`,
);
zip.file("Metadata/project_settings.config", JSON.stringify({ wall_loops: "3", filament_colour: ["#FF0000"] }));
return zip.generateAsync({ type: "nodebuffer" });
}
async function fetchApplication(query = "?normalize=1"): Promise<string> {
const res = await auth(request(app).get(`/api${fileUrl}${query}`))
.buffer(true)
.parse((r, cb) => {
const chunks: Buffer[] = [];
r.on("data", (c: Buffer) => chunks.push(c));
r.on("end", () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
const xml = await (await JSZip.loadAsync(res.body as Buffer)).file("3D/3dmodel.model")!.async("string");
return /<metadata name="Application">([^<]*)</.exec(xml)?.[1] ?? "";
}
beforeAll(async () => {
const email = `normalize-test-${Date.now()}@example.com`;
const registered = await request(app)
.post("/api/register")
.send({ displayName: "Normalize Test", email, password: "password123" });
if (registered.status !== 200) throw new Error(`register failed: ${registered.status}`);
token = registered.body.token;
const file = path.join(os.tmpdir(), `normalize-${Date.now()}.3mf`);
fs.writeFileSync(file, await bambuProject());
const upload = await auth(request(app).post("/api/upload")).attach("files", file);
fs.rmSync(file, { force: true });
if (upload.status !== 200) throw new Error(`upload failed: ${upload.status}`);
printId = upload.body.prints[0].id;
fileUrl = upload.body.prints[0].plates[0].url;
});
afterAll(async () => {
if (printId) await auth(request(app).delete(`/api/print/${printId}`));
});
describe("GET plate file with ?normalize=1", () => {
it("serves the normalized copy when asked", async () => {
expect(await fetchApplication()).toBe("Thingport");
});
it("serves the original without the flag, so downloads and the viewer are unaffected", async () => {
expect(await fetchApplication("")).toMatch(/^BambuStudio/);
});
it("ignores other flag values", async () => {
expect(await fetchApplication("?normalize=auto")).toMatch(/^BambuStudio/);
});
it("leaves non-3MF plates untouched", async () => {
const file = path.join(os.tmpdir(), `normalize-${Date.now()}.stl`);
fs.writeFileSync(file, "solid x endsolid");
const upload = await auth(request(app).post("/api/upload")).attach("files", file);
fs.rmSync(file, { force: true });
const stl = upload.body.prints[0];
try {
const res = await auth(request(app).get(`/api${stl.plates[0].url}?normalize=1`)).responseType("blob");
expect(res.status).toBe(200);
expect(Buffer.from(res.body as Buffer).toString()).toBe("solid x endsolid");
} finally {
await auth(request(app).delete(`/api/print/${stl.id}`));
}
});
});
describe("POST plate normalize status", () => {
const normalizeUrl = () => `/api${fileUrl.replace(/\/file\/[^/]+$/, "/normalize")}`;
it("reports ready once the copy exists", async () => {
let status = "";
for (let i = 0; i < 50 && status !== "ready"; i++) {
const res = await auth(request(app).post(normalizeUrl()));
expect(res.status).toBe(200);
status = res.body.status;
expect(["preparing", "ready"]).toContain(status);
if (status !== "ready") await new Promise((r) => setTimeout(r, 100));
}
expect(status).toBe("ready");
});
it("rejects plates that aren't plain 3MF projects", async () => {
const file = path.join(os.tmpdir(), `normalize-${Date.now()}.stl`);
fs.writeFileSync(file, "solid x endsolid");
const upload = await auth(request(app).post("/api/upload")).attach("files", file);
fs.rmSync(file, { force: true });
const stl = upload.body.prints[0];
try {
const url = `/api${stl.plates[0].url.replace(/\/file\/[^/]+$/, "/normalize")}`;
expect((await auth(request(app).post(url))).status).toBe(400);
} finally {
await auth(request(app).delete(`/api/print/${stl.id}`));
}
});
it("404s for someone else's plate", async () => {
const other = await request(app)
.post("/api/register")
.send({ displayName: "Other", email: `normalize-other-${Date.now()}@example.com`, password: "password123" });
const res = await request(app).post(normalizeUrl()).set("Authorization", `Bearer ${other.body.token}`);
expect(res.status).toBe(404);
});
});