feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)

This commit is contained in:
SnapOtter
2026-06-13 10:19:11 +08:00
parent 638288e196
commit fc7c1f850e
93 changed files with 7636 additions and 817 deletions
+108
View File
@@ -0,0 +1,108 @@
// convert-document integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const DOCX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.docx"));
const ODT = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.odt"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-document",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
async function pollJob(jobId: string) {
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
return row;
}
describe.skipIf(!sofficeAvailable())("convert-document (requires soffice)", () => {
it("converts docx to odt (PK magic)", async () => {
const res = await runTool("tiny.docx", DOCX, { format: "odt" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// ODT is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("converts odt to docx (PK magic)", async () => {
const res = await runTool("tiny.odt", ODT, { format: "docx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// DOCX is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("rejects same-format conversion (docx to docx)", async () => {
const res = await runTool("tiny.docx", DOCX, { format: "docx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("failed");
const error = row?.error as { message: string } | null;
expect(error?.message).toMatch(/already in that format/i);
}, 90_000);
});
it("rejects missing format with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.docx", contentType: "application/octet-stream", content: DOCX },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-document",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
@@ -0,0 +1,81 @@
// convert-presentation integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const PPTX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.pptx"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-presentation",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
async function pollJob(jobId: string) {
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
return row;
}
describe.skipIf(!sofficeAvailable())("convert-presentation (requires soffice)", () => {
it("converts pptx to odp (PK magic)", async () => {
const res = await runTool("tiny.pptx", PPTX, { format: "odp" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// ODP is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
});
it("rejects missing format with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.pptx", contentType: "application/octet-stream", content: PPTX },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-presentation",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
@@ -0,0 +1,98 @@
// convert-spreadsheet integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const XLSX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-spreadsheet",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
async function pollJob(jobId: string) {
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown; error: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
return row;
}
describe.skipIf(!sofficeAvailable())("convert-spreadsheet (requires soffice)", () => {
it("converts xlsx to ods (PK magic)", async () => {
const res = await runTool("tiny.xlsx", XLSX, { format: "ods" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// ODS is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
it("converts csv to xlsx (PK magic)", async () => {
const csvContent = Buffer.from("id,name\n1,alpha\n");
const res = await runTool("data.csv", csvContent, { format: "xlsx" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// XLSX is a ZIP (PK magic)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 90_000);
});
it("rejects missing format with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.xlsx", contentType: "application/octet-stream", content: XLSX },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/convert-spreadsheet",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
+66
View File
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv"));
const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("create-zip (pure JS, no skipIf)", () => {
it("zips two fixture files into a valid ZIP with PK magic", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "file", filename: "tiny-b.csv", contentType: "text/csv", content: CSV_B },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/create-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
// PK magic bytes
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
// Non-trivial size (both fixture files + zip overhead)
expect(dl.rawPayload.length).toBeGreaterThan(CSV_A.length + CSV_B.length - 50);
}, 30_000);
it("rejects a single file with 422 'at least two'", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/create-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/at least two/i);
}, 30_000);
});
+18
View File
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv"));
const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv"));
const XLSX_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx"));
let testApp: TestApp;
@@ -61,6 +62,23 @@ describe("csv-excel (pure JS, no skipIf)", () => {
expect(sstXml).toContain("name");
}, 30_000);
it("converts TSV to XLSX with PK magic", async () => {
const res = await runTool("tiny.tsv", TSV);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// XLSX files start with PK zip magic
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
}, 30_000);
it("converts XLSX to CSV containing the fixture content", async () => {
// Use the committed tiny.xlsx from tests/fixtures/documents/
// (Sheet1 with "SnapOtter" in A1)
+21
View File
@@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv"));
const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv"));
const JSON_FIXTURE = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.json"));
let testApp: TestApp;
@@ -68,6 +69,26 @@ describe("csv-json (pure JS, no skipIf)", () => {
expect(csvText).toContain("Ada");
}, 30_000);
it("converts TSV to JSON with the correct keys", async () => {
const res = await runTool("tiny.tsv", TSV, { pretty: true });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
const data = JSON.parse(dl.payload);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(2);
const first = data[0] as Record<string, string>;
expect(first.id).toBe("1");
expect(first.name).toBe("alpha");
}, 30_000);
it("rejects non-array JSON input for JSON-to-CSV", async () => {
const obj = Buffer.from(JSON.stringify({ key: "value" }));
const res = await runTool("obj.json", obj);
+206
View File
@@ -0,0 +1,206 @@
// epub-convert integration suite.
// Requires pandoc (and weasyprint for the pdf case). Skips locally
// (pandoc absent on dev Macs); the Docker compose smoke is the real proof.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { pythonWith } from "../helpers/python-gate.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const EPUB = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.epub"));
/** Build a minimal valid epub in memory containing a remote image reference. */
function buildRemoteRefEpub(): Buffer {
const zip = new AdmZip();
// mimetype must be first entry, stored (no compression)
zip.addFile("mimetype", Buffer.from("application/epub+zip"), "", 0o644);
zip.addFile(
"META-INF/container.xml",
Buffer.from(
`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`,
),
);
zip.addFile(
"OEBPS/content.opf",
Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:00000000-0000-0000-0000-000000000001</dc:identifier>
<dc:title>RemoteRefBook</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2024-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="ch1"/></spine>
</package>`,
),
);
zip.addFile(
"OEBPS/chapter1.xhtml",
Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>RemoteRefBook</title></head>
<body>
<h1>RemoteRefBook</h1>
<p>Content for remote-ref SSRF test.</p>
<img src="http://127.0.0.1:9/x.png" alt="remote"/>
</body>
</html>`,
),
);
return zip.toBuffer();
}
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer, settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/epub-convert",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!pandocAvailable())("epub-convert (requires pandoc)", () => {
it("converts epub to HTML containing source text", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "html" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.payload).toContain("SnapOtter test epub");
}, 30_000);
it("converts epub to DOCX with PK magic", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "docx" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
it("html output passes remote refs through without fetching (no SSRF)", async () => {
const remoteEpub = buildRemoteRefEpub();
const res = await runTool("remote.epub", remoteEpub, { format: "html" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// The literal remote URL must still be present (pandoc did not inline it)
expect(dl.payload).toContain("http://127.0.0.1:9/x.png");
expect(dl.payload).toContain("RemoteRefBook");
}, 30_000);
it("converts epub to Markdown containing source text", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "md" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.payload.length).toBeGreaterThan(0);
expect(dl.payload).toContain("SnapOtter");
}, 30_000);
});
describe.skipIf(!pandocAvailable() || !pythonWith("weasyprint"))(
"epub-convert pdf (requires pandoc + weasyprint)",
() => {
it("pdf output rejects remote refs in epub content", async () => {
const remoteEpub = buildRemoteRefEpub();
const res = await runTool("remote.epub", remoteEpub, { format: "pdf" });
// Long hint: expects 202 with jobId
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; errorMessage: string | null } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db
.select({ status: schema.jobs.status, errorMessage: schema.jobs.errorMessage })
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("failed");
expect(row?.errorMessage).toMatch(/remote resources are disabled/i);
}, 90_000);
it("converts epub to PDF via the weasyprint chain", async () => {
const res = await runTool("tiny.epub", EPUB, { format: "pdf" });
// Long hint: expects 202 with jobId
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
}, 90_000);
},
);
// Ungated tests: run locally without pandoc
it("rejects missing format with 400", async () => {
const res = await runTool("tiny.epub", EPUB, {});
expect(res.statusCode).toBe(400);
});
+81
View File
@@ -0,0 +1,81 @@
// excel-to-pdf integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const XLSX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.xlsx"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/excel-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!sofficeAvailable())("excel-to-pdf (requires soffice)", () => {
it("returns 202 (long hint) and the job completes with a PDF", async () => {
const res = await runTool("tiny.xlsx", XLSX);
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
// Poll the durable row until terminal (the long hint skips the sync window).
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
}, 90_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/excel-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+141
View File
@@ -0,0 +1,141 @@
import { createWriteStream, readFileSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import archiver from "archiver";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv"));
const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Build a zip buffer in memory using archiver. */
async function buildZipBuffer(entries: Array<{ name: string; content: Buffer }>): Promise<Buffer> {
const tmpDir = join(tmpdir(), `extract-zip-test-${Date.now()}`);
await mkdir(tmpDir, { recursive: true });
const zipPath = join(tmpDir, "test.zip");
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 1 } });
output.on("close", () => resolve());
archive.on("error", (err: Error) => reject(err));
archive.pipe(output);
for (const entry of entries) {
archive.append(entry.content, { name: entry.name });
}
void archive.finalize();
});
const buf = readFileSync(zipPath);
await rm(tmpDir, { recursive: true, force: true });
return buf;
}
async function runExtract(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/zip", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/extract-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("extract-zip (pure JS, no skipIf)", () => {
it("extracts a two-entry zip into _extracted.zip with PK magic and resultPayload", async () => {
const zipBuf = await buildZipBuffer([
{ name: "tiny-a.csv", content: CSV_A },
{ name: "tiny-b.csv", content: CSV_B },
]);
const res = await runExtract("test.zip", zipBuf);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
// resultPayload is spread into the envelope
expect(envelope.entries).toBeDefined();
expect(envelope.entries).toHaveLength(2);
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
// Output should be a zip (_extracted.zip)
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
}, 30_000);
it("extracts a single-entry zip returning the bare file content", async () => {
const content = Buffer.from("hello world from extract-zip test");
const zipBuf = await buildZipBuffer([{ name: "readme.txt", content }]);
const res = await runExtract("single.zip", zipBuf);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
// resultPayload is spread into the envelope
expect(envelope.entries).toBeDefined();
expect(envelope.entries).toHaveLength(1);
expect(envelope.entries[0].name).toBe("readme.txt");
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
// Content should match the original
expect(dl.payload).toBe("hello world from extract-zip test");
}, 30_000);
it("rejects a zip with path traversal (../evil.txt) with 422", async () => {
// Most zip libraries sanitize entry names, so we binary-patch
// a placeholder to inject "../evil.txt" into the raw zip bytes.
const zip = new AdmZip();
// Placeholder same length as "../evil.txt" (11 chars)
zip.addFile("XXXXXXXXXXX", Buffer.from("evil"));
const zipBuf = Buffer.from(zip.toBuffer());
const placeholder = Buffer.from("XXXXXXXXXXX");
const replacement = Buffer.from("../evil.txt");
let offset = zipBuf.indexOf(placeholder);
while (offset !== -1) {
replacement.copy(zipBuf, offset);
offset = zipBuf.indexOf(placeholder, offset + 1);
}
const res = await runExtract("traversal.zip", zipBuf);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
// yauzl itself rejects "../" paths with "invalid relative path" (defense in depth);
// our guard also catches them if yauzl's validation is bypassed
expect(parsed.details).toMatch(/unsafe entry path|invalid relative path/i);
}, 30_000);
it("rejects a high-ratio zip bomb with 422", async () => {
// Create a 60 MiB zero buffer - compresses to a very small zip
const bomb = Buffer.alloc(60 * 1024 * 1024, 0);
const zipBuf = await buildZipBuffer([{ name: "bomb.bin", content: bomb }]);
// The zip itself should be very small due to compression
expect(zipBuf.length).toBeLessThan(1 * 1024 * 1024);
const res = await runExtract("bomb.zip", zipBuf);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/suspicious compression/i);
}, 60_000);
});
@@ -0,0 +1,73 @@
// markdown-to-docx integration suite.
// Requires pandoc. Skips locally (pandoc absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised pandoc install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-docx",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!pandocAvailable())("markdown-to-docx (requires pandoc)", () => {
it("converts tiny.md to DOCX with PK magic", async () => {
const res = await runTool("tiny.md", MD);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// DOCX is a ZIP archive (PK magic bytes)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-docx",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
@@ -0,0 +1,73 @@
// markdown-to-html integration suite.
// Requires pandoc. Skips locally (pandoc absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised pandoc install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-html",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!pandocAvailable())("markdown-to-html (requires pandoc)", () => {
it("converts tiny.md to HTML containing source text", async () => {
const res = await runTool("tiny.md", MD);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// The fixture contains "SnapOtter Markdown fixture" as a heading
expect(dl.payload).toContain("SnapOtter Markdown fixture");
}, 30_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/markdown-to-html",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+85
View File
@@ -0,0 +1,85 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV_A = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-a.csv"));
const CSV_B = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny-b.csv"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("merge-csvs (pure JS, no skipIf)", () => {
it("merges tiny-a.csv and tiny-b.csv into one file with both rows", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "file", filename: "tiny-b.csv", contentType: "text/csv", content: CSV_B },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const text = dl.payload;
const lines = text.trim().split("\n");
// One header line + two data rows
expect(lines.length).toBe(3);
expect(text).toContain("alpha");
expect(text).toContain("beta");
}, 30_000);
it("rejects mismatched headers with 422", async () => {
const bad = Buffer.from("x,y\n10,20\n");
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "file", filename: "bad.csv", contentType: "text/csv", content: bad },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/different columns/i);
}, 30_000);
it("rejects a single file with 422", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-a.csv", contentType: "text/csv", content: CSV_A },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/at least two/i);
}, 30_000);
});
@@ -0,0 +1,81 @@
// powerpoint-to-pdf integration suite.
// Requires LibreOffice (soffice). Skips locally (soffice absent on dev Macs);
// the Docker compose smoke is the real proof that this tool works end to end
// against the containerised LibreOffice install.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { sofficeAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const PPTX = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.pptx"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/powerpoint-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!sofficeAvailable())("powerpoint-to-pdf (requires soffice)", () => {
it("returns 202 (long hint) and the job completes with a PDF", async () => {
const res = await runTool("tiny.pptx", PPTX);
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
// Poll the durable row until terminal (the long hint skips the sync window).
const { db, schema } = await import("../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
}, 90_000);
});
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "readme.txt",
contentType: "text/plain",
content: Buffer.from("hello"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/powerpoint-to-pdf",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+40
View File
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const CSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.csv"));
const TSV = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.tsv"));
let testApp: TestApp;
let adminToken: string;
@@ -60,6 +61,45 @@ describe("split-csv (pure JS, no skipIf)", () => {
}
}, 30_000);
it("splits a TSV file correctly with rowsPerFile 1", async () => {
const { body: tsvBody, contentType: tsvCt } = createMultipartPayload([
{
name: "file",
filename: "tiny.tsv",
contentType: "text/tab-separated-values",
content: TSV,
},
{ name: "settings", content: JSON.stringify({ rowsPerFile: 1, keepHeader: true }) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/split-csv",
headers: { authorization: `Bearer ${adminToken}`, "content-type": tsvCt },
body: tsvBody,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
const zip = new AdmZip(Buffer.from(dl.rawPayload));
const entries = zip.getEntries();
// TSV has 2 data rows -> 2 parts
expect(entries.length).toBe(2);
// Each part should contain the header
for (const entry of entries) {
const text = entry.getData().toString("utf8");
expect(text).toContain("id");
expect(text).toContain("name");
}
}, 30_000);
it("splits with keepHeader false omits the header from parts", async () => {
const res = await runTool({ rowsPerFile: 2, keepHeader: false });
expect(res.statusCode).toBe(200);
+73
View File
@@ -0,0 +1,73 @@
// to-epub integration suite.
// Requires pandoc. Skips locally (pandoc absent on dev Macs);
// the Docker compose smoke is the real proof.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { pandocAvailable } from "@snapotter/doc-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const MD = readFileSync(join(__dirname, "..", "fixtures", "documents", "tiny.md"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify({}) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/to-epub",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!pandocAvailable())("to-epub (requires pandoc)", () => {
it("converts tiny.md to EPUB with PK magic", async () => {
const res = await runTool("tiny.md", MD);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// EPUB is a ZIP archive (PK magic bytes)
expect(dl.rawPayload.subarray(0, 2).toString()).toBe("PK");
}, 30_000);
});
// Ungated: runs locally without pandoc
it("rejects unsupported file types with 415", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "data.csv",
contentType: "text/csv",
content: Buffer.from("a,b\n1,2"),
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/to-epub",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(415);
});
+58
View File
@@ -0,0 +1,58 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer, settings: Record<string, unknown> = {}) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/xml-to-csv",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("xml-to-csv (pure JS, no skipIf)", () => {
it("converts XML with 2 repeated elements to CSV with 2 data rows", async () => {
const xml = Buffer.from(
'<?xml version="1.0"?><items><item><name>Ada</name><age>36</age></item><item><name>Grace</name><age>85</age></item></items>',
);
const res = await runTool("data.xml", xml);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
expect(envelope.rows).toBe(2);
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const text = dl.payload;
const lines = text.trim().split("\n");
// header + 2 data rows
expect(lines.length).toBe(3);
expect(text).toContain("Ada");
expect(text).toContain("Grace");
}, 30_000);
it("rejects XML with no repeating elements", async () => {
const xml = Buffer.from('<?xml version="1.0"?><root><single>value</single></root>');
const res = await runTool("flat.xml", xml);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/no repeating elements/i);
}, 30_000);
});
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const YAML = readFileSync(join(__dirname, "..", "fixtures", "data", "tiny.yaml"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(filename: string, content: Buffer, settings: Record<string, unknown> = {}) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "application/octet-stream", content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/yaml-json",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("yaml-json (pure JS, no skipIf)", () => {
it("converts tiny.yaml to JSON containing 'alpha'", async () => {
const res = await runTool("tiny.yaml", YAML);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const json = dl.payload;
expect(json).toContain("alpha");
const parsed = JSON.parse(json);
expect(parsed.name).toBe("SnapOtter");
}, 30_000);
it("round-trips: converts the produced JSON back to YAML", async () => {
// First: yaml -> json
const res1 = await runTool("tiny.yaml", YAML);
expect(res1.statusCode).toBe(200);
const env1 = JSON.parse(res1.body);
const dl1 = await testApp.app.inject({ method: "GET", url: env1.downloadUrl });
const jsonBuf = Buffer.from(dl1.payload, "utf8");
// Second: json -> yaml
const res2 = await runTool("tiny.json", jsonBuf);
expect(res2.statusCode).toBe(200);
const env2 = JSON.parse(res2.body);
const dl2 = await testApp.app.inject({ method: "GET", url: env2.downloadUrl });
expect(dl2.statusCode).toBe(200);
const yamlText = dl2.payload;
expect(yamlText).toContain("alpha");
expect(yamlText).toContain("SnapOtter");
}, 30_000);
it("rejects malformed YAML with 422", async () => {
const bad = Buffer.from("a: [unclosed");
const res = await runTool("bad.yaml", bad);
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/not valid yaml/i);
}, 30_000);
});