Merge branch 'worktree-test+suite-overhaul-and-real-fixtures' into chore/consolidate-v2.0.0

# Conflicts:
#	tests/integration/generated/settings-matrix.test.ts
#	tests/integration/platform/api.test.ts
#	tests/integration/platform/concurrent.test.ts
#	tests/integration/platform/factory-multi-input.test.ts
#	tests/integration/security/adversarial-comprehensive.test.ts
#	tests/integration/security/adversarial-coverage-gaps.test.ts
#	tests/integration/security/adversarial-extended.test.ts
#	tests/integration/security/adversarial-final-gaps.test.ts
#	tests/integration/security/adversarial-matrix.test.ts
#	tests/integration/security/adversarial-security.test.ts
#	tests/integration/security/adversarial.test.ts
#	tests/integration/tools/image/color-adjustments.test.ts
This commit is contained in:
SnapOtter
2026-06-21 02:18:53 +08:00
547 changed files with 37191 additions and 4059 deletions
@@ -0,0 +1,286 @@
/**
* Integration tests for the chart-maker tool (/api/v1/tools/files/chart-maker).
*
* Factory FILE tool that consumes CSV/JSON and renders hand-rolled SVG
* rasterized to PNG via sharp. Tests cover bar/line/pie kinds, CSV and JSON
* inputs, numeric validation, SVG label escaping, and invalid kind rejection.
*/
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
function makeCsv(rows: [string, number][]): Buffer {
const lines = ["label,value", ...rows.map(([l, v]) => `${l},${v}`)];
return Buffer.from(lines.join("\n"), "utf8");
}
describe("Chart Maker", () => {
it("generates a bar chart from a 4-row CSV", async () => {
const csv = makeCsv([
["Apples", 10],
["Bananas", 25],
["Cherries", 15],
["Dates", 30],
]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
// Download and verify PNG
const dlRes = await app.inject({ method: "GET", url: result.downloadUrl });
expect(dlRes.statusCode).toBe(200);
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.format).toBe("png");
expect(meta.width).toBeGreaterThan(0);
});
it("generates a chart from JSON object input", async () => {
const jsonData = JSON.stringify({ Apples: 10, Bananas: 25, Cherries: 15 });
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "data.json",
contentType: "application/json",
content: Buffer.from(jsonData),
},
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
});
it("generates a pie chart", async () => {
const csv = makeCsv([
["Red", 40],
["Blue", 30],
["Green", 30],
]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ kind: "pie" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
});
it("generates a line chart", async () => {
const csv = makeCsv([
["Jan", 10],
["Feb", 20],
["Mar", 15],
]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ kind: "line" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
});
it("rejects CSV with non-numeric column 2", async () => {
const csv = Buffer.from("label,value\nApples,lots\nBananas,many\n", "utf8");
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "bad.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const result = JSON.parse(res.body);
expect(result.error + " " + (result.details ?? "")).toMatch(/numeric/i);
});
it("escapes SVG-injection labels and produces valid PNG", async () => {
const csv = makeCsv([["<script>alert(1)</script>", 10]]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "xss.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ kind: "bar" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
// Download and verify it decodes as PNG (the escape proof: no SVG parse error)
const dlRes = await app.inject({ method: "GET", url: result.downloadUrl });
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.format).toBe("png");
});
it("rejects invalid chart kind", async () => {
const csv = makeCsv([["A", 1]]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ kind: "donut" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
});
it("uses default kind bar when no kind specified", async () => {
const csv = makeCsv([
["A", 10],
["B", 20],
]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
});
it("generates a chart from JSON array input", async () => {
const jsonData = JSON.stringify([
{ label: "A", value: 10 },
{ label: "B", value: 20 },
{ label: "C", value: 30 },
]);
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "data.json",
contentType: "application/json",
content: Buffer.from(jsonData),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
});
it("respects custom title", async () => {
const csv = makeCsv([["A", 10]]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
{ name: "settings", content: JSON.stringify({ title: "Sales Report" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
});
it("rejects unauthenticated requests", async () => {
const csv = makeCsv([["A", 10]]);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "data.csv", contentType: "text/csv", content: csv },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/files/chart-maker",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
@@ -0,0 +1,70 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const CSV_A = readFixture(fixtures.data.csvA);
const CSV_B = readFixture(fixtures.data.csvB);
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/files/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 400 '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/files/create-zip",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/at least 2/i);
}, 30_000);
});
@@ -0,0 +1,103 @@
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const CSV = readFixture(fixtures.data.csv);
const TSV = readFixture(fixtures.data.tsv);
const XLSX_FIXTURE = readFixture(fixtures.document.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/files/csv-excel",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("csv-excel (pure JS, no skipIf)", () => {
it("converts CSV to XLSX with PK magic and reloadable content", async () => {
const res = await runTool("tiny.csv", CSV);
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);
// XLSX is a ZIP containing xl/worksheets/sheet1.xml.
// Parse the zip and verify the first sheet's XML has the header "name".
const zip = new AdmZip(Buffer.from(dl.rawPayload));
const sheetEntry = zip.getEntry("xl/worksheets/sheet1.xml");
expect(sheetEntry).toBeDefined();
// The shared strings table stores cell values; look there
const sst = zip.getEntry("xl/sharedStrings.xml");
expect(sst).toBeDefined();
const sstXml = sst?.getData().toString("utf8") ?? "";
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/document/formats/
// (Sheet1 with "SnapOtter" in A1)
const res = await runTool("tiny.xlsx", XLSX_FIXTURE, { sheet: 1 });
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 csvText = dl.payload;
expect(csvText).toContain("SnapOtter");
}, 30_000);
});
@@ -0,0 +1,101 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const CSV = readFixture(fixtures.data.csv);
const TSV = readFixture(fixtures.data.tsv);
const JSON_FIXTURE = readFixture(fixtures.data.json);
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/files/csv-json",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("csv-json (pure JS, no skipIf)", () => {
it("converts CSV to JSON with the Ada row present", async () => {
const res = await runTool("tiny.csv", CSV, { 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);
const ada = data.find((r: Record<string, string>) => r.name === "Ada");
expect(ada).toBeDefined();
expect(ada.age).toBe("36");
}, 30_000);
it("converts JSON to CSV containing name,age header", async () => {
const res = await runTool("tiny.json", JSON_FIXTURE);
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 csvText = dl.payload;
expect(csvText).toContain("name,age");
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);
expect(res.statusCode).toBe(422);
}, 30_000);
});
@@ -0,0 +1,147 @@
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 { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const CSV_A = readFixture(fixtures.data.csvA);
const CSV_B = readFixture(fixtures.data.csvB);
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/files/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 400", 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(400);
const parsed = JSON.parse(res.body);
// Path traversal is now rejected pre-enqueue with a clean 400 InputValidationError,
// whose message is surfaced in `error` (the legacy worker path used `details`/422).
expect(parsed.error).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,75 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const JSON_FIXTURE = readFixture(fixtures.data.json);
const XML_FIXTURE = readFixture(fixtures.data.xml);
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/files/json-xml",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("json-xml (pure JS, no skipIf)", () => {
it("converts JSON to XML containing <name>Ada</name>", async () => {
const res = await runTool("tiny.json", JSON_FIXTURE, { 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 xmlText = dl.payload;
expect(xmlText).toContain("<name>Ada</name>");
}, 30_000);
it("converts XML to JSON with the people structure", async () => {
const res = await runTool("tiny.xml", XML_FIXTURE, { 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);
// fast-xml-parser parses into a structure with a "people" key
expect(data.people).toBeDefined();
const persons = data.people.person;
expect(Array.isArray(persons)).toBe(true);
const ada = persons.find((p: Record<string, string>) => p.name === "Ada");
expect(ada).toBeDefined();
}, 30_000);
});
@@ -0,0 +1,89 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const CSV_A = readFixture(fixtures.data.csvA);
const CSV_B = readFixture(fixtures.data.csvB);
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/files/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/files/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 400", 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/files/merge-csvs",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/at least 2/i);
}, 30_000);
});
@@ -0,0 +1,124 @@
import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const CSV = readFixture(fixtures.data.csv);
const TSV = readFixture(fixtures.data.tsv);
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(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.csv", contentType: "text/csv", content: CSV },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/files/split-csv",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe("split-csv (pure JS, no skipIf)", () => {
it("splits with rowsPerFile 1 into a zip with 3 entries each containing the header", async () => {
const res = await runTool({ rowsPerFile: 1, keepHeader: 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);
// Verify zip magic (PK header: 0x50 0x4B)
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
// Verify 3 entries via adm-zip (3 data rows = 3 parts)
const zip = new AdmZip(Buffer.from(dl.rawPayload));
const entries = zip.getEntries();
expect(entries.length).toBe(3);
// Each part should contain the header row "name,age"
for (const entry of entries) {
const text = entry.getData().toString("utf8");
expect(text).toContain("name,age");
}
}, 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/files/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);
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();
// 4 rows (including header treated as data) / 2 = 2 parts
expect(entries.length).toBe(2);
}, 30_000);
});
@@ -0,0 +1,74 @@
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/files/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("converts single non-repeating record to a 1-row CSV", 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(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
expect(envelope.rows).toBe(1);
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 + 1 data row
expect(lines.length).toBe(2);
expect(text).toContain("single");
expect(text).toContain("value");
}, 30_000);
});
@@ -0,0 +1,80 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const YAML = readFixture(fixtures.data.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/files/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);
});