Files
ConvertX/tests/converters/main.test.ts
Emrik Östling 2135c6b9f1 chore: use bun fmt and bun check (#685)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-23 18:23:49 +02:00

398 lines
14 KiB
TypeScript

import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Cookie } from "elysia";
import { properties as libreofficeProperties } from "../../src/converters/libreoffice";
import { captureConsole } from "./helpers/converters";
if (!process.env.CONVERTX_TEST_ROOT) {
// Without tests/preload.ts the db module would open ./data/mydb.sqlite and these tests
// would write their rows into it.
throw new Error("Converter tests must run via `bun test` so that tests/preload.ts is loaded.");
}
// dynamic imports ensure that the guard above runs before the db module is loaded
const { default: db } = await import("../../src/db/db");
const { chunks, getAllInputs, getAllTargets, getPossibleTargets, handleConvert, mainConverter } =
await import("../../src/converters/main");
const testRoot = mkdtempSync(join(tmpdir(), "convertx-main-test-"));
// Every row written by these tests uses this prefix, so cleanup never touches other rows.
const JOB_PREFIX = "main-test-";
type FileRow = { file_name: string; output_file_name: string; status: string };
function jobCookie(name?: string): Cookie<string | undefined> {
return { value: name === undefined ? undefined : `${JOB_PREFIX}${name}` } as Cookie<
string | undefined
>;
}
function rowsFor(name: string, orderBy: "id" | "file_name" = "id"): FileRow[] {
return db
.query(
`SELECT file_name, output_file_name, status FROM file_names WHERE job_id = ? ORDER BY ${orderBy}`,
)
.all(`${JOB_PREFIX}${name}`) as FileRow[];
}
const vcard = (fullName: string) => `BEGIN:VCARD\nFN:${fullName}\nEND:VCARD\n`;
let caseCounter = 0;
let uploadsDir: string;
let outputDir: string;
let output: ReturnType<typeof captureConsole>;
beforeEach(() => {
// handleConvert concatenates dir and file name, so both need a trailing slash
const caseDir = join(testRoot, `case-${caseCounter++}`);
uploadsDir = `${join(caseDir, "uploads")}/`;
outputDir = `${join(caseDir, "output")}/`;
mkdirSync(uploadsDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
output = captureConsole();
});
afterEach(() => {
output.restore();
db.query("DELETE FROM file_names WHERE job_id LIKE ?").run(`${JOB_PREFIX}%`);
});
// removes the temp directory with the uploads and outputs after all tests finish
afterAll(() => {
rmSync(testRoot, { recursive: true, force: true });
});
describe("converter registry", () => {
test("getPossibleTargets lists the targets of every converter for an input type", () => {
expect(getPossibleTargets("vcf")).toEqual({ vcf: ["csv"] });
});
test("getPossibleTargets normalizes the input type", () => {
expect(getPossibleTargets("VCF")).toEqual({ vcf: ["csv"] });
expect(Object.keys(getPossibleTargets("jpeg")).length).toBeGreaterThan(0);
expect(getPossibleTargets("JPG")).toEqual(getPossibleTargets("jpeg"));
expect(getPossibleTargets("htm")).toEqual(getPossibleTargets("html"));
});
test("getPossibleTargets returns an empty object for unknown input types", () => {
expect(getPossibleTargets("xyz123")).toEqual({});
});
test("getAllTargets has an entry for every registered converter", () => {
expect(Object.keys(getAllTargets()).sort()).toEqual([
"assimp",
"calibre",
"dasel",
"djvu",
"dvisvgm",
"ffmpeg",
"graphicsmagick",
"imagemagick",
"inkscape",
"libheif",
"libjxl",
"libreoffice",
"markitDown",
"msgconvert",
"pandoc",
"pdftops",
"potrace",
"resvg",
"vcf",
"vips",
"vtracer",
"xelatex",
]);
});
test("getAllTargets merges the targets of all categories of a converter", () => {
const allTargets = getAllTargets();
expect(allTargets.vcf).toEqual(["csv"]);
expect(allTargets.libjxl).toEqual([
"apng",
"exr",
"jpeg",
"pam",
"pfm",
"pgm",
"pgx",
"png",
"ppm",
"jxl",
]);
});
test("getAllInputs merges the inputs of all categories of a converter", () => {
expect(getAllInputs("vcf")).toEqual(["vcf"]);
expect(getAllInputs("libjxl")).toEqual([
"jxl",
"apng",
"exr",
"gif",
"jpeg",
"pam",
"pfm",
"pgm",
"pgx",
"png",
"ppm",
]);
});
test("getAllInputs returns an empty list for unknown converters", () => {
expect(getAllInputs("doesnotexist")).toEqual([]);
});
test("listing all targets and inputs does not leak formats across categories", () => {
expect(getPossibleTargets("docx").libreoffice).not.toContain("xlsx");
expect(getPossibleTargets("jxl").libjxl).not.toContain("jxl");
expect(libreofficeProperties.from.text).not.toContain("xlsx");
expect(libreofficeProperties.to.text).not.toContain("xlsm");
});
});
describe("chunks", () => {
test("chunks with size 0 returns entire array as single chunk", () => {
expect(chunks([1, 2, 3, 4, 5], 0)).toEqual([[1, 2, 3, 4, 5]]);
});
test("chunks with negative size returns entire array as single chunk", () => {
expect(chunks(["a", "b", "c"], -1)).toEqual([["a", "b", "c"]]);
});
test("chunks with size larger than array returns single chunk", () => {
expect(chunks([1, 2], 10)).toEqual([[1, 2]]);
});
test("chunks with exact division returns equal-sized chunks", () => {
expect(chunks([1, 2, 3, 4, 5, 6], 2)).toEqual([
[1, 2],
[3, 4],
[5, 6],
]);
});
test("chunks puts the remainder into a smaller last chunk", () => {
expect(chunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
test("chunks of an empty array is empty", () => {
expect(chunks([], 3)).toEqual([]);
});
});
describe("mainConverter", () => {
test("returns 'File type not supported' for unsupported combination", async () => {
expect(await mainConverter("test.xyz", "xyz", "abc", "out.abc")).toBe(
"File type not supported",
);
expect(output.logs).toContain("No available converter supports converting from xyz to abc.");
});
test("returns 'File type not supported' for an unknown converter name", async () => {
const inputPath = `${uploadsDir}contact.vcf`;
await writeFile(inputPath, vcard("Jane Roe"));
expect(
await mainConverter(inputPath, "vcf", "csv", `${outputDir}contact.csv`, {}, "doesnotexist"),
).toBe("File type not supported");
});
test("auto-discovers converter when not specified", async () => {
const inputPath = `${uploadsDir}test.vcf`;
const outPath = `${outputDir}test.csv`;
await writeFile(inputPath, vcard("Discover Test"));
expect(await mainConverter(inputPath, "vcf", "csv", outPath)).toBe("Done");
expect(await readFile(outPath, "utf-8")).toContain("Discover Test");
});
test("normalizes the input type before discovering a converter", async () => {
const inputPath = `${uploadsDir}test.VCF`;
const outPath = `${outputDir}test.csv`;
await writeFile(inputPath, vcard("Upper Case"));
expect(await mainConverter(inputPath, "VCF", "csv", outPath)).toBe("Done");
});
test("returns 'Failed, check logs' when converter throws", async () => {
expect(
await mainConverter(`${uploadsDir}missing.vcf`, "vcf", "csv", "out.csv", undefined, "vcf"),
).toBe("Failed, check logs");
expect(output.errors.join("\n")).toContain("using vcf");
});
test.each([
["msgconvert", "mail.eml", "eml", "msg"],
["calibre", "news.recipe", "recipe", "epub"],
])(
"returns 'Failed, check logs' when %s refuses the conversion",
async (converterName, fileName, fileType, convertTo) => {
expect(
await mainConverter(
`${uploadsDir}${fileName}`,
fileType,
convertTo,
`${outputDir}out.${convertTo}`,
{},
converterName,
),
).toBe("Failed, check logs");
expect(output.errors.join("\n")).toContain(`using ${converterName}`);
},
);
test("logs the name of the auto-discovered converter", async () => {
const inputPath = `${uploadsDir}named.vcf`;
await writeFile(inputPath, vcard("Named"));
await mainConverter(inputPath, "vcf", "csv", `${outputDir}named.csv`);
expect(output.logs.join("\n")).toContain("successfully using vcf");
});
test("auto-discovery prefers the first registered converter", async () => {
await mainConverter(`${uploadsDir}missing.emf`, "emf", "png", `${outputDir}missing.png`);
expect(output.errors.join("\n")).toContain("using inkscape");
});
});
describe("handleConvert", () => {
test("converts every file and records one row per file", async () => {
const files = ["a.vcf", "b.vcf", "c.vcf"];
for (const fileName of files) {
await writeFile(`${uploadsDir}${fileName}`, vcard(`Contact ${fileName}`));
}
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobCookie("rows"));
expect(rowsFor("rows", "file_name")).toEqual([
{ file_name: "a.vcf", output_file_name: "a.csv", status: "Done" },
{ file_name: "b.vcf", output_file_name: "b.csv", status: "Done" },
{ file_name: "c.vcf", output_file_name: "c.csv", status: "Done" },
]);
for (const fileName of files) {
const csv = await readFile(`${outputDir}${fileName.replace(".vcf", ".csv")}`, "utf-8");
expect(csv).toBe(`Full Name\n"Contact ${fileName}"`);
}
});
test("converts but does not record rows when the job id cookie has no value", async () => {
// without a job id there is no job_id to look for, so look for the file name instead
const fileName = `${JOB_PREFIX}no-job.vcf`;
await writeFile(`${uploadsDir}${fileName}`, vcard("No Job"));
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobCookie());
expect(db.query("SELECT * FROM file_names WHERE file_name = ?").all(fileName)).toEqual([]);
expect(await readFile(`${outputDir}${JOB_PREFIX}no-job.csv`, "utf-8")).toContain("No Job");
});
test("replaces only the last occurrence of the extension", async () => {
await writeFile(`${uploadsDir}vcf.backup.vcf`, vcard("Backup"));
await handleConvert(["vcf.backup.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie("ext"));
expect(rowsFor("ext")).toEqual([
{ file_name: "vcf.backup.vcf", output_file_name: "vcf.backup.csv", status: "Done" },
]);
expect(await readFile(`${outputDir}vcf.backup.csv`, "utf-8")).toContain("Backup");
});
test("keeps the base name of files with an upper-case extension", async () => {
await writeFile(`${uploadsDir}CONTACT.VCF`, vcard("Upper"));
await handleConvert(["CONTACT.VCF"], uploadsDir, outputDir, "csv", "vcf", jobCookie("upper"));
expect(rowsFor("upper")).toEqual([
{ file_name: "CONTACT.VCF", output_file_name: "CONTACT.csv", status: "Done" },
]);
});
test("handles files without extension by appending output extension", async () => {
await writeFile(`${uploadsDir}noextfile`, vcard("No Ext"));
await handleConvert(["noextfile"], uploadsDir, outputDir, "csv", "vcf", jobCookie("noext"));
expect(rowsFor("noext")).toEqual([
{ file_name: "noextfile", output_file_name: "noextfile.csv", status: "Done" },
]);
expect(await readFile(`${outputDir}noextfile.csv`, "utf-8")).toContain("No Ext");
});
test("resolves and records the status when no converter supports the conversion", async () => {
await writeFile(`${uploadsDir}dummy.xyz123`, "dummy content");
await expect(
handleConvert(["dummy.xyz123"], uploadsDir, outputDir, "pdf", "xyz123", jobCookie("none")),
).resolves.toBeUndefined();
expect(rowsFor("none")).toEqual([
{
file_name: "dummy.xyz123",
output_file_name: "dummy.pdf",
status: "File type not supported",
},
]);
});
test("rejects when the output directory is missing, so the caller can log it", async () => {
await writeFile(`${uploadsDir}contact.vcf`, vcard("Lost"));
const missingDir = `${join(testRoot, "does-not-exist")}/`;
await expect(
handleConvert(["contact.vcf"], uploadsDir, missingDir, "csv", "vcf", jobCookie("lost")),
).rejects.toThrow(/ENOENT/);
expect(rowsFor("lost")).toEqual([]);
});
test.each([
["jpeg", "photo.jpg"],
["markdown_strict", "photo.md"],
["glb2", "photo.glb"],
["stlb", "photo.stl"],
])("names the output for target %s with its canonical extension", async (convertTo, expected) => {
// the conversion itself is unsupported, but the output name is still derived and recorded
await handleConvert(["photo.JPG"], uploadsDir, outputDir, convertTo, "none", jobCookie("name"));
expect(rowsFor("name")).toEqual([
{ file_name: "photo.JPG", output_file_name: expected, status: "File type not supported" },
]);
});
test("records numbered sibling outputs (e.g. one file per page) in numeric order", async () => {
// converters such as ImageMagick write doc-1.csv, doc-2.csv, ... instead of doc.csv
for (const fileName of [
"doc-1.csv",
"doc-2.csv",
"doc-10.csv",
// none of these belong to doc.csv
"doc-.csv",
"doc-a.csv",
"doc-1.txt",
"mydoc-1.csv",
"other-1.csv",
]) {
await writeFile(`${outputDir}${fileName}`, "");
}
await writeFile(`${uploadsDir}doc.vcf`, vcard("Pages"));
await handleConvert(["doc.vcf"], uploadsDir, outputDir, "csv", "vcf", jobCookie("pages"));
expect(rowsFor("pages").map((row) => row.output_file_name)).toEqual([
"doc-1.csv",
"doc-2.csv",
"doc-10.csv",
"doc.csv",
]);
});
});