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:
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
id,name
|
||||
1,alpha
|
||||
|
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
id,name
|
||||
2,beta
|
||||
|
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
id name
|
||||
1 alpha
|
||||
2 beta
|
||||
|
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
name: SnapOtter
|
||||
items:
|
||||
- id: 1
|
||||
label: alpha
|
||||
- id: 2
|
||||
label: beta
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
{\rtf1\ansi\deff4\adeflang1025
|
||||
{\fonttbl{\f0\froman\fprq2\fcharset0 Times New Roman;}{\f1\froman\fprq2\fcharset2 Symbol;}{\f2\fswiss\fprq2\fcharset0 Arial;}{\f3\froman\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f4\froman\fprq2\fcharset0 Calibri;}{\f5\fswiss\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}{\f6\fnil\fprq2\fcharset0 WenQuanYi Zen Hei;}{\f7\fswiss\fprq0\fcharset128 FreeSans;}{\f8\fnil\fprq2\fcharset0 FreeSans;}}
|
||||
{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;}
|
||||
{\stylesheet{\s0\snext0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af4\loch\nowidctlpar\hyphpar0\ltrpar\cf0\f4\fs22\lang1033\kerning1\dbch\af6\langfe2052 Normal;}
|
||||
{\s15\sbasedon0\snext16\rtlch\af8\afs28 \ltrch\hich\af5\loch\sb240\sa120\keepn\f5\fs28\dbch\af6 Heading;}
|
||||
{\s16\sbasedon0\snext16\loch\sl276\slmult1\sb0\sa140 Text Body;}
|
||||
{\s17\sbasedon16\snext17\rtlch\af7 \ltrch\loch\sl276\slmult1\sb0\sa140 List;}
|
||||
{\s18\sbasedon0\snext18\rtlch\af7\afs24\ai \ltrch\loch\sb120\sa120\noline\fs24\i Caption;}
|
||||
{\s19\sbasedon0\snext19\rtlch\af7 \ltrch\loch\noline Index;}
|
||||
}{\*\generator LibreOffice/7.4.7.2$Linux_AARCH64 LibreOffice_project/40$Build-2}{\info{\creatim\yr0\mo0\dy0\hr0\min0}{\revtim\yr0\mo0\dy0\hr0\min0}{\printim\yr0\mo0\dy0\hr0\min0}}{\*\userprops}\deftab709
|
||||
\hyphauto1\viewscale100
|
||||
{\*\pgdsctbl
|
||||
{\pgdsc0\pgdscuse451\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\pgdscnxt0 Default Page Style;}}
|
||||
\formshade\paperh15840\paperw12240\margl1440\margr1440\margt1440\margb1440\sectd\sbknone\pgndec\sftnnar\saftnnrlc\sectunlocked1\pgwsxn12240\pghsxn15840\marglsxn1440\margrsxn1440\margtsxn1440\margbsxn1440\ftnbj\ftnstart1\ftnrstcont\ftnnar\aenddoc\aftnrstcont\aftnstart1\aftnnrlc
|
||||
{\*\ftnsep\chftnsep}\pgndec\pard\plain \s0\rtlch\af8\afs24\alang1081 \ltrch\lang1033\langfe2052\hich\af4\loch\nowidctlpar\hyphpar0\ltrpar\cf0\f4\fs22\lang1033\kerning1\dbch\af6\langfe2052\ql\ltrpar{\loch
|
||||
SnapOtter test document}
|
||||
\par }
|
||||
@@ -30,6 +30,10 @@ export const TOOL_SETTINGS_OVERRIDES: Record<string, unknown> = {
|
||||
"resize-video": { preset: "720p" },
|
||||
"watermark-video": { text: "CONFIDENTIAL" },
|
||||
"audio-channels": { mode: "mono-to-stereo" },
|
||||
"convert-document": { format: "odt" },
|
||||
"epub-convert": { format: "html" },
|
||||
"convert-presentation": { format: "odp" },
|
||||
"convert-spreadsheet": { format: "ods" },
|
||||
};
|
||||
|
||||
export function defaultSettingsFor(toolId: string): unknown {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// Pure-regex unit test for the SSRF pre-scan in doc_html_pdf.py.
|
||||
// Runs python3 against the actual module regexes (no weasyprint needed).
|
||||
// Mirrors the step-3 verification matrix from the fix ticket.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasPython } from "../../helpers/python-gate.js";
|
||||
|
||||
const SCRIPT_DIR = join(process.cwd(), "packages", "ai", "python");
|
||||
|
||||
/** Run a python3 snippet that imports regexes from the actual module and tests a case. */
|
||||
function testRegex(html: string, patternName: string): boolean {
|
||||
const code = [
|
||||
"import sys, os",
|
||||
`sys.path.insert(0, ${JSON.stringify(SCRIPT_DIR)})`,
|
||||
"from doc_html_pdf import _REMOTE_REF_RE, _REMOTE_CSS_URL_RE",
|
||||
`pat = ${patternName}`,
|
||||
`m = pat.search(${JSON.stringify(html)})`,
|
||||
"print('MATCH' if m else 'NO_MATCH')",
|
||||
].join("; ");
|
||||
const res = spawnSync("python3", ["-c", code], { encoding: "utf8", timeout: 5000 });
|
||||
if (res.status !== 0) throw new Error(`python3 failed: ${res.stderr}`);
|
||||
return res.stdout.trim() === "MATCH";
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPython)("SSRF pre-scan regexes (doc_html_pdf.py)", () => {
|
||||
describe("_REMOTE_REF_RE", () => {
|
||||
it("matches double-slash http img", () => {
|
||||
expect(testRegex('<img src="http://h/x.png">', "_REMOTE_REF_RE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches single-slash http img (pandoc rewrite)", () => {
|
||||
expect(testRegex('<img src="http:/h/x.png">', "_REMOTE_REF_RE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches double-slash https img", () => {
|
||||
expect(testRegex('<img src="https://h/x.png">', "_REMOTE_REF_RE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches single-slash https img", () => {
|
||||
expect(testRegex('<img src="https:/h/x.png">', "_REMOTE_REF_RE")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match data: URIs", () => {
|
||||
expect(testRegex('<img src="data:image/png;base64,AA==">', "_REMOTE_REF_RE")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match relative paths", () => {
|
||||
expect(testRegex('<a href="page2.xhtml">', "_REMOTE_REF_RE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("_REMOTE_CSS_URL_RE", () => {
|
||||
it("matches single-slash CSS url()", () => {
|
||||
expect(testRegex("url(https:/h/a.css)", "_REMOTE_CSS_URL_RE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches double-slash CSS url()", () => {
|
||||
expect(testRegex("url(https://h/a.css)", "_REMOTE_CSS_URL_RE")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match data: CSS url()", () => {
|
||||
expect(testRegex("url(data:image/png;base64,AA==)", "_REMOTE_CSS_URL_RE")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { parseConvertTarget } from "@snapotter/doc-engine";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("parseConvertTarget", () => {
|
||||
it("returns a bare extension unchanged", () => {
|
||||
const result = parseConvertTarget("pdf");
|
||||
expect(result).toEqual({ ext: "pdf", convertTo: "pdf" });
|
||||
});
|
||||
|
||||
it("splits on the first colon for qualified filter targets", () => {
|
||||
const result = parseConvertTarget("docx:MS Word 2007 XML");
|
||||
expect(result).toEqual({ ext: "docx", convertTo: "docx:MS Word 2007 XML" });
|
||||
});
|
||||
|
||||
it("handles filter names with colons after the first", () => {
|
||||
const result = parseConvertTarget("csv:Text - txt - csv (StarCalc):44,34,76,1");
|
||||
expect(result).toEqual({
|
||||
ext: "csv",
|
||||
convertTo: "csv:Text - txt - csv (StarCalc):44,34,76,1",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles single-character extensions", () => {
|
||||
const result = parseConvertTarget("a:SomeFilter");
|
||||
expect(result).toEqual({ ext: "a", convertTo: "a:SomeFilter" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pandocAvailable, runPandoc } from "@snapotter/doc-engine";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
describe("pandocAvailable", () => {
|
||||
it("returns a boolean without throwing", () => {
|
||||
expect(typeof pandocAvailable()).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!pandocAvailable())("runPandoc (requires pandoc)", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "pandoc-test-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("converts a trivial markdown file to html", async () => {
|
||||
const mdPath = join(tmpDir, "test.md");
|
||||
const htmlPath = join(tmpDir, "test.html");
|
||||
await writeFile(mdPath, "# Hello\n\nWorld\n");
|
||||
await runPandoc(mdPath, htmlPath, { extraArgs: ["--standalone"] });
|
||||
const html = readFileSync(htmlPath, "utf8");
|
||||
expect(html).toContain("Hello");
|
||||
expect(html).toContain("<");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -24,7 +24,11 @@ describe("modality metadata", () => {
|
||||
for (const tool of TOOLS) {
|
||||
expect(validModalities).toContain(tool.modality);
|
||||
expect(Array.isArray(tool.acceptedInputs)).toBe(true);
|
||||
expect(tool.acceptedInputs.length).toBeGreaterThan(0);
|
||||
// Empty acceptedInputs is valid for file-modality tools that accept any file
|
||||
// (e.g. create-zip); the factory 415 gate skips when the list is empty.
|
||||
if (tool.modality !== "file" || tool.acceptedInputs.length > 0) {
|
||||
expect(tool.acceptedInputs.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(["fast", "long"]).toContain(tool.executionHint);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user