mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user