mirror of
https://github.com/C4illin/ConvertX.git
synced 2026-10-28 07:44:21 +01:00
Merge pull request #663 from C4illin/ExtendAndImproveUnitTests
test: extend and improve unit tests
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
[test]
|
||||
preload = ["./tests/preload.ts"]
|
||||
coverage = true
|
||||
coveragePathIgnorePatterns = [
|
||||
"tests/converters/helpers/commonTests.ts"
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/assimp";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test("invokes assimp export with the target format id", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/scene.obj", "obj", "stl", "out/scene.stl", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ cmd: "assimp", args: ["export", "in/scene.obj", "out/scene.stl", "-fstl"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes format ids that are not file extensions through unchanged", async () => {
|
||||
// e.g. glb2 is written to a .glb file, but assimp still needs -fglb2
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/scene.obj", "obj", "glb2", "out/scene.glb", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ cmd: "assimp", args: ["export", "in/scene.obj", "out/scene.glb", "-fglb2"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,39 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/calibre";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test("invokes ebook-convert with input and target path", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/book.epub", "epub", "mobi", "out/book.mobi", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([{ cmd: "ebook-convert", args: ["in/book.epub", "out/book.mobi"] }]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
// the extension is harmless, so only the file type triggers the rejection
|
||||
["recipe file type", "in/news.txt", "recipe"],
|
||||
["downloaded_recipe file type", "in/news.txt", "downloaded_recipe"],
|
||||
["upper-case file type", "in/news.txt", "RECIPE"],
|
||||
["recipe extension with another file type", "in/news.recipe", "txt"],
|
||||
["upper-case downloaded_recipe extension", "in/news.DOWNLOADED_RECIPE", "txt"],
|
||||
])("rejects recipe input (%s) without invoking ebook-convert", async (_, filePath, fileType) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await expect(
|
||||
convert(filePath, fileType, "epub", "out/news.epub", undefined, execFile),
|
||||
).rejects.toThrow("Recipe files are not supported");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("accepts files without an extension", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await expect(
|
||||
convert("in/README", "txt", "epub", "out/README.epub", undefined, execFile),
|
||||
).resolves.toBe("Done");
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -2,25 +2,11 @@ import fs from "fs";
|
||||
import { beforeEach, afterEach, expect, test, describe } from "bun:test";
|
||||
import { buildDaselArgs, convert } from "../../src/converters/dasel";
|
||||
import type { ExecFileFn } from "../../src/converters/types";
|
||||
import { captureConsole } from "./helpers/converters";
|
||||
|
||||
const originalWriteFile = fs.writeFile;
|
||||
|
||||
describe("convert", () => {
|
||||
let mockExecFile: ExecFileFn;
|
||||
|
||||
beforeEach(() => {
|
||||
// mock fs.writeFile
|
||||
// @ts-expect-error: property __promisify__ is missing
|
||||
fs.writeFile = (path, data, cb) => cb(null);
|
||||
// mock execFile
|
||||
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// reset fs.writeFile
|
||||
fs.writeFile = originalWriteFile;
|
||||
});
|
||||
|
||||
describe("buildDaselArgs", () => {
|
||||
test("should build dasel v3 arguments", () => {
|
||||
expect(buildDaselArgs("input.yaml", "yaml", "json")).toEqual([
|
||||
"--var",
|
||||
@@ -31,6 +17,47 @@ describe("convert", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["toml", "yaml"],
|
||||
["json", "toml"],
|
||||
["xml", "json"],
|
||||
["csv", "yaml"],
|
||||
])("should read %s and write %s", (fileType, convertTo) => {
|
||||
expect(buildDaselArgs(`in/data.${fileType}`, fileType, convertTo)).toEqual([
|
||||
"--var",
|
||||
`data=${fileType}:file:in/data.${fileType}`,
|
||||
"--out",
|
||||
convertTo,
|
||||
"$data",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert", () => {
|
||||
let mockExecFile: ExecFileFn;
|
||||
let writes: { path: unknown; data: unknown }[];
|
||||
let output: ReturnType<typeof captureConsole>;
|
||||
|
||||
beforeEach(() => {
|
||||
writes = [];
|
||||
output = captureConsole();
|
||||
// mock fs.writeFile
|
||||
// @ts-expect-error: property __promisify__ is missing
|
||||
fs.writeFile = (path, data, cb) => {
|
||||
writes.push({ path, data });
|
||||
// @ts-expect-error: could not be callable with null
|
||||
cb(null);
|
||||
};
|
||||
// mock execFile
|
||||
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// reset fs.writeFile
|
||||
fs.writeFile = originalWriteFile;
|
||||
output.restore();
|
||||
});
|
||||
|
||||
test("should call dasel with correct arguments and write output", async () => {
|
||||
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
@@ -38,16 +65,6 @@ describe("convert", () => {
|
||||
callback(null, "output-data", "");
|
||||
};
|
||||
|
||||
let writeFileCalled = false;
|
||||
// @ts-expect-error: property __promisify__ is missing
|
||||
fs.writeFile = (path, data, cb) => {
|
||||
writeFileCalled = true;
|
||||
expect(path).toBe("output.json");
|
||||
expect(data).toBe("output-data");
|
||||
// @ts-expect-error: could not be callable with null
|
||||
cb(null);
|
||||
};
|
||||
|
||||
const result = await convert(
|
||||
"input.yaml",
|
||||
"yaml",
|
||||
@@ -59,7 +76,7 @@ describe("convert", () => {
|
||||
|
||||
expect(calledArgs[0]).toBe("dasel");
|
||||
expect(calledArgs[1]).toEqual(["--var", "data=yaml:file:input.yaml", "--out", "json", "$data"]);
|
||||
expect(writeFileCalled).toBe(true);
|
||||
expect(writes).toEqual([{ path: "output.json", data: "output-data" }]);
|
||||
expect(result).toBe("Done");
|
||||
});
|
||||
|
||||
@@ -67,7 +84,7 @@ describe("convert", () => {
|
||||
let stdinEnded = false;
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
callback(null, "output-data", "");
|
||||
return { stdin: { end: () => (stdinEnded = true) } } as ReturnType<ExecFileFn>;
|
||||
return { stdin: { end: () => (stdinEnded = true) } } as unknown as ReturnType<ExecFileFn>;
|
||||
};
|
||||
|
||||
await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile);
|
||||
@@ -75,11 +92,38 @@ describe("convert", () => {
|
||||
expect(stdinEnded).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject if execFile returns an error", async () => {
|
||||
test("should not fail when execFile returns no child process", async () => {
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
callback(null, "output-data", "");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
await expect(
|
||||
convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile),
|
||||
).resolves.toBe("Done");
|
||||
});
|
||||
|
||||
test("should log stderr and still write the output", async () => {
|
||||
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "deprecation warning");
|
||||
|
||||
await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile);
|
||||
|
||||
expect(output.errors).toEqual(["stderr: deprecation warning"]);
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should not log stdout, since it is the converted document", async () => {
|
||||
await convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile);
|
||||
|
||||
expect(output.logs).toEqual([]);
|
||||
});
|
||||
|
||||
test("should reject and not write anything if execFile returns an error", async () => {
|
||||
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "");
|
||||
await expect(
|
||||
convert("input.yaml", "yaml", "json", "output.json", undefined, mockExecFile),
|
||||
).rejects.toMatch(/error: Error: fail/);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("should reject if writeFile fails", async () => {
|
||||
@@ -89,6 +133,6 @@ describe("convert", () => {
|
||||
convert("input.yaml", "yaml", "json", "output.json", undefined, (cmd, args, cb) =>
|
||||
cb(null, "output-data", ""),
|
||||
),
|
||||
).rejects.toMatch(/Failed to write output/);
|
||||
).rejects.toMatch(/Failed to write output: Error: write fail/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,91 +1,35 @@
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/dvisvgm";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
|
||||
let calls: string[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test("convert respects eps filetype", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test.each([
|
||||
{ fileType: "dvi", convertTo: "svg", flags: [] },
|
||||
{ fileType: "xdv", convertTo: "svg", flags: [] },
|
||||
{ fileType: "eps", convertTo: "svg", flags: ["--eps"] },
|
||||
{ fileType: "pdf", convertTo: "svg", flags: ["--pdf"] },
|
||||
{ fileType: "dvi", convertTo: "svgz", flags: ["-z"] },
|
||||
{ fileType: "eps", convertTo: "svgz", flags: ["--eps", "-z"] },
|
||||
{ fileType: "pdf", convertTo: "svgz", flags: ["--pdf", "-z"] },
|
||||
])("converts $fileType to $convertTo", async ({ fileType, convertTo, flags }) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.eps", "eps", "stl", "output.stl", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
const result = await convert(
|
||||
`in/figure.${fileType}`,
|
||||
fileType,
|
||||
convertTo,
|
||||
`out/figure.${convertTo}`,
|
||||
undefined,
|
||||
execFile,
|
||||
);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["--eps", "input.eps", "output.stl"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
});
|
||||
|
||||
test("convert respects pdf filetype", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.pdf", "pdf", "stl", "output.stl", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["--pdf", "input.pdf", "output.stl"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
});
|
||||
|
||||
test("convert respects svgz conversion target type", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.obj", "eps", "svgz", "output.svgz", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["-z", "input.obj", "output.svgz"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cmd: "dvisvgm",
|
||||
args: [...flags, `in/figure.${fileType}`, "-o", `out/figure.${convertTo}`],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
+135
-106
@@ -1,16 +1,19 @@
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import { afterAll, afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/ffmpeg";
|
||||
import type { ExecFileOptions } from "node:child_process";
|
||||
import { captureConsole } from "./helpers/converters";
|
||||
|
||||
let calls: string[][] = [];
|
||||
let commands: string[] = [];
|
||||
let lastOptions: ExecFileOptions | undefined;
|
||||
|
||||
function mockExecFile(
|
||||
_cmd: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) {
|
||||
commands.push(cmd);
|
||||
calls.push(args);
|
||||
lastOptions = options;
|
||||
if (args.includes("fail.mov")) {
|
||||
@@ -20,155 +23,182 @@ function mockExecFile(
|
||||
}
|
||||
}
|
||||
|
||||
// Both variables are read on every call and may be set in the developer's shell
|
||||
// (mise.toml sets FFMPEG_OUTPUT_ARGS), so isolate them and restore them afterwards.
|
||||
const originalEnv = {
|
||||
FFMPEG_ARGS: process.env.FFMPEG_ARGS,
|
||||
FFMPEG_OUTPUT_ARGS: process.env.FFMPEG_OUTPUT_ARGS,
|
||||
};
|
||||
|
||||
let output: ReturnType<typeof captureConsole>;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
commands = [];
|
||||
lastOptions = undefined;
|
||||
delete process.env.FFMPEG_ARGS;
|
||||
delete process.env.FFMPEG_OUTPUT_ARGS;
|
||||
output = captureConsole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
output.restore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const ICO_FILTER = "scale='min(256,iw)':min'(256,ih)':force_original_aspect_ratio=decrease";
|
||||
|
||||
test("converts a normal file", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const result = await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["-i", "in.mp4", "out.avi"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(commands).toEqual(["ffmpeg"]);
|
||||
expect(calls).toEqual([["-i", "in.mp4", "out.avi"]]);
|
||||
expect(output.logs).toEqual(["stdout: Fake stdout"]);
|
||||
});
|
||||
|
||||
test("adds resize for ico output", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const result = await convert("in.png", "png", "ico", "out.ico", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done: resized to 256x256");
|
||||
expect(calls[0]).toEqual(
|
||||
expect.arrayContaining(["-filter:v", expect.stringContaining("scale=")]),
|
||||
);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls[0]).toEqual(["-i", "in.png", "-filter:v", ICO_FILTER, "out.ico"]);
|
||||
});
|
||||
|
||||
test("uses libaom-av1 for av1.mp4", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test.each([
|
||||
["av1.mp4", "libaom-av1"],
|
||||
["av1.mkv", "libaom-av1"],
|
||||
["h264.mp4", "libx264"],
|
||||
["h264.mkv", "libx264"],
|
||||
["h265.mp4", "libx265"],
|
||||
["h265.mkv", "libx265"],
|
||||
["h266.mp4", "libx266"],
|
||||
["h266.mkv", "libx266"],
|
||||
])("uses the right video codec for %s", async (convertTo, codec) => {
|
||||
const result = await convert("in.mkv", "mkv", convertTo, "out.video", undefined, mockExecFile);
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
await convert("in.mkv", "mkv", "av1.mp4", "out.mp4", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libaom-av1"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(["-i", "in.mkv", "-c:v", codec, "out.video"]);
|
||||
});
|
||||
|
||||
test("uses libx264 for h264.mp4", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test.each(["m4a", "mp3", "flac", "ogg"])("keeps embedded cover art for %s output", async (ext) => {
|
||||
await convert("in.wav", "wav", ext, `out.${ext}`, undefined, mockExecFile);
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx264"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls[0]).toEqual([
|
||||
"-i",
|
||||
"in.wav",
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map",
|
||||
"0:v:disp:attached_pic?",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-disposition:v",
|
||||
"attached_pic",
|
||||
`out.${ext}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses libx265 for h265.mp4", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test.each(["wav", "aac"])("drops video streams for %s output", async (ext) => {
|
||||
await convert("in.mp3", "mp3", ext, `out.${ext}`, undefined, mockExecFile);
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx265"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls[0]).toEqual(["-i", "in.mp3", "-vn", `out.${ext}`]);
|
||||
});
|
||||
|
||||
test("uses libx266 for h266.mp4", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test("places the cover art args after FFMPEG_OUTPUT_ARGS", async () => {
|
||||
process.env.FFMPEG_OUTPUT_ARGS = "-b:a 192k";
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
await convert("in.wav", "wav", "mp3", "out.mp3", undefined, mockExecFile);
|
||||
|
||||
await convert("in.mkv", "mkv", "h266.mp4", "out.mp4", undefined, mockExecFile);
|
||||
expect(calls[0]?.slice(0, 5)).toEqual(["-i", "in.wav", "-b:a", "192k", "-map"]);
|
||||
});
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
test("does not force a codec for an unknown codec prefix", async () => {
|
||||
await convert("in.mkv", "mkv", "vp9.webm", "out.webm", undefined, mockExecFile);
|
||||
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx266"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls[0]).toEqual(["-i", "in.mkv", "out.webm"]);
|
||||
});
|
||||
|
||||
test("respects FFMPEG_ARGS", async () => {
|
||||
process.env.FFMPEG_ARGS = "-hide_banner -y";
|
||||
|
||||
const originalConsoleLog = console.log;
|
||||
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
expect(calls[0]).toEqual(["-hide_banner", "-y", "-i", "input.mov", "output.mp4"]);
|
||||
});
|
||||
|
||||
test("places FFMPEG_OUTPUT_ARGS after the input", async () => {
|
||||
process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast -threads 2";
|
||||
|
||||
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
expect(calls[0]).toEqual([
|
||||
"-i",
|
||||
"input.mov",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-threads",
|
||||
"2",
|
||||
"output.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
expect(calls[0]?.slice(0, 2)).toEqual(["-hide_banner", "-y"]);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
test("orders global args, input, output args, codec args and target", async () => {
|
||||
process.env.FFMPEG_ARGS = "-hwaccel vaapi";
|
||||
process.env.FFMPEG_OUTPUT_ARGS = "-preset veryfast";
|
||||
|
||||
await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile);
|
||||
|
||||
expect(calls[0]).toEqual([
|
||||
"-hwaccel",
|
||||
"vaapi",
|
||||
"-i",
|
||||
"in.mkv",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-c:v",
|
||||
"libx265",
|
||||
"out.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("splits FFMPEG_ARGS on any whitespace", async () => {
|
||||
process.env.FFMPEG_ARGS = "-hide_banner\t-y -nostdin";
|
||||
|
||||
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
|
||||
|
||||
expect(calls[0]?.slice(0, 3)).toEqual(["-hide_banner", "-y", "-nostdin"]);
|
||||
});
|
||||
|
||||
// BUG: split(/\s+/) keeps empty strings for leading/trailing whitespace
|
||||
// (" -y ".split(/\s+/) is ["", "-y", ""]), so ffmpeg receives "" as an argument
|
||||
// and fails. Remove `.failing` once src/converters/ffmpeg.ts drops empty entries.
|
||||
test.failing("ignores leading and trailing whitespace in FFMPEG_ARGS", async () => {
|
||||
process.env.FFMPEG_ARGS = " -y ";
|
||||
process.env.FFMPEG_OUTPUT_ARGS = " -preset veryfast ";
|
||||
|
||||
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile);
|
||||
|
||||
expect(calls[0]).toEqual(["-y", "-i", "input.mov", "-preset", "veryfast", "output.mp4"]);
|
||||
});
|
||||
|
||||
test("fails on exec error", async () => {
|
||||
const originalConsoleError = console.error;
|
||||
await expect(
|
||||
convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile),
|
||||
).rejects.toBe("error: Error: mock failure");
|
||||
|
||||
let loggedMessage = "";
|
||||
console.error = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
expect(convert("fail.mov", "mov", "mp4", "output.mp4", undefined, mockExecFile)).rejects.toThrow(
|
||||
"mock failure",
|
||||
);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
|
||||
expect(loggedMessage).toBe("stderr: Fake stderr: fail");
|
||||
expect(output.errors).toEqual(["stderr: Fake stderr: fail"]);
|
||||
});
|
||||
|
||||
test("logs stderr when execFile returns only stderr and no error", async () => {
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.error = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
// Mock execFile to call back with no error, no stdout, but with stderr
|
||||
const mockExecFileStderrOnly = (
|
||||
_cmd: string,
|
||||
@@ -181,9 +211,8 @@ test("logs stderr when execFile returns only stderr and no error", async () => {
|
||||
|
||||
await convert("input.mov", "mov", "mp4", "output.mp4", undefined, mockExecFileStderrOnly);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
|
||||
expect(loggedMessage).toBe("stderr: Only stderr output");
|
||||
expect(output.errors).toEqual(["stderr: Only stderr output"]);
|
||||
expect(output.logs).toEqual([]);
|
||||
});
|
||||
|
||||
test("passes a maxBuffer above the 1 MB default so long conversions don't overflow stderr (#565)", async () => {
|
||||
|
||||
@@ -1,32 +1,17 @@
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/graphicsmagick";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
|
||||
let calls: string[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test("convert applies EXIF auto-orient", async () => {
|
||||
let command = "";
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
command = cmd;
|
||||
calls.push(args);
|
||||
callback(null, "", "");
|
||||
};
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, mockExecFile);
|
||||
const result = await convert("input.jpg", "jpg", "pdf", "output.pdf", undefined, execFile);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(command).toBe("gm");
|
||||
expect(calls[0]).toEqual(["convert", "input.jpg", "-auto-orient", "output.pdf"]);
|
||||
expect(calls).toEqual([
|
||||
{ cmd: "gm", args: ["convert", "input.jpg", "-auto-orient", "output.pdf"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from "bun:test";
|
||||
import { ConvertFnWithExecFile } from "../../../src/converters/types";
|
||||
import {
|
||||
runConvertFailTest,
|
||||
runConvertLogsNothingWithoutOutput,
|
||||
runConvertLogsStderror,
|
||||
runConvertLogsStderrorAndStdout,
|
||||
runConvertSuccessTest,
|
||||
@@ -23,4 +24,8 @@ export function runCommonTests(convert: ConvertFnWithExecFile) {
|
||||
test("convert logs both stderr and stdout when present", async () => {
|
||||
await runConvertLogsStderrorAndStdout(convert);
|
||||
});
|
||||
|
||||
test("convert logs nothing when stdout and stderr are empty", async () => {
|
||||
await runConvertLogsNothingWithoutOutput(convert);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,121 +1,129 @@
|
||||
import { expect } from "bun:test";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types";
|
||||
import type { ConvertFnWithExecFile, ExecFileFn } from "../../../src/converters/types";
|
||||
|
||||
type ExecFileCall = { cmd: string; args: string[] };
|
||||
|
||||
/**
|
||||
* Creates an execFile replacement that records every invocation and answers with the
|
||||
* given error/stdout/stderr, so converter tests never spawn real binaries.
|
||||
*/
|
||||
export function createMockExecFile({
|
||||
error = null,
|
||||
stdout = "",
|
||||
stderr = "",
|
||||
}: { error?: Error | null; stdout?: string; stderr?: string } = {}) {
|
||||
const calls: ExecFileCall[] = [];
|
||||
const execFile: ExecFileFn = (cmd, args, callback) => {
|
||||
calls.push({ cmd, args });
|
||||
callback(error, stdout, stderr);
|
||||
};
|
||||
return { execFile, calls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects console.log/error/warn into arrays so tests can assert on them without
|
||||
* printing to the terminal. Always call restore() afterwards (e.g. in afterEach).
|
||||
*/
|
||||
export function captureConsole() {
|
||||
const original = { log: console.log, error: console.error, warn: console.warn };
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const sink =
|
||||
(target: string[]): Console["log"] =>
|
||||
(...data) => {
|
||||
target.push(data.map(String).join(" "));
|
||||
};
|
||||
|
||||
console.log = sink(logs);
|
||||
console.error = sink(errors);
|
||||
console.warn = sink(warnings);
|
||||
|
||||
return {
|
||||
logs,
|
||||
errors,
|
||||
warnings,
|
||||
restore: () => {
|
||||
console.log = original.log;
|
||||
console.error = original.error;
|
||||
console.warn = original.warn;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runConvertSuccessTest(convertFn: ConvertFnWithExecFile) {
|
||||
const originalConsoleLog = console.log;
|
||||
const output = captureConsole();
|
||||
const { execFile, calls } = createMockExecFile({ stdout: "Fake stdout" });
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
try {
|
||||
const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, execFile);
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(result).toBe("Done");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(output.logs).toEqual(["stdout: Fake stdout"]);
|
||||
expect(output.errors).toEqual([]);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConvertFailTest(convertFn: ConvertFnWithExecFile) {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(new Error("Test error"), "", "");
|
||||
};
|
||||
const failures: [error: unknown, expected: RegExp][] = [
|
||||
[new Error("Test error"), /error: Error: Test error/],
|
||||
// non-standard error object lacking a 'message' property
|
||||
[{ notMessage: true }, /error:/i],
|
||||
// non-object error (e.g., a string)
|
||||
["string error", /error:/i],
|
||||
];
|
||||
|
||||
expect(
|
||||
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile),
|
||||
).rejects.toMatch(/error: Error: Test error/);
|
||||
for (const [error, expected] of failures) {
|
||||
const { execFile } = createMockExecFile({ error: error as Error });
|
||||
|
||||
// Test with error object lacking 'message' property
|
||||
const mockExecFileNoMessage: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
// Simulate a non-standard error object
|
||||
callback({ notMessage: true } as unknown as ExecFileException, "", "");
|
||||
};
|
||||
|
||||
expect(
|
||||
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileNoMessage),
|
||||
).rejects.toMatch(/error:/i);
|
||||
|
||||
// Test with a non-object error (e.g., a string)
|
||||
const mockExecFileStringError: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback("string error" as unknown as ExecFileException, "", "");
|
||||
};
|
||||
|
||||
expect(
|
||||
convertFn("input.obj", "obj", "stl", "output.stl", undefined, mockExecFileStringError),
|
||||
).rejects.toMatch(/error:/i);
|
||||
await expect(
|
||||
convertFn("input.obj", "obj", "stl", "output.stl", undefined, execFile),
|
||||
).rejects.toMatch(expected);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConvertLogsStderror(convertFn: ConvertFnWithExecFile) {
|
||||
const originalConsoleError = console.error;
|
||||
const output = captureConsole();
|
||||
const { execFile } = createMockExecFile({ stderr: "Fake stderr" });
|
||||
|
||||
let loggedMessage = "";
|
||||
console.error = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
try {
|
||||
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile);
|
||||
|
||||
const mockExecFile = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(null, "", "Fake stderr");
|
||||
};
|
||||
|
||||
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
|
||||
expect(loggedMessage).toBe("stderr: Fake stderr");
|
||||
expect(output.errors).toEqual(["stderr: Fake stderr"]);
|
||||
expect(output.logs).toEqual([]);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConvertLogsStderrorAndStdout(convertFn: ConvertFnWithExecFile) {
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleLog = console.log;
|
||||
const output = captureConsole();
|
||||
const { execFile } = createMockExecFile({ stdout: "Fake stdout", stderr: "Fake stderr" });
|
||||
|
||||
let loggedError = "";
|
||||
let loggedMessage = "";
|
||||
console.error = (msg) => {
|
||||
loggedError = msg;
|
||||
};
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
try {
|
||||
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile);
|
||||
|
||||
const mockExecFile = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(null, "Fake stdout", "Fake stderr");
|
||||
};
|
||||
|
||||
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, mockExecFile);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(loggedError).toBe("stderr: Fake stderr");
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(output.errors).toEqual(["stderr: Fake stderr"]);
|
||||
expect(output.logs).toEqual(["stdout: Fake stdout"]);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConvertLogsNothingWithoutOutput(convertFn: ConvertFnWithExecFile) {
|
||||
const output = captureConsole();
|
||||
const { execFile } = createMockExecFile();
|
||||
|
||||
try {
|
||||
await convertFn("file.obj", "obj", "stl", "out.stl", undefined, execFile);
|
||||
|
||||
expect(output.logs).toEqual([]);
|
||||
expect(output.errors).toEqual([]);
|
||||
} finally {
|
||||
output.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +1,86 @@
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/imagemagick";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
|
||||
let calls: string[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test("convert respects ico conversion target type", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
const ICO_ARGS = ["-define", "icon:auto-resize=256,128,64,48,32,16", "-background", "none"];
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
test.each([
|
||||
{
|
||||
name: "applies only EXIF auto-orient for a plain conversion",
|
||||
fileType: "jpg",
|
||||
convertTo: "png",
|
||||
inputArgs: [],
|
||||
outputArgs: ["-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "does not rasterize svg at high density unless the target is ico",
|
||||
fileType: "svg",
|
||||
convertTo: "png",
|
||||
inputArgs: [],
|
||||
outputArgs: ["-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "auto-resizes ico output with a transparent background",
|
||||
fileType: "eps",
|
||||
convertTo: "ico",
|
||||
inputArgs: [],
|
||||
outputArgs: [...ICO_ARGS, "-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "rasterizes svg at high density for ico output",
|
||||
fileType: "svg",
|
||||
convertTo: "ico",
|
||||
inputArgs: ["-background", "none", "-density", "512"],
|
||||
outputArgs: [...ICO_ARGS, "-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "rasterizes pdf input at 300 dpi",
|
||||
fileType: "pdf",
|
||||
convertTo: "png",
|
||||
inputArgs: ["-density", "300"],
|
||||
outputArgs: ["-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "combines pdf density with ico options",
|
||||
fileType: "pdf",
|
||||
convertTo: "ico",
|
||||
inputArgs: ["-density", "300"],
|
||||
outputArgs: [...ICO_ARGS, "-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "disables the emf delegate and flattens onto white",
|
||||
fileType: "emf",
|
||||
convertTo: "png",
|
||||
inputArgs: ["-define", "emf:delegate=false", "-density", "300"],
|
||||
outputArgs: ["-background", "white", "-alpha", "remove", "-auto-orient"],
|
||||
},
|
||||
{
|
||||
name: "combines emf handling with ico options",
|
||||
fileType: "emf",
|
||||
convertTo: "ico",
|
||||
inputArgs: ["-define", "emf:delegate=false", "-density", "300"],
|
||||
outputArgs: [...ICO_ARGS, "-background", "white", "-alpha", "remove", "-auto-orient"],
|
||||
},
|
||||
])("$name ($fileType -> $convertTo)", async ({ fileType, convertTo, inputArgs, outputArgs }) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.obj", "eps", "ico", "output.ico", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-define",
|
||||
"icon:auto-resize=256,128,64,48,32,16",
|
||||
"-background",
|
||||
"none",
|
||||
"input.obj",
|
||||
"output.ico",
|
||||
]),
|
||||
const result = await convert(
|
||||
`in/image.${fileType}`,
|
||||
fileType,
|
||||
convertTo,
|
||||
`out/image.${convertTo}`,
|
||||
undefined,
|
||||
execFile,
|
||||
);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
});
|
||||
|
||||
test("convert respects ico conversion target type with svg as input filetype", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.svg", "svg", "ico", "output.ico", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-define",
|
||||
"icon:auto-resize=256,128,64,48,32,16",
|
||||
"-background",
|
||||
"none",
|
||||
"-density",
|
||||
"512",
|
||||
"input.svg",
|
||||
"output.ico",
|
||||
]),
|
||||
);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
});
|
||||
|
||||
test("convert respects ico conversion target type with emf as input filetype", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.emf", "emf", "ico", "output.ico", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-define",
|
||||
"icon:auto-resize=256,128,64,48,32,16",
|
||||
"-background",
|
||||
"none",
|
||||
"emf:delegate=false",
|
||||
"-density",
|
||||
"300",
|
||||
"white",
|
||||
"-alpha",
|
||||
"remove",
|
||||
"input.emf",
|
||||
"output.ico",
|
||||
]),
|
||||
);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
});
|
||||
|
||||
test("convert respects emf as input filetype", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.emf", "emf", "obj", "output.obj", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-define",
|
||||
"emf:delegate=false",
|
||||
"-density",
|
||||
"300",
|
||||
"-background",
|
||||
"white",
|
||||
"-alpha",
|
||||
"remove",
|
||||
"input.emf",
|
||||
"output.obj",
|
||||
]),
|
||||
);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
});
|
||||
|
||||
test("convert applies EXIF auto-orient", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.jpg", "jpg", "png", "output.png", undefined, mockExecFile);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["input.jpg", "-auto-orient", "output.png"]));
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cmd: "magick",
|
||||
args: [...inputArgs, `in/image.${fileType}`, ...outputArgs, `out/image.${convertTo}`],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/inkscape";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test("invokes inkscape with input and -o target path", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/drawing.svg", "svg", "png", "out/drawing.png", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([{ cmd: "inkscape", args: ["in/drawing.svg", "-o", "out/drawing.png"] }]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/libheif";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test("invokes heif-convert with input and target path", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/photo.heic", "heic", "jpeg", "out/photo.jpg", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([{ cmd: "heif-convert", args: ["in/photo.heic", "out/photo.jpg"] }]);
|
||||
});
|
||||
|
||||
@@ -1,91 +1,40 @@
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/libjxl";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
|
||||
let command: string = "";
|
||||
|
||||
beforeEach(() => {
|
||||
command = "";
|
||||
});
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test("convert uses djxl with input filetype being jxl", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
command = _cmd;
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
const result = await convert("input.jxl", "jxl", "png", "output.png", undefined, execFile);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(command).toEqual("djxl");
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls).toEqual([{ cmd: "djxl", args: ["input.jxl", "output.png"] }]);
|
||||
});
|
||||
|
||||
test("convert uses cjxl with output filetype being jxl", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
command = _cmd;
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
const result = await convert("input.png", "png", "jxl", "output.jxl", undefined, execFile);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(command).toEqual("cjxl");
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls).toEqual([{ cmd: "cjxl", args: ["input.png", "output.jxl"] }]);
|
||||
});
|
||||
|
||||
test("convert prefers cjxl when both input and output filetype are jxl", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("input.jxl", "jxl", "jxl", "output.jxl", undefined, execFile);
|
||||
|
||||
expect(calls[0]?.cmd).toBe("cjxl");
|
||||
});
|
||||
|
||||
test("convert uses empty string as command with neither input nor output filetype being jxl", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
await convert("input.png", "png", "jpg", "output.jpg", undefined, execFile);
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
command = _cmd;
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.png", "png", "jpg", "output.jpg", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(command).toEqual("");
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls[0]?.cmd).toBe("");
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ test("invokes soffice with --headless and outdir derived from targetPath", async
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async () => {
|
||||
test("uses writer_pdf_import and a text outFilter for pdf -> txt", async () => {
|
||||
await convert("in.pdf", "pdf", "txt", "out/out.txt", undefined, mockExecFile);
|
||||
|
||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||
@@ -89,18 +89,12 @@ test("uses only outFilter when input has no filter (e.g., pdf -> txt)", async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses only infilter when convertTo has no out filter (e.g., docx -> pdf)", async () => {
|
||||
test("uses no filters at all when converting to pdf (e.g., docx -> pdf)", async () => {
|
||||
await convert("in.docx", "docx", "pdf", "out/out.pdf", undefined, mockExecFile);
|
||||
|
||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||
|
||||
// If docx has an infilter, it should be present
|
||||
expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.docx"]);
|
||||
|
||||
const i = args.indexOf("--convert-to");
|
||||
expect(i).toBeGreaterThanOrEqual(0);
|
||||
expect(args[i + 1]).toBe("pdf");
|
||||
expect(args.slice(-2)).toEqual(["out", "in.docx"]);
|
||||
});
|
||||
|
||||
test("does not force an infilter for wps (Microsoft Works, not MS Word 97)", async () => {
|
||||
@@ -203,26 +197,94 @@ test("logs both stdout and stderr when both are present", async () => {
|
||||
test("logs stderr on exec error as well", async () => {
|
||||
behavior = { kind: "error", message: "boom", stderr: "EPIPE" };
|
||||
|
||||
expect(convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile)).rejects.toMatch(
|
||||
/error: Error: boom/,
|
||||
);
|
||||
await expect(
|
||||
convert("in.txt", "txt", "docx", "out/out.docx", undefined, mockExecFile),
|
||||
).rejects.toMatch(/error: Error: boom/);
|
||||
|
||||
// The callback still provided stderr; your implementation logs it before settling
|
||||
expect(errors).toContain("stderr: EPIPE");
|
||||
});
|
||||
|
||||
// --- calc filter branch (test-only exports) ---------------------------------
|
||||
test("getFilters returns calc mapping when present", () => {
|
||||
// temporarily add entries to calc mapping
|
||||
filters.calc["testfoo"] = "TestFooFilter";
|
||||
filters.calc["testbar"] = "TestBarFilter";
|
||||
// --- spreadsheet (calc) conversions ------------------------------------------
|
||||
test.each([
|
||||
{
|
||||
fileType: "csv",
|
||||
convertTo: "xlsx",
|
||||
infilter: "Text - txt - csv (StarCalc)",
|
||||
convertToArg: "xlsx:Calc MS Excel 2007 XML",
|
||||
},
|
||||
{
|
||||
fileType: "ods",
|
||||
convertTo: "xlsx",
|
||||
infilter: "calc8",
|
||||
convertToArg: "xlsx:Calc MS Excel 2007 XML",
|
||||
},
|
||||
{
|
||||
fileType: "xlsx",
|
||||
convertTo: "ods",
|
||||
infilter: "Calc MS Excel 2007 XML",
|
||||
convertToArg: "ods:calc8",
|
||||
},
|
||||
{
|
||||
fileType: "xls",
|
||||
convertTo: "xlsm",
|
||||
infilter: "MS Excel 97",
|
||||
convertToArg: "xlsm:Calc MS Excel 2007 XML VBA",
|
||||
},
|
||||
])(
|
||||
"uses calc filters for $fileType -> $convertTo",
|
||||
async ({ fileType, convertTo, infilter, convertToArg }) => {
|
||||
await convert(
|
||||
`in.${fileType}`,
|
||||
fileType,
|
||||
convertTo,
|
||||
`out/out.${convertTo}`,
|
||||
undefined,
|
||||
mockExecFile,
|
||||
);
|
||||
|
||||
try {
|
||||
const res = getFilters("testfoo", "testbar");
|
||||
expect(res).toEqual(["TestFooFilter", "TestBarFilter"]);
|
||||
} finally {
|
||||
// cleanup
|
||||
delete filters.calc["testfoo"];
|
||||
delete filters.calc["testbar"];
|
||||
}
|
||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||
|
||||
expect(args).toEqual([
|
||||
"--headless",
|
||||
`--infilter=${infilter}`,
|
||||
"--convert-to",
|
||||
convertToArg,
|
||||
"--outdir",
|
||||
"out",
|
||||
`in.${fileType}`,
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("uses no filters for a spreadsheet converted to pdf", async () => {
|
||||
await convert("in.xlsx", "xlsx", "pdf", "out/out.pdf", undefined, mockExecFile);
|
||||
|
||||
const { args } = requireDefined(calls[0], "Expected at least one execFile call");
|
||||
|
||||
expect(args).toEqual(["--headless", "--convert-to", "pdf", "--outdir", "out", "in.xlsx"]);
|
||||
});
|
||||
|
||||
// --- getFilters (test-only export) -------------------------------------------
|
||||
test("getFilters returns text filters when both formats are text formats", () => {
|
||||
expect(getFilters("doc", "odt")).toEqual(["MS Word 97", "writer8"]);
|
||||
});
|
||||
|
||||
test("getFilters falls back to calc filters when the source is not a text format", () => {
|
||||
expect(getFilters("xls", "csv")).toEqual(["MS Excel 97", "Text - txt - csv (StarCalc)"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["docx", "xlsx"],
|
||||
["xlsx", "docx"],
|
||||
["unknown", "odt"],
|
||||
["docx", "unknown"],
|
||||
])("getFilters returns no filters for unrelated formats (%s -> %s)", (fileType, convertTo) => {
|
||||
expect(getFilters(fileType, convertTo)).toEqual([null, null]);
|
||||
});
|
||||
|
||||
test("getFilters keeps wps deliberately unmapped instead of falling through to calc", () => {
|
||||
expect("wps" in filters.text).toBe(true);
|
||||
expect(filters.text.wps).toBeNull();
|
||||
expect(getFilters("wps", "docx")).toEqual([null, "MS Word 2007 XML"]);
|
||||
});
|
||||
|
||||
+363
-351
@@ -1,393 +1,405 @@
|
||||
// Set isolated DB path before import to protect production data
|
||||
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 { mkdirSync, rmSync, existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const testDbDir = join(tmpdir(), "converter-test-db");
|
||||
rmSync(testDbDir, { recursive: true, force: true });
|
||||
mkdirSync(testDbDir, { recursive: true });
|
||||
process.env.DB_PATH = join(testDbDir, "test.sqlite");
|
||||
|
||||
import { test, expect, afterAll, afterEach } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import type { Cookie } from "elysia";
|
||||
import { writeFile, mkdir, rm, readFile } from "fs/promises";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { properties as libreofficeProperties } from "../../src/converters/libreoffice";
|
||||
import { captureConsole } from "./helpers/converters";
|
||||
|
||||
// dynamic import ensures that the module is loaded after the environment variable is set
|
||||
const converterModule = await import("../../src/converters/main");
|
||||
const { getPossibleTargets, getAllTargets, getAllInputs, handleConvert, mainConverter, chunks } =
|
||||
converterModule;
|
||||
|
||||
// Isolated test database: avoids mutation of ./data/mydb.sqlite
|
||||
const dbPath = process.env.DB_PATH ?? join(testDbDir, "test.sqlite");
|
||||
const dbDir = dirname(resolve(dbPath));
|
||||
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
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.");
|
||||
}
|
||||
|
||||
const testDb = new Database(dbPath, { create: true });
|
||||
// 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();
|
||||
});
|
||||
|
||||
// cleans up the table before each test for real isolation (even with parallel tests)
|
||||
afterEach(() => {
|
||||
try {
|
||||
testDb.query("DELETE FROM file_names");
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
// ignore only the expected error for missing tables; rethrow real issues
|
||||
if (!err.message.includes("no such table")) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.restore();
|
||||
db.query("DELETE FROM file_names WHERE job_id LIKE ?").run(`${JOB_PREFIX}%`);
|
||||
});
|
||||
|
||||
// closes the DB and removes the temp directory after all tests finish
|
||||
// removes the temp directory with the uploads and outputs after all tests finish
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
rmSync(testDbDir, { recursive: true, force: true });
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Mock factory for jobId Cookie to avoid repeated `as Cookie` casts
|
||||
function createMockJobId(value: string): Cookie<string | undefined> {
|
||||
return { value } as Cookie<string | undefined>;
|
||||
}
|
||||
describe("converter registry", () => {
|
||||
test("getPossibleTargets lists the targets of every converter for an input type", () => {
|
||||
expect(getPossibleTargets("vcf")).toEqual({ vcf: ["csv"] });
|
||||
});
|
||||
|
||||
test("getPossibleTargets, getAllTargets and getAllInputs include vcf/csv mapping", () => {
|
||||
const possible = getPossibleTargets("vcf");
|
||||
// should have an entry for the vcf converter
|
||||
expect(Object.keys(possible).length).toBeGreaterThan(0);
|
||||
// getAllTargets should include 'vcf' converter target csv
|
||||
const allTargets = getAllTargets();
|
||||
// Be defensive: allTargets.vcf may be undefined in some builds
|
||||
expect(allTargets).toHaveProperty("vcf");
|
||||
expect(Array.isArray(allTargets.vcf)).toBe(true);
|
||||
expect((allTargets.vcf ?? []).includes("csv")).toBe(true);
|
||||
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"));
|
||||
});
|
||||
|
||||
const allInputs = getAllInputs("vcf");
|
||||
expect(allInputs.includes("vcf")).toBe(true);
|
||||
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",
|
||||
"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([]);
|
||||
});
|
||||
|
||||
// BUG: building allTargets/allInputs stores the first category's array by
|
||||
// reference and then pushes the other categories into it. That mutates the
|
||||
// converters' own properties, so e.g. LibreOffice offers docx -> xlsx and
|
||||
// libjxl offers jxl -> jxl. Remove `.failing` once main.ts copies the arrays.
|
||||
test.failing("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");
|
||||
});
|
||||
});
|
||||
|
||||
test("handleConvert uses vcf converter to transform .vcf to .csv and records DB entry", async () => {
|
||||
const uploadsDir = "./data/uploads/test-main/";
|
||||
const outputDir = "./data/output/test-main/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
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]]);
|
||||
});
|
||||
|
||||
const fileName = "contact.vcf";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
const sampleVcf = `BEGIN:VCARD
|
||||
FN:John Doe
|
||||
N:Doe;John;;;
|
||||
TEL;TYPE=CELL:123456789
|
||||
EMAIL:john@example.com
|
||||
ORG:Example Inc;
|
||||
END:VCARD
|
||||
`;
|
||||
await writeFile(inputPath, sampleVcf, "utf-8");
|
||||
test("chunks with negative size returns entire array as single chunk", () => {
|
||||
expect(chunks(["a", "b", "c"], -1)).toEqual([["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
const jobId = createMockJobId("4242");
|
||||
test("chunks with size larger than array returns single chunk", () => {
|
||||
expect(chunks([1, 2], 10)).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
|
||||
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],
|
||||
]);
|
||||
});
|
||||
|
||||
const outPath = `${outputDir}contact.csv`;
|
||||
const out = await readFile(outPath, "utf-8");
|
||||
test("chunks puts the remainder into a smaller last chunk", () => {
|
||||
expect(chunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
// CSV should contain headers and the name
|
||||
expect(out.includes("Full Name")).toBe(true);
|
||||
expect(out.includes("John Doe")).toBe(true);
|
||||
|
||||
// cleanup
|
||||
await rm(inputPath);
|
||||
await rm(outPath);
|
||||
test("chunks of an empty array is empty", () => {
|
||||
expect(chunks([], 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("handleConvert with unsupported format does not throw", async () => {
|
||||
const uploadsDir = "./data/uploads/test-unsupported/";
|
||||
const outputDir = "./data/output/test-unsupported/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
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.");
|
||||
});
|
||||
|
||||
// Create a dummy file with unsupported extension
|
||||
const fileName = "dummy.xyz123";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
await writeFile(inputPath, "dummy content", "utf-8");
|
||||
test("returns 'File type not supported' for an unknown converter name", async () => {
|
||||
const inputPath = `${uploadsDir}contact.vcf`;
|
||||
await writeFile(inputPath, vcard("Jane Roe"));
|
||||
|
||||
// Try to convert unsupported format
|
||||
const jobId = createMockJobId("unsupported-test");
|
||||
// This should not throw, just log that no converter is available
|
||||
await expect(
|
||||
handleConvert([fileName], uploadsDir, outputDir, "pdf", "xyz123", jobId),
|
||||
).resolves.toBeUndefined();
|
||||
expect(
|
||||
await mainConverter(inputPath, "vcf", "csv", `${outputDir}contact.csv`, {}, "doesnotexist"),
|
||||
).toBe("File type not supported");
|
||||
});
|
||||
|
||||
await rm(inputPath);
|
||||
});
|
||||
test("auto-discovers converter when not specified", async () => {
|
||||
const inputPath = `${uploadsDir}test.vcf`;
|
||||
const outPath = `${outputDir}test.csv`;
|
||||
await writeFile(inputPath, vcard("Discover Test"));
|
||||
|
||||
test("handleConvert with multiple files processes them", async () => {
|
||||
const uploadsDir = "./data/uploads/test-multi/";
|
||||
const outputDir = "./data/output/test-multi/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
expect(await mainConverter(inputPath, "vcf", "csv", outPath)).toBe("Done");
|
||||
expect(await readFile(outPath, "utf-8")).toContain("Discover Test");
|
||||
});
|
||||
|
||||
// Create multiple vcf files
|
||||
const files = ["contact1.vcf", "contact2.vcf", "contact3.vcf"];
|
||||
const baseVcf = `BEGIN:VCARD
|
||||
FN:Test Contact
|
||||
N:Contact;Test;;;
|
||||
END:VCARD
|
||||
`;
|
||||
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"));
|
||||
|
||||
for (const fileName of files) {
|
||||
await writeFile(`${uploadsDir}${fileName}`, baseVcf, "utf-8");
|
||||
}
|
||||
expect(await mainConverter(inputPath, "VCF", "csv", outPath)).toBe("Done");
|
||||
});
|
||||
|
||||
const jobId = createMockJobId("multi-test");
|
||||
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId);
|
||||
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");
|
||||
});
|
||||
|
||||
// Verify all output files were created
|
||||
for (const fileName of files) {
|
||||
const outputFileName = fileName.replace(".vcf", ".csv");
|
||||
const outPath = `${outputDir}${outputFileName}`;
|
||||
expect(await readFile(outPath, "utf-8")).toBeTruthy();
|
||||
await rm(outPath);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for (const fileName of files) {
|
||||
await rm(`${uploadsDir}${fileName}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("handleConvert with explicit converter skips discovery", async () => {
|
||||
const uploadsDir = "./data/uploads/test-explicit/";
|
||||
const outputDir = "./data/output/test-explicit/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const fileName = "test.vcf";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
const sampleVcf = `BEGIN:VCARD
|
||||
FN:Explicit Test
|
||||
N:Test;Explicit;;;
|
||||
END:VCARD
|
||||
`;
|
||||
await writeFile(inputPath, sampleVcf, "utf-8");
|
||||
|
||||
// Use explicit vcf converter (avoids discovery loop)
|
||||
const jobId = createMockJobId("explicit-test");
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
|
||||
|
||||
const outPath = `${outputDir}test.csv`;
|
||||
expect(await readFile(outPath, "utf-8")).toBeTruthy();
|
||||
|
||||
await rm(inputPath);
|
||||
await rm(outPath);
|
||||
});
|
||||
|
||||
test("handleConvert with dvisvgm discovers converter from category keys", async () => {
|
||||
const uploadsDir = "./data/uploads/test-dvisvgm/";
|
||||
const outputDir = "./data/output/test-dvisvgm/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Create a simple DVI-like file (dvisvgm would normally handle .dvi files)
|
||||
// For testing we'll use a latex file and ask for svg output, which should fail gracefully
|
||||
const fileName = "test.tex";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
await writeFile(
|
||||
inputPath,
|
||||
"\\documentclass{article}\\begin{document}test\\end{document}",
|
||||
"utf-8",
|
||||
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}`);
|
||||
},
|
||||
);
|
||||
|
||||
// This tests that converter discovery iterates through all converters
|
||||
// and tries to find one matching tex -> svg
|
||||
const jobId = createMockJobId("dvi-test");
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "svg", "tex", jobId);
|
||||
// BUG: discovery loops with `for (converterName in properties)` and only breaks
|
||||
// the inner loop, so the variable always ends on the last registered converter.
|
||||
// Remove `.failing` once mainConverter stops at the first match.
|
||||
test.failing("logs the name of the auto-discovered converter", async () => {
|
||||
const inputPath = `${uploadsDir}named.vcf`;
|
||||
await writeFile(inputPath, vcard("Named"));
|
||||
|
||||
await rm(inputPath);
|
||||
await mainConverter(inputPath, "vcf", "csv", `${outputDir}named.csv`);
|
||||
|
||||
expect(output.logs.join("\n")).toContain("successfully using vcf");
|
||||
});
|
||||
|
||||
// BUG: same root cause - later matches overwrite earlier ones, so emf -> png
|
||||
// uses ImageMagick even though Inkscape is registered first on purpose
|
||||
// ("Prioritize Inkscape for EMF files"). The input does not exist, so whichever
|
||||
// converter is picked fails fast; only the logged converter name matters.
|
||||
test.failing("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");
|
||||
});
|
||||
});
|
||||
|
||||
test("handleConvert processes multiple files with vcf converter across categories", async () => {
|
||||
const uploadsDir = "./data/uploads/test-vcf-multi/";
|
||||
const outputDir = "./data/output/test-vcf-multi/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
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}`));
|
||||
}
|
||||
|
||||
const baseVcf = `BEGIN:VCARD
|
||||
FN:Multi Test
|
||||
N:Test;Multi;;;
|
||||
TEL:9999
|
||||
EMAIL:multi@test.com
|
||||
END:VCARD
|
||||
`;
|
||||
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobCookie("rows"));
|
||||
|
||||
// Create 2 vcf files to test looping through fileNames
|
||||
const files = ["a.vcf", "b.vcf"];
|
||||
for (const f of files) {
|
||||
await writeFile(`${uploadsDir}${f}`, baseVcf, "utf-8");
|
||||
}
|
||||
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}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit converter to hit the properties access code path
|
||||
const jobId = createMockJobId("vcf-multi-test");
|
||||
await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId);
|
||||
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"));
|
||||
|
||||
for (const f of files) {
|
||||
const csvName = f.replace(".vcf", ".csv");
|
||||
expect(await readFile(`${outputDir}${csvName}`, "utf-8")).toBeTruthy();
|
||||
await rm(`${outputDir}${csvName}`);
|
||||
}
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobCookie());
|
||||
|
||||
for (const f of files) {
|
||||
await rm(`${uploadsDir}${f}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chunks with size 0 returns entire array as single chunk", () => {
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
const result = chunks(arr, 0);
|
||||
expect(result).toEqual([[1, 2, 3, 4, 5]]);
|
||||
});
|
||||
|
||||
test("chunks with negative size returns entire array as single chunk", () => {
|
||||
const arr = ["a", "b", "c"];
|
||||
const result = chunks(arr, -1);
|
||||
expect(result).toEqual([["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
test("chunks with size larger than array returns single chunk", () => {
|
||||
const arr = [1, 2];
|
||||
const result = chunks(arr, 10);
|
||||
expect(result).toEqual([[1, 2]]);
|
||||
});
|
||||
|
||||
test("chunks with exact division returns equal-sized chunks", () => {
|
||||
const arr = [1, 2, 3, 4, 5, 6];
|
||||
const result = chunks(arr, 2);
|
||||
expect(result).toEqual([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
test("mainConverter returns 'File type not supported' for unsupported combination", async () => {
|
||||
const result = await mainConverter("test.xyz", "xyz", "abc", "out.abc");
|
||||
expect(result).toBe("File type not supported");
|
||||
});
|
||||
|
||||
test("mainConverter auto-discovers converter when not specified", async () => {
|
||||
const uploadsDir = "./data/uploads/test-discover/";
|
||||
const outputDir = "./data/output/test-discover/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const fileName = "test.vcf";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
const outPath = `${outputDir}test.csv`;
|
||||
const sampleVcf = `BEGIN:VCARD
|
||||
FN:Discover Test
|
||||
N:Test;Discover;;;
|
||||
END:VCARD
|
||||
`;
|
||||
await writeFile(inputPath, sampleVcf, "utf-8");
|
||||
|
||||
// Call mainConverter without explicit converterName to trigger discovery
|
||||
const result = await mainConverter(inputPath, "vcf", "csv", outPath, undefined, undefined);
|
||||
expect(result).toBe("Done");
|
||||
expect(await readFile(outPath, "utf-8")).toBeTruthy();
|
||||
|
||||
await rm(inputPath);
|
||||
await rm(outPath);
|
||||
});
|
||||
|
||||
test("mainConverter returns 'Failed, check logs' when converter throws", async () => {
|
||||
// Try with a file that might cause issues (non-existent input)
|
||||
// This should trigger the catch block
|
||||
const result = await mainConverter(
|
||||
"/nonexistent/path.vcf",
|
||||
"vcf",
|
||||
"csv",
|
||||
"out.csv",
|
||||
undefined,
|
||||
"vcf",
|
||||
);
|
||||
expect(result).toBe("Failed, check logs");
|
||||
});
|
||||
|
||||
test("handleConvert with normalization covers fileTypeOrig variations", async () => {
|
||||
const uploadsDir = "./data/uploads/test-normalize/";
|
||||
const outputDir = "./data/output/test-normalize/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Use a file extension that gets normalized (e.g., .htm -> .html)
|
||||
const fileName = "index.htm";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
const sampleHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body>Test Content</body>
|
||||
</html>`;
|
||||
await writeFile(inputPath, sampleHtml, "utf-8");
|
||||
|
||||
// htm normalizes to html; libreoffice can handle html -> pdf
|
||||
const jobId = createMockJobId("normalize-test");
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "pdf", "htm", jobId);
|
||||
|
||||
await rm(inputPath);
|
||||
// PDF output may or may not exist depending on soffice availability, so we don't check it
|
||||
});
|
||||
|
||||
test("handleConvert with explicit converter processes VCF to CSV", async () => {
|
||||
const uploadsDir = "./data/uploads/test-main-extra/";
|
||||
const outputDir = "./data/output/test-main-extra/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const fileName = "contact.vcf";
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
const outPath = `${outputDir}contact.csv`;
|
||||
const sampleVcf = `BEGIN:VCARD
|
||||
FN:Jane Roe
|
||||
N:Roe;Jane;;;
|
||||
TEL;TYPE=CELL:555
|
||||
EMAIL:jane@example.com
|
||||
END:VCARD
|
||||
`;
|
||||
await writeFile(inputPath, sampleVcf, "utf-8");
|
||||
|
||||
// Call handleConvert with an explicit converter name ("vcf") to transform VCF to CSV
|
||||
const jobId = createMockJobId("discovery-test");
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
|
||||
|
||||
const out = await readFile(outPath, "utf-8");
|
||||
expect(out.includes("Jane Roe")).toBe(true);
|
||||
|
||||
await rm(inputPath);
|
||||
await rm(outPath);
|
||||
});
|
||||
|
||||
test("handleConvert handles files without extension by appending output extension", async () => {
|
||||
const uploadsDir = "./data/uploads/test-main-noext/";
|
||||
const outputDir = "./data/output/test-main-noext/";
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const fileName = "noextfile"; // no extension
|
||||
const inputPath = `${uploadsDir}${fileName}`;
|
||||
const sampleVcf = `BEGIN:VCARD
|
||||
FN:No Ext
|
||||
N:Ext;No;;;
|
||||
END:VCARD
|
||||
`;
|
||||
await writeFile(inputPath, sampleVcf, "utf-8");
|
||||
|
||||
const outPath = `${outputDir}${fileName}.csv`;
|
||||
// Call handleConvert with explicit vcf converter (no extension on input)
|
||||
const jobId = createMockJobId("noext-test");
|
||||
await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId);
|
||||
|
||||
await rm(inputPath);
|
||||
await rm(outPath);
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
import { test } from "bun:test";
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/markitdown";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { captureConsole, createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
let output: ReturnType<typeof captureConsole>;
|
||||
|
||||
beforeEach(() => {
|
||||
output = captureConsole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
output.restore();
|
||||
});
|
||||
|
||||
test("invokes markitdown with input and -o target path", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([{ cmd: "markitdown", args: ["in/report.docx", "-o", "out/report.md"] }]);
|
||||
});
|
||||
|
||||
test("prefixes rejections with the converter name", async () => {
|
||||
const { execFile } = createMockExecFile({ error: new Error("boom") });
|
||||
|
||||
await expect(
|
||||
convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile),
|
||||
).rejects.toBe("markitdown error: Error: boom");
|
||||
});
|
||||
|
||||
test("does not log output of a failed run", async () => {
|
||||
const { execFile } = createMockExecFile({
|
||||
error: new Error("boom"),
|
||||
stdout: "partial",
|
||||
stderr: "trace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
convert("in/report.docx", "docx", "md", "out/report.md", undefined, execFile),
|
||||
).rejects.toMatch(/boom/);
|
||||
expect(output.logs).toEqual([]);
|
||||
expect(output.errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,61 +1,90 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/msgconvert";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { captureConsole, createMockExecFile } from "./helpers/converters";
|
||||
|
||||
test("convert rejects conversion if input filetype is not msg and output type is not eml", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
let output: ReturnType<typeof captureConsole>;
|
||||
|
||||
const expectedError = new Error(
|
||||
"Unsupported conversion from obj to stl. Only MSG to EML conversion is currently supported.",
|
||||
);
|
||||
|
||||
expect(convert("input.obj", "obj", "stl", "output.stl", undefined, mockExecFile)).rejects.toEqual(
|
||||
expectedError,
|
||||
beforeEach(() => {
|
||||
output = captureConsole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
output.restore();
|
||||
});
|
||||
|
||||
test("convert invokes msgconvert with --outfile and resolves with the target path", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const result = await convert("in/mail.msg", "msg", "eml", "out/mail.eml", undefined, execFile);
|
||||
|
||||
expect(result).toBe("out/mail.eml");
|
||||
expect(calls).toEqual([
|
||||
{ cmd: "msgconvert", args: ["--outfile", "out/mail.eml", "in/mail.msg"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["obj", "stl"],
|
||||
["msg", "pdf"],
|
||||
["eml", "eml"],
|
||||
["eml", "msg"],
|
||||
])("convert rejects %s to %s without invoking msgconvert", async (fileType, convertTo) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await expect(
|
||||
convert(`input.${fileType}`, fileType, convertTo, `output.${convertTo}`, undefined, execFile),
|
||||
).rejects.toThrow(
|
||||
`Unsupported conversion from ${fileType} to ${convertTo}. Only MSG to EML conversion is currently supported.`,
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("convert rejects conversion on error", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(new Error("Test error"), "", "");
|
||||
};
|
||||
const { execFile } = createMockExecFile({ error: new Error("Test error") });
|
||||
|
||||
const expectedError = new Error("msgconvert failed: Test error");
|
||||
|
||||
expect(convert("input.msg", "msg", "eml", "output.eml", undefined, mockExecFile)).rejects.toEqual(
|
||||
expectedError,
|
||||
);
|
||||
await expect(
|
||||
convert("input.msg", "msg", "eml", "output.eml", undefined, execFile),
|
||||
).rejects.toThrow("msgconvert failed: Test error");
|
||||
});
|
||||
|
||||
test("convert logs stderr as warning", async () => {
|
||||
const originalConsoleWarn = console.warn;
|
||||
const { execFile } = createMockExecFile({ stderr: "Fake stderr" });
|
||||
|
||||
let loggedMessage = "";
|
||||
console.warn = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
|
||||
|
||||
const mockExecFile = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(null, "", "Fake stderr");
|
||||
};
|
||||
|
||||
await convert("file.msg", "msg", "eml", "out.eml", undefined, mockExecFile);
|
||||
|
||||
console.error = originalConsoleWarn;
|
||||
|
||||
expect(loggedMessage).toBe("msgconvert stderr: Fake stderr");
|
||||
expect(output.warnings).toEqual(["msgconvert stderr: Fake stderr"]);
|
||||
});
|
||||
|
||||
test("convert redacts absolute paths in logged stderr", async () => {
|
||||
const { execFile } = createMockExecFile({
|
||||
stderr: "cannot read /home/user/uploads/secret.msg: bad header",
|
||||
});
|
||||
|
||||
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
|
||||
|
||||
expect(output.warnings).toEqual(["msgconvert stderr: cannot read [REDACTED_PATH] bad header"]);
|
||||
});
|
||||
|
||||
test("convert truncates logged stderr longer than 200 characters", async () => {
|
||||
const { execFile } = createMockExecFile({ stderr: "x".repeat(201) });
|
||||
|
||||
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
|
||||
|
||||
expect(output.warnings).toEqual([`msgconvert stderr: ${"x".repeat(200)}...`]);
|
||||
});
|
||||
|
||||
test("convert does not truncate stderr of exactly 200 characters", async () => {
|
||||
const { execFile } = createMockExecFile({ stderr: "x".repeat(200) });
|
||||
|
||||
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
|
||||
|
||||
expect(output.warnings).toEqual([`msgconvert stderr: ${"x".repeat(200)}`]);
|
||||
});
|
||||
|
||||
test("convert does not warn when stderr is empty", async () => {
|
||||
const { execFile } = createMockExecFile({ stdout: "converted" });
|
||||
|
||||
await convert("file.msg", "msg", "eml", "out.eml", undefined, execFile);
|
||||
|
||||
expect(output.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { beforeEach, expect, test, describe } from "bun:test";
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { convert } from "../../src/converters/pandoc";
|
||||
import type { ExecFileFn } from "../../src/converters/types";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
describe("convert", () => {
|
||||
let mockExecFile: ExecFileFn;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
|
||||
});
|
||||
|
||||
test("should call pandoc with correct arguments (normal)", async () => {
|
||||
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
calledArgs = [cmd, args, callback];
|
||||
callback(null, "output-data", "");
|
||||
};
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const result = await convert(
|
||||
"input.md",
|
||||
@@ -22,45 +15,40 @@ describe("convert", () => {
|
||||
"html",
|
||||
"output.html",
|
||||
undefined,
|
||||
mockExecFile,
|
||||
execFile,
|
||||
);
|
||||
|
||||
expect(calledArgs[0]).toBe("pandoc");
|
||||
expect(calledArgs[1]).toEqual([
|
||||
"input.md",
|
||||
"-f",
|
||||
"markdown",
|
||||
"-t",
|
||||
"html",
|
||||
"-o",
|
||||
"output.html",
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cmd: "pandoc",
|
||||
args: ["input.md", "-f", "markdown", "-t", "html", "-o", "output.html"],
|
||||
},
|
||||
]);
|
||||
expect(result).toBe("Done");
|
||||
});
|
||||
|
||||
test("should add xelatex argument for pdf/latex", async () => {
|
||||
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
calledArgs = [cmd, args, callback];
|
||||
callback(null, "output-data", "");
|
||||
};
|
||||
test.each(["pdf", "latex"])("should use the xelatex pdf engine for %s", async (convertTo) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("input.md", "markdown", "pdf", "output.pdf", undefined, mockExecFile);
|
||||
await convert("input.md", "markdown", convertTo, `output.${convertTo}`, undefined, execFile);
|
||||
|
||||
expect(calledArgs[1][0]).toBe("--pdf-engine=xelatex");
|
||||
expect(calledArgs[1]).toContain("input.md");
|
||||
expect(calledArgs[1]).toContain("-f");
|
||||
expect(calledArgs[1]).toContain("markdown");
|
||||
expect(calledArgs[1]).toContain("-t");
|
||||
expect(calledArgs[1]).toContain("pdf");
|
||||
expect(calledArgs[1]).toContain("-o");
|
||||
expect(calledArgs[1]).toContain("output.pdf");
|
||||
expect(calls[0]?.args).toEqual([
|
||||
"--pdf-engine=xelatex",
|
||||
"input.md",
|
||||
"-f",
|
||||
"markdown",
|
||||
"-t",
|
||||
convertTo,
|
||||
"-o",
|
||||
`output.${convertTo}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("should reject if execFile returns an error", async () => {
|
||||
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "");
|
||||
await expect(
|
||||
convert("input.md", "markdown", "html", "output.html", undefined, mockExecFile),
|
||||
).rejects.toMatch(/error: Error: fail/);
|
||||
test("should not set a pdf engine for other targets", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("input.md", "markdown", "docx", "output.docx", undefined, execFile);
|
||||
|
||||
expect(calls[0]?.args.some((arg) => arg.startsWith("--pdf-engine"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +1,55 @@
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/pdftops";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { captureConsole, createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
let calls: string[][] = [];
|
||||
|
||||
function mockExecFile(
|
||||
_cmd: string,
|
||||
args: string[],
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) {
|
||||
calls.push(args);
|
||||
if (args.includes("fail.pdf")) {
|
||||
callback(new Error("mock failure"), "", "Fake stderr: fail");
|
||||
} else {
|
||||
callback(null, "Fake stdout", "");
|
||||
}
|
||||
}
|
||||
let output: ReturnType<typeof captureConsole>;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
output = captureConsole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
output.restore();
|
||||
});
|
||||
|
||||
test("converts a normal file to ps", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
const { execFile, calls } = createMockExecFile({ stdout: "Fake stdout" });
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
|
||||
const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
const result = await convert("in.pdf", "pdf", "ps", "out.ps", undefined, execFile);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(["in.pdf", "out.ps"]);
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls).toEqual([{ cmd: "pdftops", args: ["in.pdf", "out.ps"] }]);
|
||||
expect(output.logs).toEqual(["stdout: Fake stdout"]);
|
||||
});
|
||||
|
||||
test("adds -eps flag for eps output", async () => {
|
||||
const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, mockExecFile);
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const result = await convert("in.pdf", "pdf", "eps", "out.eps", undefined, execFile);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(["-eps", "in.pdf", "out.eps"]);
|
||||
expect(calls).toEqual([{ cmd: "pdftops", args: ["-eps", "in.pdf", "out.eps"] }]);
|
||||
});
|
||||
|
||||
test("fails on exec error", async () => {
|
||||
expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, mockExecFile)).rejects.toMatch(
|
||||
/error: Error: mock failure/,
|
||||
test("fails on exec error without logging the output of the failed run", async () => {
|
||||
const { execFile } = createMockExecFile({
|
||||
error: new Error("mock failure"),
|
||||
stderr: "Fake stderr: fail",
|
||||
});
|
||||
|
||||
await expect(convert("fail.pdf", "pdf", "ps", "output.ps", undefined, execFile)).rejects.toBe(
|
||||
"error: Error: mock failure",
|
||||
);
|
||||
expect(output.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("logs stderr when execFile returns only stderr and no error", async () => {
|
||||
const originalConsoleError = console.error;
|
||||
const { execFile } = createMockExecFile({ stderr: "Only stderr output" });
|
||||
|
||||
let loggedMessage = "";
|
||||
console.error = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
await convert("input.pdf", "pdf", "ps", "output.ps", undefined, execFile);
|
||||
|
||||
const mockExecFileStderrOnly = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback(null, "", "Only stderr output");
|
||||
};
|
||||
|
||||
await convert("input.pdf", "pdf", "ps", "output.ps", undefined, mockExecFileStderrOnly);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
|
||||
expect(loggedMessage).toBe("stderr: Only stderr output");
|
||||
expect(output.errors).toEqual(["stderr: Only stderr output"]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/potrace";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test.each(["svg", "pdf", "eps", "dxf"])("selects the %s backend via -b", async (convertTo) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/bitmap.pbm", "pbm", convertTo, `out/bitmap.${convertTo}`, undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cmd: "potrace",
|
||||
args: ["in/bitmap.pbm", "-o", `out/bitmap.${convertTo}`, "-b", convertTo],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/resvg";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test("invokes resvg with input and target path", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/icon.svg", "svg", "png", "out/icon.png", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([{ cmd: "resvg", args: ["in/icon.svg", "out/icon.png"] }]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { afterAll, beforeAll, expect, test, describe } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { convert, parseVCF, toCSV } from "../../src/converters/vcf";
|
||||
|
||||
describe("parseVCF", () => {
|
||||
@@ -60,6 +64,83 @@ END:VCARD`;
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle CRLF line endings", () => {
|
||||
const vcfData = "BEGIN:VCARD\r\nFN:John Doe\r\nTEL:123\r\nEND:VCARD\r\n";
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe", Phone: "123" }]);
|
||||
});
|
||||
|
||||
test("should keep colons that are part of the value", () => {
|
||||
const vcfData = `BEGIN:VCARD
|
||||
FN:Dr: Who
|
||||
END:VCARD`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "Dr: Who" }]);
|
||||
});
|
||||
|
||||
test("should default the first name to an empty string when N has only a last name", () => {
|
||||
const vcfData = `BEGIN:VCARD
|
||||
N:Doe
|
||||
END:VCARD`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ "Last Name": "Doe", "First Name": "" }]);
|
||||
});
|
||||
|
||||
test("should only use the organization name, not its units", () => {
|
||||
const vcfData = `BEGIN:VCARD
|
||||
ORG:Example Corp;Marketing;Berlin
|
||||
END:VCARD`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ Organization: "Example Corp" }]);
|
||||
});
|
||||
|
||||
test("should not mistake properties starting with N for the name", () => {
|
||||
const vcfData = `BEGIN:VCARD
|
||||
FN:John Doe
|
||||
NOTE:likes cats
|
||||
NICKNAME:Johnny
|
||||
END:VCARD`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]);
|
||||
});
|
||||
|
||||
test("should skip lines without a colon", () => {
|
||||
const vcfData = `BEGIN:VCARD
|
||||
FN:John Doe
|
||||
this line is garbage
|
||||
END:VCARD`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]);
|
||||
});
|
||||
|
||||
test("should ignore text outside of BEGIN/END blocks", () => {
|
||||
const vcfData = `exported by some tool
|
||||
FN:Not A Contact
|
||||
BEGIN:VCARD
|
||||
FN:John Doe
|
||||
END:VCARD
|
||||
FN:Also Not A Contact`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([{ "Full Name": "John Doe" }]);
|
||||
});
|
||||
|
||||
test("should drop cards that contain no known properties", () => {
|
||||
const vcfData = `BEGIN:VCARD
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
URL:https://example.com
|
||||
END:VCARD
|
||||
BEGIN:VCARDEND:VCARD`;
|
||||
|
||||
expect(parseVCF(vcfData)).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return an empty list for input without cards", () => {
|
||||
expect(parseVCF("")).toEqual([]);
|
||||
expect(parseVCF("just some text")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCSV", () => {
|
||||
@@ -85,6 +166,30 @@ describe("toCSV", () => {
|
||||
expect(result).toBe('Full Name\n"John ""Johnny"" Doe"');
|
||||
});
|
||||
|
||||
test("should keep commas and newlines inside quoted values", () => {
|
||||
const contacts = [{ "Full Name": "Doe, John", Organization: "Line 1\nLine 2" }];
|
||||
|
||||
expect(toCSV(contacts)).toBe('Full Name,Organization\n"Doe, John","Line 1\nLine 2"');
|
||||
});
|
||||
|
||||
test("should write an empty cell for values missing in later contacts", () => {
|
||||
const contacts = [
|
||||
{ "Full Name": "John Doe", Email: "john@example.com" },
|
||||
{ "Full Name": "Jane Smith" },
|
||||
];
|
||||
|
||||
expect(toCSV(contacts)).toBe('Full Name,Email\n"John Doe","john@example.com"\n"Jane Smith",""');
|
||||
});
|
||||
|
||||
// BUG: the header is built from the first contact only, so fields that only
|
||||
// later contacts have are silently dropped from the export. Remove `.failing`
|
||||
// once toCSV builds the header from all contacts.
|
||||
test.failing("should not drop fields that only later contacts have", () => {
|
||||
const contacts = [{ "Full Name": "John Doe" }, { "Full Name": "Jane Smith", Email: "j@x.com" }];
|
||||
|
||||
expect(toCSV(contacts)).toContain("j@x.com");
|
||||
});
|
||||
|
||||
test("should handle empty data", () => {
|
||||
const result = toCSV([]);
|
||||
expect(result).toBe("");
|
||||
@@ -92,7 +197,52 @@ describe("toCSV", () => {
|
||||
});
|
||||
|
||||
describe("convert", () => {
|
||||
test("should be a function", () => {
|
||||
expect(typeof convert).toBe("function");
|
||||
let dir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "convertx-vcf-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("should read the vcf file and write the contacts as csv", async () => {
|
||||
const inputPath = join(dir, "contacts.vcf");
|
||||
const targetPath = join(dir, "contacts.csv");
|
||||
await writeFile(
|
||||
inputPath,
|
||||
`BEGIN:VCARD
|
||||
FN:John Doe
|
||||
EMAIL:john@example.com
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
FN:Jane Smith
|
||||
EMAIL:jane@example.com
|
||||
END:VCARD
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await convert(inputPath, "vcf", "csv", targetPath);
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(await readFile(targetPath, "utf-8")).toBe(
|
||||
'Full Name,Email\n"John Doe","john@example.com"\n"Jane Smith","jane@example.com"',
|
||||
);
|
||||
});
|
||||
|
||||
test("should write an empty file when the input has no contacts", async () => {
|
||||
const inputPath = join(dir, "empty.vcf");
|
||||
const targetPath = join(dir, "empty.csv");
|
||||
await writeFile(inputPath, "");
|
||||
|
||||
await expect(convert(inputPath, "vcf", "csv", targetPath)).resolves.toBe("Done");
|
||||
expect(await readFile(targetPath, "utf-8")).toBe("");
|
||||
});
|
||||
|
||||
test("should reject when the input file does not exist", async () => {
|
||||
await expect(
|
||||
convert(join(dir, "missing.vcf"), "vcf", "csv", join(dir, "missing.csv")),
|
||||
).rejects.toThrow(/ENOENT/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,65 +1,24 @@
|
||||
import { beforeEach, expect, test } from "bun:test";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/vips";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
|
||||
let calls: string[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test("convert uses action pdfload with filetype being pdf", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test("uses the pdfload action for pdf input", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
await convert("in/doc.pdf", "pdf", "png", "out/doc.png", undefined, execFile);
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.pdf", "pdf", "obj", "output.obj", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["pdfload"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls).toEqual([{ cmd: "vips", args: ["pdfload", "in/doc.pdf", "out/doc.png"] }]);
|
||||
});
|
||||
|
||||
test("convert uses action copy with filetype being anything but pdf", async () => {
|
||||
const originalConsoleLog = console.log;
|
||||
test.each(["jpeg", "png", "svg", "tiff"])("uses the copy action for %s input", async (fileType) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
let loggedMessage = "";
|
||||
console.log = (msg) => {
|
||||
loggedMessage = msg;
|
||||
};
|
||||
await convert(`in/image.${fileType}`, fileType, "webp", "out/image.webp", undefined, execFile);
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
calls.push(_args);
|
||||
callback(null, "Fake stdout", "");
|
||||
};
|
||||
|
||||
const result = await convert("input.jpg", "jpg", "obj", "output.obj", undefined, mockExecFile);
|
||||
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
expect(result).toBe("Done");
|
||||
expect(calls[0]).toEqual(expect.arrayContaining(["copy"]));
|
||||
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||
expect(calls).toEqual([
|
||||
{ cmd: "vips", args: ["copy", `in/image.${fileType}`, "out/image.webp"] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,58 +1,133 @@
|
||||
import { beforeEach, expect, test, describe } from "bun:test";
|
||||
import { afterEach, beforeEach, expect, test, describe } from "bun:test";
|
||||
import { convert } from "../../src/converters/vtracer";
|
||||
import type { ExecFileFn } from "../../src/converters/types";
|
||||
import { captureConsole, createMockExecFile } from "./helpers/converters";
|
||||
|
||||
describe("convert", () => {
|
||||
let mockExecFile: ExecFileFn;
|
||||
let output: ReturnType<typeof captureConsole>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockExecFile = (cmd, args, callback) => callback(null, "output-data", "");
|
||||
output = captureConsole();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
output.restore();
|
||||
});
|
||||
|
||||
test("should call vtracer with correct arguments (minimal)", async () => {
|
||||
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
calledArgs = [cmd, args, callback];
|
||||
callback(null, "output-data", "");
|
||||
};
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const result = await convert("input.png", "png", "svg", "output.svg", undefined, mockExecFile);
|
||||
const result = await convert("input.png", "png", "svg", "output.svg", undefined, execFile);
|
||||
|
||||
expect(calledArgs[0]).toBe("vtracer");
|
||||
expect(calledArgs[1]).toEqual(["--input", "input.png", "--output", "output.svg"]);
|
||||
expect(calls).toEqual([
|
||||
{ cmd: "vtracer", args: ["--input", "input.png", "--output", "output.svg"] },
|
||||
]);
|
||||
expect(result).toBe("Done");
|
||||
});
|
||||
|
||||
test("should add options as arguments", async () => {
|
||||
let calledArgs: Parameters<ExecFileFn> = ["", [], () => {}];
|
||||
mockExecFile = (cmd, args, callback) => {
|
||||
calledArgs = [cmd, args, callback];
|
||||
callback(null, "output-data", "");
|
||||
};
|
||||
test("should pass every supported option as a flag in a fixed order", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
// deliberately not in the converter's internal order
|
||||
const options = {
|
||||
path_precision: 8,
|
||||
splice_threshold: 45,
|
||||
max_iterations: 10,
|
||||
length_threshold: 4,
|
||||
corner_threshold: 60,
|
||||
layer_difference: 16,
|
||||
color_precision: 6,
|
||||
filter_speckle: 4,
|
||||
mode: "spline",
|
||||
hierarchical: "stacked",
|
||||
colormode: "color",
|
||||
hierarchical: "true",
|
||||
filter_speckle: 5,
|
||||
path_precision: 0.8,
|
||||
};
|
||||
|
||||
await convert("input.png", "png", "svg", "output.svg", options, mockExecFile);
|
||||
await convert("input.png", "png", "svg", "output.svg", options, execFile);
|
||||
|
||||
expect(calledArgs[1]).toContain("--colormode");
|
||||
expect(calledArgs[1]).toContain("color");
|
||||
expect(calledArgs[1]).toContain("--hierarchical");
|
||||
expect(calledArgs[1]).toContain("true");
|
||||
expect(calledArgs[1]).toContain("--filter_speckle");
|
||||
expect(calledArgs[1]).toContain("5");
|
||||
expect(calledArgs[1]).toContain("--path_precision");
|
||||
expect(calledArgs[1]).toContain("0.8");
|
||||
expect(calls[0]?.args).toEqual([
|
||||
"--input",
|
||||
"input.png",
|
||||
"--output",
|
||||
"output.svg",
|
||||
"--colormode",
|
||||
"color",
|
||||
"--hierarchical",
|
||||
"stacked",
|
||||
"--mode",
|
||||
"spline",
|
||||
"--filter_speckle",
|
||||
"4",
|
||||
"--color_precision",
|
||||
"6",
|
||||
"--layer_difference",
|
||||
"16",
|
||||
"--corner_threshold",
|
||||
"60",
|
||||
"--length_threshold",
|
||||
"4",
|
||||
"--max_iterations",
|
||||
"10",
|
||||
"--splice_threshold",
|
||||
"45",
|
||||
"--path_precision",
|
||||
"8",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should keep falsy option values such as 0", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("input.png", "png", "svg", "output.svg", { filter_speckle: 0 }, execFile);
|
||||
|
||||
expect(calls[0]?.args.slice(4)).toEqual(["--filter_speckle", "0"]);
|
||||
});
|
||||
|
||||
test("should ignore unknown and undefined options", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
const options = { mode: undefined, preset: "photo", "--output": "/etc/passwd" };
|
||||
await convert("input.png", "png", "svg", "output.svg", options, execFile);
|
||||
|
||||
expect(calls[0]?.args).toEqual(["--input", "input.png", "--output", "output.svg"]);
|
||||
});
|
||||
|
||||
test.each([null, "colormode=color", 42])(
|
||||
"should ignore non-object options (%p)",
|
||||
async (options) => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("input.png", "png", "svg", "output.svg", options, execFile);
|
||||
|
||||
expect(calls[0]?.args).toEqual(["--input", "input.png", "--output", "output.svg"]);
|
||||
},
|
||||
);
|
||||
|
||||
test("should reject if execFile returns an error", async () => {
|
||||
mockExecFile = (cmd, args, callback) => callback(new Error("fail"), "", "stderr output");
|
||||
const { execFile } = createMockExecFile({
|
||||
error: new Error("fail"),
|
||||
stderr: "stderr output",
|
||||
});
|
||||
|
||||
await expect(
|
||||
convert("input.png", "png", "svg", "output.svg", undefined, mockExecFile),
|
||||
).rejects.toMatch(/error: Error: fail\nstderr: stderr output/);
|
||||
convert("input.png", "png", "svg", "output.svg", undefined, execFile),
|
||||
).rejects.toBe("error: Error: fail\nstderr: stderr output");
|
||||
});
|
||||
|
||||
test("should omit the stderr suffix from the rejection when stderr is empty", async () => {
|
||||
const { execFile } = createMockExecFile({ error: new Error("fail") });
|
||||
|
||||
await expect(
|
||||
convert("input.png", "png", "svg", "output.svg", undefined, execFile),
|
||||
).rejects.toBe("error: Error: fail");
|
||||
});
|
||||
|
||||
test("should log stdout and stderr of a successful run", async () => {
|
||||
const { execFile } = createMockExecFile({ stdout: "traced", stderr: "warning" });
|
||||
|
||||
await convert("input.png", "png", "svg", "output.svg", undefined, execFile);
|
||||
|
||||
// vtracer reports progress on stderr, so it is logged as regular output
|
||||
expect(output.logs).toEqual(["stdout: traced", "stderr: warning"]);
|
||||
expect(output.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { test } from "bun:test";
|
||||
import { expect, test } from "bun:test";
|
||||
import { convert } from "../../src/converters/xelatex";
|
||||
import { runCommonTests } from "./helpers/commonTests";
|
||||
import { createMockExecFile } from "./helpers/converters";
|
||||
|
||||
runCommonTests(convert);
|
||||
|
||||
test.skip("dummy - required to trigger test detection", () => {});
|
||||
test("invokes latexmk with xelatex in non-interactive mode", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/paper.tex", "latex", "pdf", "out/job/paper.pdf", undefined, execFile);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
cmd: "latexmk",
|
||||
args: ["-xelatex", "-interaction=nonstopmode", "-output-directory=out/job", "in/paper.tex"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("strips a leading './' from the output directory", async () => {
|
||||
const { execFile, calls } = createMockExecFile();
|
||||
|
||||
await convert("in/paper.tex", "latex", "pdf", "./data/output/paper.pdf", undefined, execFile);
|
||||
|
||||
expect(calls[0]?.args).toContain("-output-directory=data/output");
|
||||
});
|
||||
|
||||
+11
-39
@@ -1,11 +1,15 @@
|
||||
import { test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { unlinkSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { unlinkSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// set environment variable to ensure the test database is used instead of production data
|
||||
process.env.DB_PATH = "./data/test-isolated.sqlite";
|
||||
const testRoot = process.env.CONVERTX_TEST_ROOT;
|
||||
if (!testRoot) {
|
||||
// Without tests/preload.ts the db module would open and migrate ./data/mydb.sqlite.
|
||||
throw new Error("Database tests must run via `bun test` so that tests/preload.ts is loaded.");
|
||||
}
|
||||
|
||||
// dynamic import ensures that db.ts is loaded after the env is set
|
||||
// the dynamic import ensures that the guard above runs before the db module is loaded
|
||||
let initializeDatabase: (db: Database) => void;
|
||||
let defaultDb: Database | undefined;
|
||||
await import("../../src/db/db").then((mod) => {
|
||||
@@ -61,8 +65,7 @@ let testDbPath: string;
|
||||
let testDb: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync("./data", { recursive: true });
|
||||
testDbPath = `./data/test-db-${Date.now()}.sqlite`;
|
||||
testDbPath = join(testRoot, `test-db-${Date.now()}.sqlite`);
|
||||
testDb = new Database(testDbPath, { create: true });
|
||||
// Now uses the real initialization logic from db.ts
|
||||
initializeDatabase(testDb);
|
||||
@@ -97,37 +100,6 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Close the module-level default database before cleanup to prevent file lock errors
|
||||
if (defaultDb) {
|
||||
defaultDb.close();
|
||||
}
|
||||
// Cleanup of the isolated test database after the test run
|
||||
if (existsSync("./data/test-isolated.sqlite")) {
|
||||
unlinkSync("./data/test-isolated.sqlite");
|
||||
}
|
||||
if (existsSync("./data/test-isolated.sqlite-wal")) {
|
||||
try {
|
||||
unlinkSync("./data/test-isolated.sqlite-wal");
|
||||
} catch (err) {
|
||||
// WAL file cleanup error - log but don't fail test
|
||||
if (err instanceof Error && err.message.includes("ENOENT")) {
|
||||
// File already gone, which is fine
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync("./data/test-isolated.sqlite-shm")) {
|
||||
try {
|
||||
unlinkSync("./data/test-isolated.sqlite-shm");
|
||||
} catch (err) {
|
||||
// SHM file cleanup error - log but don't fail test
|
||||
if (err instanceof Error && err.message.includes("ENOENT")) {
|
||||
// File already gone, which is fine
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("db initializes and creates tables on first run", () => {
|
||||
const tables = queryAllTables(testDb);
|
||||
expect(tables.length).toBeGreaterThanOrEqual(3);
|
||||
@@ -139,7 +111,7 @@ test("db initializes and creates tables on first run", () => {
|
||||
|
||||
test("db handles migration from version 0 to version 1", () => {
|
||||
testDb.close();
|
||||
const migrateDbPath = `./data/test-db-migrate-${Date.now()}.sqlite`;
|
||||
const migrateDbPath = join(testRoot, `test-db-migrate-${Date.now()}.sqlite`);
|
||||
const migrateDb = new Database(migrateDbPath, { create: true });
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { request } from "./helpers/app";
|
||||
|
||||
async function conversionsFor(fileType: string) {
|
||||
return (await request("/conversions", { json: { fileType } })).text();
|
||||
}
|
||||
|
||||
describe("POST /conversions", () => {
|
||||
test("lists the targets of every converter that accepts the file type", async () => {
|
||||
const body = await conversionsFor("vcf");
|
||||
|
||||
expect(body).toContain('data-converter="vcf"');
|
||||
expect(body).toContain('data-value="csv,vcf"');
|
||||
expect(body).toContain('<optgroup label="vcf"><option value="csv,vcf">csv</option></optgroup>');
|
||||
});
|
||||
|
||||
test("normalizes the file type", async () => {
|
||||
expect(await conversionsFor("VCF")).toBe(await conversionsFor("vcf"));
|
||||
expect(await conversionsFor("jpg")).toBe(await conversionsFor("jpeg"));
|
||||
});
|
||||
|
||||
test("offers no converters for an unknown file type", async () => {
|
||||
const body = await conversionsFor("xyz123");
|
||||
|
||||
expect(body).not.toContain("convert_to_group");
|
||||
expect(body).not.toContain("<optgroup");
|
||||
// the placeholder of the select is still rendered
|
||||
expect(body).toContain('<option selected disabled value="">');
|
||||
});
|
||||
|
||||
test("returns a fragment, not a full page", async () => {
|
||||
const body = await conversionsFor("vcf");
|
||||
|
||||
expect(body).toStartWith("<article");
|
||||
expect(body).not.toContain("<html");
|
||||
});
|
||||
|
||||
test("works without a session, as it only exposes the converter list", async () => {
|
||||
const response = await request("/conversions", { json: { fileType: "vcf" } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("rejects a request without a file type", async () => {
|
||||
const response = await request("/conversions", { json: {} });
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createJob, createUser, db, jobDirs, request, waitFor } from "./helpers/app";
|
||||
|
||||
type JobRow = { status: string; num_files: number };
|
||||
type FileRow = { file_name: string; output_file_name: string; status: string };
|
||||
|
||||
function jobRow(jobId: number) {
|
||||
return db.query("SELECT status, num_files FROM jobs WHERE id = ?").get(jobId) as JobRow;
|
||||
}
|
||||
|
||||
function fileRows(jobId: number) {
|
||||
return db
|
||||
.query(
|
||||
"SELECT file_name, output_file_name, status FROM file_names WHERE job_id = ? ORDER BY file_name",
|
||||
)
|
||||
.all(jobId) as FileRow[];
|
||||
}
|
||||
|
||||
async function userWithJob() {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
return { user, job, dirs: jobDirs(user.id, job) };
|
||||
}
|
||||
|
||||
function convertRequest(
|
||||
cookies: { auth: string; jobId?: number },
|
||||
form: { convert_to: string; file_names: string },
|
||||
) {
|
||||
return request("/convert", { cookies, form });
|
||||
}
|
||||
|
||||
describe("POST /convert", () => {
|
||||
test("requires a session", async () => {
|
||||
const response = await request("/convert", {
|
||||
headers: { accept: "application/json" },
|
||||
form: { convert_to: "csv,vcf", file_names: "[]" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("redirects home without a job", async () => {
|
||||
const { user } = await userWithJob();
|
||||
|
||||
const response = await convertRequest(
|
||||
{ auth: user.token },
|
||||
{ convert_to: "csv,vcf", file_names: '["a.vcf"]' },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
});
|
||||
|
||||
test("refuses to convert another user's job", async () => {
|
||||
const { user } = await userWithJob();
|
||||
const other = await userWithJob();
|
||||
|
||||
const response = await convertRequest(
|
||||
{ auth: user.token, jobId: other.job },
|
||||
{ convert_to: "csv,vcf", file_names: '["a.vcf"]' },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(jobRow(other.job).status).toBe("not started");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["no converter is given", "csv"],
|
||||
["the target contains a path separator", "a/b,vcf"],
|
||||
["the target contains a backslash", "a\\b,vcf"],
|
||||
["the target points to a parent directory", "..,vcf"],
|
||||
])("redirects home without converting when %s", async (_, convertTo) => {
|
||||
const { user, job } = await userWithJob();
|
||||
|
||||
const response = await convertRequest(
|
||||
{ auth: user.token, jobId: job },
|
||||
{ convert_to: convertTo, file_names: '["a.vcf"]' },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(jobRow(job).status).toBe("not started");
|
||||
});
|
||||
|
||||
test("redirects home without converting when no files are given", async () => {
|
||||
const { user, job } = await userWithJob();
|
||||
|
||||
const response = await convertRequest(
|
||||
{ auth: user.token, jobId: job },
|
||||
{ convert_to: "csv,vcf", file_names: "[]" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(jobRow(job).status).toBe("not started");
|
||||
});
|
||||
|
||||
test("converts the uploaded files in the background and shows the results", async () => {
|
||||
const { user, job, dirs } = await userWithJob();
|
||||
mkdirSync(dirs.uploads, { recursive: true });
|
||||
await writeFile(`${dirs.uploads}a.vcf`, "BEGIN:VCARD\nFN:Alice\nEND:VCARD\n");
|
||||
await writeFile(`${dirs.uploads}b.vcf`, "BEGIN:VCARD\nFN:Bob\nEND:VCARD\n");
|
||||
|
||||
const response = await convertRequest(
|
||||
{ auth: user.token, jobId: job },
|
||||
{ convert_to: "csv,vcf", file_names: '["a.vcf","b.vcf"]' },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe(`/results/${job}`);
|
||||
expect(jobRow(job).num_files).toBe(2);
|
||||
|
||||
await waitFor(() => jobRow(job).status === "completed");
|
||||
expect(fileRows(job)).toEqual([
|
||||
{ file_name: "a.vcf", output_file_name: "a.csv", status: "Done" },
|
||||
{ file_name: "b.vcf", output_file_name: "b.csv", status: "Done" },
|
||||
]);
|
||||
expect(await readFile(`${dirs.output}a.csv`, "utf-8")).toBe('Full Name\n"Alice"');
|
||||
});
|
||||
|
||||
test("normalizes the target type before converting", async () => {
|
||||
const { user, job } = await userWithJob();
|
||||
|
||||
await convertRequest(
|
||||
{ auth: user.token, jobId: job },
|
||||
{ convert_to: "JPG,doesnotexist", file_names: '["photo.png"]' },
|
||||
);
|
||||
|
||||
await waitFor(() => jobRow(job).status === "completed");
|
||||
// jpg is normalized to jpeg, whose files get the .jpg extension
|
||||
expect(fileRows(job)).toEqual([
|
||||
{ file_name: "photo.png", output_file_name: "photo.jpg", status: "File type not supported" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("sanitizes file names so conversions cannot read outside the job directory", async () => {
|
||||
const { user, job } = await userWithJob();
|
||||
|
||||
await convertRequest(
|
||||
{ auth: user.token, jobId: job },
|
||||
{ convert_to: "csv,vcf", file_names: '["../../secret.vcf"]' },
|
||||
);
|
||||
|
||||
await waitFor(() => jobRow(job).status === "completed");
|
||||
expect(fileRows(job)).toEqual([
|
||||
{
|
||||
file_name: "....secret.vcf",
|
||||
output_file_name: "....secret.csv",
|
||||
status: "Failed, check logs",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// BUG: file_names is parsed and indexed before it is validated, so a value that is not a
|
||||
// JSON array ends in a 500 instead of the redirect used for every other invalid input.
|
||||
// Remove `.failing` once convert.tsx validates the parsed value first.
|
||||
test.failing.each([
|
||||
["is not JSON", "not json"],
|
||||
["is a JSON string", '"a.vcf"'],
|
||||
])("redirects home when file_names %s", async (_, fileNames) => {
|
||||
const { user, job } = await userWithJob();
|
||||
|
||||
const response = await convertRequest(
|
||||
{ auth: user.token, jobId: job },
|
||||
{ convert_to: "csv,vcf", file_names: fileNames },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { createJob, createUser, jobDirs, request, uploadsDir } from "./helpers/app";
|
||||
|
||||
async function userWithUploads(...fileNames: string[]) {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
const dirs = jobDirs(user.id, job);
|
||||
mkdirSync(dirs.uploads, { recursive: true });
|
||||
for (const fileName of fileNames) {
|
||||
writeFileSync(`${dirs.uploads}${fileName}`, fileName);
|
||||
}
|
||||
return { user, job, dirs };
|
||||
}
|
||||
|
||||
describe("POST /delete", () => {
|
||||
test("requires a session", async () => {
|
||||
const response = await request("/delete", {
|
||||
headers: { accept: "application/json" },
|
||||
json: { filename: "a.txt" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("redirects home without a job", async () => {
|
||||
const { user, dirs } = await userWithUploads("a.txt");
|
||||
|
||||
const response = await request("/delete", {
|
||||
cookies: { auth: user.token },
|
||||
json: { filename: "a.txt" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(existsSync(`${dirs.uploads}a.txt`)).toBe(true);
|
||||
});
|
||||
|
||||
test("refuses to delete from another user's job", async () => {
|
||||
const { user } = await userWithUploads();
|
||||
const other = await userWithUploads("a.txt");
|
||||
|
||||
const response = await request("/delete", {
|
||||
cookies: { auth: user.token, jobId: other.job },
|
||||
json: { filename: "a.txt" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(existsSync(`${other.dirs.uploads}a.txt`)).toBe(true);
|
||||
});
|
||||
|
||||
test("deletes only the given uploaded file", async () => {
|
||||
const { user, job, dirs } = await userWithUploads("a.txt", "b.txt");
|
||||
|
||||
const response = await request("/delete", {
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
json: { filename: "a.txt" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ message: "File deleted successfully." });
|
||||
expect(existsSync(`${dirs.uploads}a.txt`)).toBe(false);
|
||||
expect(existsSync(`${dirs.uploads}b.txt`)).toBe(true);
|
||||
});
|
||||
|
||||
test("sanitizes the file name so files outside the job cannot be deleted", async () => {
|
||||
const { user, job } = await userWithUploads();
|
||||
const other = await userWithUploads("keep.txt");
|
||||
|
||||
const response = await request("/delete", {
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
json: { filename: `../../${other.user.id}/${other.job}/keep.txt` },
|
||||
});
|
||||
|
||||
// the request passed the session and job checks, so the file name was really used;
|
||||
// the sanitized name does not exist, which runs into the bug tracked below
|
||||
expect(response.status).not.toBeOneOf([302, 401]);
|
||||
expect(existsSync(`${other.dirs.uploads}keep.txt`)).toBe(true);
|
||||
});
|
||||
|
||||
// BUG: unlink errors are not handled, so deleting a file that does not exist answers
|
||||
// with a 500 whose body contains the absolute upload path on the server. This also
|
||||
// happens for every sanitized traversal attempt. Remove `.failing` once deleteFile.tsx
|
||||
// handles the missing file.
|
||||
test.failing.each(["missing.txt", "../../1/1/keep.txt"])(
|
||||
"answers a missing file (%s) without a server error or server paths",
|
||||
async (filename) => {
|
||||
const { user, job } = await userWithUploads();
|
||||
|
||||
const response = await request("/delete", {
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
json: { filename },
|
||||
});
|
||||
|
||||
expect(response.status).toBeLessThan(500);
|
||||
expect(await response.text()).not.toContain(uploadsDir);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { createJob, createUser, db, jobDirs, request } from "./helpers/app";
|
||||
|
||||
async function userWithJobs(count = 1) {
|
||||
const user = await createUser();
|
||||
const jobs = Array.from({ length: count }, () => {
|
||||
const job = createJob(user.id, { numFiles: 1 });
|
||||
const dirs = jobDirs(user.id, job);
|
||||
mkdirSync(dirs.uploads, { recursive: true });
|
||||
mkdirSync(dirs.output, { recursive: true });
|
||||
writeFileSync(`${dirs.uploads}in.vcf`, "in");
|
||||
writeFileSync(`${dirs.output}out.csv`, "out");
|
||||
return job;
|
||||
});
|
||||
return { user, jobs };
|
||||
}
|
||||
|
||||
function jobExists(jobId: number | string) {
|
||||
return db.query("SELECT id FROM jobs WHERE id = ?").get(jobId) !== null;
|
||||
}
|
||||
|
||||
function filesExist(userId: number, jobId: number) {
|
||||
const dirs = jobDirs(userId, jobId);
|
||||
return existsSync(dirs.uploads) || existsSync(dirs.output);
|
||||
}
|
||||
|
||||
describe("POST /delete/:jobId", () => {
|
||||
test("requires a session", async () => {
|
||||
const { user, jobs } = await userWithJobs();
|
||||
|
||||
const response = await request(`/delete/${jobs[0]}`, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(jobExists(jobs[0] ?? 0)).toBe(true);
|
||||
expect(filesExist(user.id, jobs[0] ?? 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("deletes the job with its uploaded and converted files", async () => {
|
||||
const { user, jobs } = await userWithJobs();
|
||||
const job = jobs[0] ?? 0;
|
||||
|
||||
const response = await request(`/delete/${job}`, {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/history");
|
||||
expect(jobExists(job)).toBe(false);
|
||||
expect(filesExist(user.id, job)).toBe(false);
|
||||
});
|
||||
|
||||
test("refuses to delete another user's job", async () => {
|
||||
const user = await createUser();
|
||||
const other = await userWithJobs();
|
||||
const job = other.jobs[0] ?? 0;
|
||||
|
||||
const response = await request(`/delete/${job}`, {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/results");
|
||||
expect(jobExists(job)).toBe(true);
|
||||
expect(filesExist(other.user.id, job)).toBe(true);
|
||||
});
|
||||
|
||||
test("cannot be triggered by a GET request (CSRF protection)", async () => {
|
||||
const { user, jobs } = await userWithJobs();
|
||||
const job = jobs[0] ?? 0;
|
||||
|
||||
const response = await request(`/delete/${job}`, { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(jobExists(job)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /delete-multiple", () => {
|
||||
const deleteMultiple = (token: string, jobIds: unknown) =>
|
||||
request("/delete-multiple", { cookies: { auth: token }, json: { jobIds } });
|
||||
|
||||
test("requires a session", async () => {
|
||||
const { jobs } = await userWithJobs();
|
||||
|
||||
const response = await request("/delete-multiple", {
|
||||
headers: { accept: "application/json" },
|
||||
json: { jobIds: jobs.map(String) },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(jobExists(jobs[0] ?? 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("deletes all given jobs with their files", async () => {
|
||||
const { user, jobs } = await userWithJobs(2);
|
||||
|
||||
const response = await deleteMultiple(user.token, jobs.map(String));
|
||||
|
||||
expect(await response.json()).toEqual({
|
||||
success: true,
|
||||
deleted: 2,
|
||||
failed: 0,
|
||||
details: { success: jobs.map(String), failed: [] },
|
||||
});
|
||||
for (const job of jobs) {
|
||||
expect(jobExists(job)).toBe(false);
|
||||
expect(filesExist(user.id, job)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("reports jobs that do not exist or belong to another user", async () => {
|
||||
const { user, jobs } = await userWithJobs();
|
||||
const other = await userWithJobs();
|
||||
const foreign = String(other.jobs[0]);
|
||||
|
||||
const response = await deleteMultiple(user.token, [String(jobs[0]), foreign, "999999"]);
|
||||
|
||||
expect(await response.json()).toEqual({
|
||||
success: false,
|
||||
deleted: 1,
|
||||
failed: 2,
|
||||
details: {
|
||||
success: [String(jobs[0])],
|
||||
failed: [
|
||||
{ jobId: foreign, error: "Job not found or unauthorized" },
|
||||
{ jobId: "999999", error: "Job not found or unauthorized" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(jobExists(foreign)).toBe(true);
|
||||
expect(filesExist(other.user.id, Number(foreign))).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an empty list", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await deleteMultiple(user.token, []);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ success: false, message: "Invalid job IDs provided" });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["more than 100 job ids", Array.from({ length: 101 }, (_, i) => String(i))],
|
||||
["job ids that are not strings", [1, 2]],
|
||||
["a value that is not a list", "1"],
|
||||
])("rejects %s", async (_, jobIds) => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await deleteMultiple(user.token, jobIds);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { createJob, createUser, jobDirs, request } from "./helpers/app";
|
||||
|
||||
async function userWithOutputs(files: Record<string, string>) {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
const dirs = jobDirs(user.id, job);
|
||||
mkdirSync(dirs.output, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(`${dirs.output}${name}`, content);
|
||||
}
|
||||
return { user, job, dirs };
|
||||
}
|
||||
|
||||
async function tarEntries(response: Response) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "convertx-archive-"));
|
||||
try {
|
||||
const file = join(dir, "archive.tar");
|
||||
writeFileSync(file, Buffer.from(await response.arrayBuffer()));
|
||||
const entries: string[] = [];
|
||||
await tar.list({ file, onReadEntry: (entry) => entries.push(entry.path) });
|
||||
return entries.sort();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("GET /download/:userId/:jobId/:fileName", () => {
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const { user, job } = await userWithOutputs({ "a.csv": "a" });
|
||||
|
||||
const response = await request(`/download/${user.id}/${job}/a.csv`, {
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("serves a converted file", async () => {
|
||||
const { user, job } = await userWithOutputs({ "a.csv": "converted" });
|
||||
|
||||
const response = await request(`/download/${user.id}/${job}/a.csv`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toStartWith("text/csv");
|
||||
expect(await response.text()).toBe("converted");
|
||||
});
|
||||
|
||||
test("serves file names with encoded spaces and hashes", async () => {
|
||||
const { user, job } = await userWithOutputs({ "my file #1.csv": "spaced" });
|
||||
|
||||
const response = await request(`/download/${user.id}/${job}/my%20file%20%231.csv`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(await response.text()).toBe("spaced");
|
||||
});
|
||||
|
||||
test("answers 404 for a file that does not exist", async () => {
|
||||
const { user, job } = await userWithOutputs({});
|
||||
|
||||
const response = await request(`/download/${user.id}/${job}/missing.csv`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ message: "Converted file not found." });
|
||||
});
|
||||
|
||||
test("refuses files of another user's job", async () => {
|
||||
const user = await createUser();
|
||||
const other = await userWithOutputs({ "secret.csv": "secret" });
|
||||
|
||||
const response = await request(`/download/${other.user.id}/${other.job}/secret.csv`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/results");
|
||||
});
|
||||
|
||||
test("ignores the user id in the URL and only serves the caller's own files", async () => {
|
||||
const { user, job } = await userWithOutputs({ "a.csv": "mine" });
|
||||
const other = await createUser();
|
||||
mkdirSync(jobDirs(other.id, job).output, { recursive: true });
|
||||
writeFileSync(`${jobDirs(other.id, job).output}a.csv`, "theirs");
|
||||
|
||||
const response = await request(`/download/${other.id}/${job}/a.csv`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(await response.text()).toBe("mine");
|
||||
});
|
||||
|
||||
test("sanitizes the file name so files outside the job cannot be read", async () => {
|
||||
const { user, job } = await userWithOutputs({});
|
||||
const other = await userWithOutputs({ "secret.csv": "secret" });
|
||||
|
||||
const traversal = encodeURIComponent(`../../${other.user.id}/${other.job}/secret.csv`);
|
||||
const response = await request(`/download/${user.id}/${job}/${traversal}`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
// BUG: Elysia already decodes path parameters and download.tsx decodes them a second
|
||||
// time, so file names containing "%" fail with a 500 "URI error" (and a literal "%20"
|
||||
// would be turned into a space). The results page links to such files. Remove
|
||||
// `.failing` once the second decodeURIComponent is dropped.
|
||||
test.failing("serves file names containing a percent sign", async () => {
|
||||
const { user, job } = await userWithOutputs({ "100%.csv": "percent" });
|
||||
|
||||
const response = await request(`/download/${user.id}/${job}/100%25.csv`, {
|
||||
cookies: { auth: user.token },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("percent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /archive/:jobId", () => {
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const { job } = await userWithOutputs({ "a.csv": "a" });
|
||||
|
||||
const response = await request(`/archive/${job}`, { headers: { accept: "text/html" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("packs all converted files of the job into a tar archive", async () => {
|
||||
const { user, job } = await userWithOutputs({ "a.csv": "a", "b.csv": "b" });
|
||||
|
||||
const response = await request(`/archive/${job}`, { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/x-tar");
|
||||
expect(await tarEntries(response)).toEqual(["./", "./a.csv", "./b.csv"]);
|
||||
});
|
||||
|
||||
test("does not include earlier archives in a new archive", async () => {
|
||||
const { user, job } = await userWithOutputs({ "a.csv": "a" });
|
||||
|
||||
await request(`/archive/${job}`, { cookies: { auth: user.token } });
|
||||
const response = await request(`/archive/${job}`, { cookies: { auth: user.token } });
|
||||
|
||||
expect(await tarEntries(response)).toEqual(["./", "./a.csv"]);
|
||||
});
|
||||
|
||||
test("refuses another user's job", async () => {
|
||||
const user = await createUser();
|
||||
const other = await userWithOutputs({ "secret.csv": "secret" });
|
||||
|
||||
const response = await request(`/archive/${other.job}`, { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/results");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { User } from "../../src/db/types";
|
||||
import { db, FIRST_ACCOUNT, firstRun as recordedFirstRun, SECOND_ACCOUNT } from "./helpers/app";
|
||||
|
||||
// helpers/app.ts walks through the first run when it loads the pages (see the comment
|
||||
// there); these tests check the recorded responses.
|
||||
if (!recordedFirstRun) {
|
||||
throw new Error("The database already had accounts when helpers/app.ts loaded the pages");
|
||||
}
|
||||
const firstRun = recordedFirstRun;
|
||||
|
||||
function findUser(email: string) {
|
||||
return db.query("SELECT * FROM users WHERE email = ?").get(email) as User | null;
|
||||
}
|
||||
|
||||
describe("first run", () => {
|
||||
test("shows the account setup form", () => {
|
||||
expect(firstRun.setup.status).toBe(200);
|
||||
expect(firstRun.setup.body).toContain("Welcome to ConvertX!");
|
||||
expect(firstRun.setup.body).toContain('action="/register"');
|
||||
});
|
||||
|
||||
test("sends visitors of the login page to the setup", () => {
|
||||
expect(firstRun.login.status).toBe(302);
|
||||
expect(firstRun.login.location).toBe("/setup");
|
||||
});
|
||||
|
||||
test("sends visitors of the home page to the setup", () => {
|
||||
expect(firstRun.home.status).toBe(302);
|
||||
expect(firstRun.home.location).toBe("/setup");
|
||||
});
|
||||
|
||||
test("registers the first account although registration is closed", async () => {
|
||||
expect(firstRun.register.status).toBe(302);
|
||||
expect(firstRun.register.location).toBe("/");
|
||||
expect(firstRun.register.cookies.some((cookie) => cookie.startsWith("auth="))).toBe(true);
|
||||
|
||||
const user = findUser(FIRST_ACCOUNT.email);
|
||||
expect(user).not.toBeNull();
|
||||
// the password is stored as a hash, never in plain text
|
||||
expect(user?.password).not.toBe(FIRST_ACCOUNT.password);
|
||||
expect(await Bun.password.verify(FIRST_ACCOUNT.password, user?.password ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
test("closes the setup once the first account exists", () => {
|
||||
expect(firstRun.setupAfterwards.status).toBe(302);
|
||||
expect(firstRun.setupAfterwards.location).toBe("/login");
|
||||
});
|
||||
|
||||
test("closes the registration once the first account exists", () => {
|
||||
expect(firstRun.registerAfterwards.status).toBe(302);
|
||||
expect(firstRun.registerAfterwards.location).toBe("/login");
|
||||
expect(findUser(SECOND_ACCOUNT.email)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { request } from "./helpers/app";
|
||||
|
||||
describe("GET /healthcheck", () => {
|
||||
test("reports ok without a session", async () => {
|
||||
const response = await request("/healthcheck");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
test("ignores an invalid session", async () => {
|
||||
const response = await request("/healthcheck", { cookies: { auth: "not-a-jwt" } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.getSetCookie()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { mock } from "bun:test";
|
||||
import { html } from "@elysiajs/html";
|
||||
import { Elysia } from "elysia";
|
||||
import * as realEnvModule from "../../../src/helpers/env";
|
||||
|
||||
if (!process.env.CONVERTX_TEST_ROOT) {
|
||||
// Without tests/preload.ts the pages would import src/index.tsx (starting the server)
|
||||
// and write into ./data next to real conversions.
|
||||
throw new Error("Page tests must run via `bun test` so that tests/preload.ts is loaded.");
|
||||
}
|
||||
|
||||
// dynamic imports ensure that the guard above runs before any page module is loaded
|
||||
const { default: db } = await import("../../../src/db/db");
|
||||
const { uploadsDir, outputDir } = await import("../../../src/index");
|
||||
const userPage = await import("../../../src/pages/user");
|
||||
const { root } = await import("../../../src/pages/root");
|
||||
const { upload } = await import("../../../src/pages/upload");
|
||||
const { history } = await import("../../../src/pages/history");
|
||||
const { convert } = await import("../../../src/pages/convert");
|
||||
const { download } = await import("../../../src/pages/download");
|
||||
const { deleteJob } = await import("../../../src/pages/deleteJob");
|
||||
const { results } = await import("../../../src/pages/results");
|
||||
const { deleteFile } = await import("../../../src/pages/deleteFile");
|
||||
const { listConverters } = await import("../../../src/pages/listConverters");
|
||||
const { chooseConverter } = await import("../../../src/pages/chooseConverter");
|
||||
const { healthcheck } = await import("../../../src/pages/healthcheck");
|
||||
|
||||
export { db, uploadsDir };
|
||||
|
||||
// Same composition as src/index.tsx, without the server, static files and job cleanup.
|
||||
export const app = new Elysia({ prefix: realEnvModule.WEBROOT })
|
||||
.use(html())
|
||||
.use(userPage.user)
|
||||
.use(root)
|
||||
.use(upload)
|
||||
.use(history)
|
||||
.use(convert)
|
||||
.use(download)
|
||||
.use(deleteJob)
|
||||
.use(results)
|
||||
.use(deleteFile)
|
||||
.use(listConverters)
|
||||
.use(chooseConverter)
|
||||
.use(healthcheck);
|
||||
|
||||
// --- environment flags ---------------------------------------------------------
|
||||
// src/helpers/env.ts reads process.env once at import, so flags are switched by
|
||||
// re-mocking the module; importers see the new values through their live bindings.
|
||||
type Env = typeof realEnvModule;
|
||||
const realEnv: Env = { ...realEnvModule };
|
||||
|
||||
export function setEnv(overrides: Partial<Env>) {
|
||||
mock.module("../../../src/helpers/env", () => ({ ...realEnv, ...overrides }));
|
||||
}
|
||||
|
||||
export function resetEnv() {
|
||||
mock.module("../../../src/helpers/env", () => realEnv);
|
||||
}
|
||||
|
||||
// --- requests --------------------------------------------------------------------
|
||||
type RequestOptions = {
|
||||
method?: string;
|
||||
cookies?: Record<string, string | number | undefined>;
|
||||
headers?: Record<string, string>;
|
||||
json?: unknown;
|
||||
form?: Record<string, string>;
|
||||
body?: RequestInit["body"];
|
||||
};
|
||||
|
||||
export function request(path: string, options: RequestOptions = {}) {
|
||||
const headers: Record<string, string> = { ...options.headers };
|
||||
let body = options.body;
|
||||
|
||||
const cookie = Object.entries(options.cookies ?? {})
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("; ");
|
||||
if (cookie) headers.cookie = cookie;
|
||||
|
||||
if (options.json !== undefined) {
|
||||
headers["content-type"] = "application/json";
|
||||
body = JSON.stringify(options.json);
|
||||
} else if (options.form) {
|
||||
headers["content-type"] = "application/x-www-form-urlencoded";
|
||||
body = new URLSearchParams(options.form).toString();
|
||||
}
|
||||
|
||||
return app.handle(
|
||||
new Request(`http://localhost${path}`, {
|
||||
method: options.method ?? (body === undefined ? "GET" : "POST"),
|
||||
headers,
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the Set-Cookie header for one cookie, or undefined if it was not set. */
|
||||
export function setCookie(response: Response, name: string) {
|
||||
return response.headers.getSetCookie().find((cookie) => cookie.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
/** Extracts the value of a cookie set by the response. */
|
||||
export function setCookieValue(response: Response, name: string) {
|
||||
return setCookie(response, name)
|
||||
?.split(";")[0]
|
||||
?.slice(name.length + 1);
|
||||
}
|
||||
|
||||
// --- users and jobs --------------------------------------------------------------
|
||||
let userCounter = 0;
|
||||
|
||||
export type TestUser = { id: number; email: string; password: string; token: string };
|
||||
|
||||
export async function signToken(id: number | string) {
|
||||
return app.decorator.jwt.sign({ id: String(id) });
|
||||
}
|
||||
|
||||
/** Creates a user directly in the database, bypassing the registration rules. */
|
||||
export async function createUser(password = "correct horse battery staple"): Promise<TestUser> {
|
||||
const email = `user${++userCounter}-${crypto.randomUUID()}@example.com`;
|
||||
const { id } = db
|
||||
.query("INSERT INTO users (email, password) VALUES (?, ?) RETURNING id")
|
||||
.get(email, await Bun.password.hash(password)) as { id: number };
|
||||
|
||||
return { id, email, password, token: await signToken(id) };
|
||||
}
|
||||
|
||||
type FileRow = { file_name: string; output_file_name: string; status: string };
|
||||
|
||||
export function createJob(
|
||||
userId: number | string,
|
||||
{ numFiles = 0, status = "not started", files = [] as FileRow[] } = {},
|
||||
) {
|
||||
const { id } = db
|
||||
.query(
|
||||
"INSERT INTO jobs (user_id, date_created, num_files, status) VALUES (?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.get(userId, new Date().toISOString(), numFiles, status) as { id: number };
|
||||
|
||||
for (const file of files) {
|
||||
db.query(
|
||||
"INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?, ?, ?, ?)",
|
||||
).run(id, file.file_name, file.output_file_name, file.status);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function jobDirs(userId: number | string, jobId: number | string) {
|
||||
return {
|
||||
uploads: `${uploadsDir}${userId}/${jobId}/`,
|
||||
output: `${outputDir}${userId}/${jobId}/`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Polls until the condition holds, e.g. for conversions that run in the background. */
|
||||
export async function waitFor(condition: () => boolean, timeoutMs = 5000) {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`Condition not met within ${timeoutMs} ms`);
|
||||
}
|
||||
await Bun.sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
// --- first run -------------------------------------------------------------------
|
||||
// FIRST_RUN is computed once when src/pages/user.tsx is loaded and only flips back when
|
||||
// the first account is registered. Until then almost every page redirects to /setup.
|
||||
// Walk through the setup once, like a fresh installation, and keep the responses for
|
||||
// tests/pages/firstRun.test.ts; every other test then starts from a configured instance.
|
||||
type Snapshot = { status: number; location: string | null; cookies: string[]; body: string };
|
||||
|
||||
async function snapshot(response: Promise<Response>): Promise<Snapshot> {
|
||||
const { status, headers } = await response;
|
||||
return {
|
||||
status,
|
||||
location: headers.get("location"),
|
||||
cookies: headers.getSetCookie(),
|
||||
body: await (await response).text(),
|
||||
};
|
||||
}
|
||||
|
||||
export const FIRST_ACCOUNT = { email: "first@example.com", password: crypto.randomUUID() };
|
||||
export const SECOND_ACCOUNT = { email: "second@example.com", password: crypto.randomUUID() };
|
||||
|
||||
async function walkThroughFirstRun() {
|
||||
// the first account must not depend on open registration
|
||||
setEnv({ ACCOUNT_REGISTRATION: false });
|
||||
try {
|
||||
return {
|
||||
setup: await snapshot(request("/setup")),
|
||||
login: await snapshot(request("/login")),
|
||||
home: await snapshot(request("/")),
|
||||
register: await snapshot(request("/register", { form: FIRST_ACCOUNT })),
|
||||
setupAfterwards: await snapshot(request("/setup")),
|
||||
registerAfterwards: await snapshot(request("/register", { form: SECOND_ACCOUNT })),
|
||||
};
|
||||
} finally {
|
||||
resetEnv();
|
||||
}
|
||||
}
|
||||
|
||||
export const firstRun = userPage.FIRST_RUN ? await walkThroughFirstRun() : undefined;
|
||||
|
||||
if (userPage.FIRST_RUN) {
|
||||
throw new Error("Registering the first account did not complete the first run");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { createJob, createUser, request, resetEnv, setEnv } from "./helpers/app";
|
||||
|
||||
afterEach(() => {
|
||||
resetEnv();
|
||||
});
|
||||
|
||||
const file = (name: string, status = "Done") => ({
|
||||
file_name: `${name}.vcf`,
|
||||
output_file_name: `${name}.csv`,
|
||||
status,
|
||||
});
|
||||
|
||||
async function historyOf(token: string) {
|
||||
return (await request("/history", { cookies: { auth: token } })).text();
|
||||
}
|
||||
|
||||
describe("GET /history", () => {
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const response = await request("/history", { headers: { accept: "text/html" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("redirects home when the history is hidden", async () => {
|
||||
setEnv({ HIDE_HISTORY: true });
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/history", { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
});
|
||||
|
||||
test("lists the user's jobs, newest first", async () => {
|
||||
const user = await createUser();
|
||||
const older = createJob(user.id, { numFiles: 1, files: [file("old")] });
|
||||
const newer = createJob(user.id, { numFiles: 1, files: [file("new")] });
|
||||
|
||||
const body = await historyOf(user.token);
|
||||
|
||||
expect(body.indexOf(`id="job-row-${newer}"`)).toBeGreaterThan(-1);
|
||||
expect(body.indexOf(`id="job-row-${older}"`)).toBeGreaterThan(
|
||||
body.indexOf(`id="job-row-${newer}"`),
|
||||
);
|
||||
expect(body).toContain(`href="/results/${newer}"`);
|
||||
expect(body).toContain(`action="/delete/${newer}"`);
|
||||
});
|
||||
|
||||
test("shows progress and file details of a job", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id, {
|
||||
numFiles: 3,
|
||||
status: "pending",
|
||||
files: [file("a"), file("b", "Failed, check logs")],
|
||||
});
|
||||
|
||||
const body = await historyOf(user.token);
|
||||
const row = body.slice(
|
||||
body.indexOf(`id="job-row-${job}"`),
|
||||
body.indexOf(`id="details-${job}"`),
|
||||
);
|
||||
const details = body.slice(body.indexOf(`id="details-${job}"`));
|
||||
|
||||
expect(row).toContain("<td>3</td>");
|
||||
expect(row).toContain('<td class="max-sm:hidden">2</td>');
|
||||
expect(row).toContain("<td>pending</td>");
|
||||
expect(details).toContain('title="a.vcf"');
|
||||
expect(details).toContain('title="b.csv"');
|
||||
});
|
||||
|
||||
test("hides jobs without files", async () => {
|
||||
const user = await createUser();
|
||||
const empty = createJob(user.id, { numFiles: 0 });
|
||||
|
||||
const body = await historyOf(user.token);
|
||||
|
||||
expect(body).not.toContain(`id="job-row-${empty}"`);
|
||||
});
|
||||
|
||||
test("does not show other users' jobs", async () => {
|
||||
const user = await createUser();
|
||||
const other = await createUser();
|
||||
const foreign = createJob(other.id, { numFiles: 1, files: [file("foreign")] });
|
||||
|
||||
const body = await historyOf(user.token);
|
||||
|
||||
expect(body).not.toContain(`id="job-row-${foreign}"`);
|
||||
expect(body).not.toContain("foreign.vcf");
|
||||
});
|
||||
|
||||
test("escapes file names and statuses", async () => {
|
||||
const user = await createUser();
|
||||
createJob(user.id, {
|
||||
numFiles: 1,
|
||||
status: "<b>pending</b>",
|
||||
files: [
|
||||
{ file_name: '"><script>alert(1)</script>.vcf', output_file_name: "x.csv", status: "Done" },
|
||||
],
|
||||
});
|
||||
|
||||
const body = await historyOf(user.token);
|
||||
|
||||
// neither the text nor the title attribute can be broken out of
|
||||
expect(body).not.toContain('"><script>');
|
||||
expect(body).not.toContain("<b>pending</b>");
|
||||
expect(body).toContain(""><script>alert(1)</script>.vcf");
|
||||
expect(body).toContain("<b>pending</b>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getAllInputs, getAllTargets } from "../../src/converters/main";
|
||||
import { createUser, request } from "./helpers/app";
|
||||
|
||||
describe("GET /converters", () => {
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const response = await request("/converters", { headers: { accept: "text/html" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("lists every converter with the number of inputs and targets", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const body = await (await request("/converters", { cookies: { auth: user.token } })).text();
|
||||
|
||||
for (const [converter, targets] of Object.entries(getAllTargets())) {
|
||||
expect(body).toContain(
|
||||
`<td>${converter}</td><td>Count: ${getAllInputs(converter).length}<ul>`,
|
||||
);
|
||||
expect(body).toContain(`Count: ${targets.length}<ul>`);
|
||||
}
|
||||
});
|
||||
|
||||
test("lists the inputs and targets of a converter", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const body = await (await request("/converters", { cookies: { auth: user.token } })).text();
|
||||
|
||||
expect(body).toContain(
|
||||
"<td>vcf</td><td>Count: 1<ul><li>vcf</li></ul></td><td>Count: 1<ul><li>csv</li></ul></td>",
|
||||
);
|
||||
});
|
||||
});
|
||||
+188
-1
@@ -1,5 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { buildDownloadUrl } from "../../src/helpers/buildDownloadUrl";
|
||||
import { createJob, createUser, request, resetEnv, setCookie, setEnv } from "./helpers/app";
|
||||
|
||||
afterEach(() => {
|
||||
resetEnv();
|
||||
});
|
||||
|
||||
test("encodes reserved characters in download filenames", () => {
|
||||
expect(buildDownloadUrl("", "1/2/", "clip #1?.gif")).toBe("/download/1/2/clip%20%231%3F.gif");
|
||||
@@ -10,3 +15,185 @@ test("preserves output path segments while encoding the filename", () => {
|
||||
"/convertx/download/user/job/%E5%A0%B1%E5%91%8A%20100%25.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
const done = (name: string) => ({
|
||||
file_name: `${name}.vcf`,
|
||||
output_file_name: `${name}.csv`,
|
||||
status: "Done",
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["GET", "/results"],
|
||||
["POST", "/progress"],
|
||||
])("%s %s/:jobId", (method, route) => {
|
||||
test("requires a session", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const response = await request(`${route}/${job}`, {
|
||||
method,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["another user's job", true],
|
||||
["a job that does not exist", false],
|
||||
])("answers 404 for %s", async (_, exists) => {
|
||||
const user = await createUser();
|
||||
const other = await createUser();
|
||||
const job = exists ? createJob(other.id, { numFiles: 1, files: [done("secret")] }) : 999_999;
|
||||
|
||||
const response = await request(`${route}/${job}`, { method, cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ message: "Job not found." });
|
||||
});
|
||||
|
||||
test("lists the converted files with view and download links", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id, { numFiles: 2, files: [done("a"), done("my file #1")] });
|
||||
|
||||
const body = await (
|
||||
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
expect(body).toContain(`href="/download/${user.id}/${job}/a.csv"`);
|
||||
expect(body).toContain(`href="/download/${user.id}/${job}/my%20file%20%231.csv"`);
|
||||
expect(body).toContain('download="my file #1.csv"');
|
||||
});
|
||||
|
||||
test.each(["Failed, check logs", "File type not supported"])(
|
||||
"shows no links for files with status '%s'",
|
||||
async (status) => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id, {
|
||||
numFiles: 1,
|
||||
files: [{ file_name: "a.xyz", output_file_name: "a.pdf", status }],
|
||||
});
|
||||
|
||||
const body = await (
|
||||
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
expect(body).toContain("Unavailable");
|
||||
expect(body).toContain(`<td>${status}</td>`);
|
||||
expect(body).not.toContain("/download/");
|
||||
},
|
||||
);
|
||||
|
||||
test("escapes file names and statuses", async () => {
|
||||
const user = await createUser();
|
||||
const name = '"><img src=x onerror=alert(1)>.csv';
|
||||
const job = createJob(user.id, {
|
||||
numFiles: 1,
|
||||
files: [{ file_name: "x", output_file_name: name, status: "<b>Done</b>" }],
|
||||
});
|
||||
|
||||
const body = await (
|
||||
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
// neither the text nor the download attribute can be broken out of
|
||||
expect(body).not.toContain('"><img');
|
||||
expect(body).not.toContain("<b>Done</b>");
|
||||
expect(body).toContain(""><img src=x onerror=alert(1)>.csv");
|
||||
expect(body).toContain("<b>Done</b>");
|
||||
});
|
||||
|
||||
test("disables delete and tar download while files are still converting", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id, { numFiles: 2, files: [done("a")] });
|
||||
|
||||
const body = await (
|
||||
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
expect(body.match(/aria-busy="true"/g)).toHaveLength(2);
|
||||
expect(body).toMatch(/<progress max="2"(?! value)/);
|
||||
});
|
||||
|
||||
test("enables delete and tar download once every file is converted", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id, { numFiles: 2, files: [done("a"), done("b")] });
|
||||
|
||||
const body = await (
|
||||
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
expect(body).not.toContain('aria-busy="true"');
|
||||
expect(body).toContain('<progress max="2" value="2"');
|
||||
expect(body).toContain(`action="/delete/${job}"`);
|
||||
expect(body).toContain(`href="/archive/${job}"`);
|
||||
});
|
||||
|
||||
test("prefixes all links with WEBROOT", async () => {
|
||||
setEnv({ WEBROOT: "/convertx" });
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id, { numFiles: 1, files: [done("a")] });
|
||||
|
||||
const body = await (
|
||||
await request(`${route}/${job}`, { method, cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
expect(body).toContain(`href="/convertx/download/${user.id}/${job}/a.csv"`);
|
||||
expect(body).toContain(`action="/convertx/delete/${job}"`);
|
||||
expect(body).toContain(`href="/convertx/archive/${job}"`);
|
||||
});
|
||||
|
||||
test("keeps the session and the current job", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const response = await request(`${route}/${job}`, {
|
||||
method,
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
});
|
||||
|
||||
expect(setCookie(response, "jobId")).toBeUndefined();
|
||||
expect(setCookie(response, "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
// no page sets job_id anymore (the job cookie is called jobId), but the handlers still
|
||||
// clear it for browsers that kept it
|
||||
test("clears the legacy job_id cookie", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const response = await request(`${route}/${job}`, {
|
||||
method,
|
||||
cookies: { auth: user.token, job_id: job },
|
||||
});
|
||||
|
||||
expect(setCookie(response, "job_id")).toContain("Max-Age=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /results/:jobId", () => {
|
||||
test("renders a full page with the results script", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const body = await (await request(`/results/${job}`, { cookies: { auth: user.token } })).text();
|
||||
|
||||
expect(body).toStartWith("<!doctype html>");
|
||||
expect(body).toContain("<title>ConvertX | Result</title>");
|
||||
expect(body).toContain('<script src="/results.js" defer');
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /progress/:jobId", () => {
|
||||
test("renders only the results fragment for polling", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const body = await (
|
||||
await request(`/progress/${job}`, { method: "POST", cookies: { auth: user.token } })
|
||||
).text();
|
||||
|
||||
expect(body).toStartWith('<article class="article">');
|
||||
expect(body).not.toContain("<html");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
app,
|
||||
createUser,
|
||||
db,
|
||||
request,
|
||||
resetEnv,
|
||||
setCookie,
|
||||
setCookieValue,
|
||||
setEnv,
|
||||
} from "./helpers/app";
|
||||
|
||||
afterEach(() => {
|
||||
resetEnv();
|
||||
});
|
||||
|
||||
type JobRow = { id: number; user_id: string; date_created: string; status: string };
|
||||
|
||||
function jobsOf(userId: number | string) {
|
||||
return db.query("SELECT * FROM jobs WHERE user_id = ? ORDER BY id").all(userId) as JobRow[];
|
||||
}
|
||||
|
||||
async function userIdOf(response: Response) {
|
||||
const payload = await app.decorator.jwt.verify(setCookieValue(response, "auth") ?? "");
|
||||
return payload ? payload.id : undefined;
|
||||
}
|
||||
|
||||
describe("GET / with accounts", () => {
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const response = await request("/");
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("redirects to the login page for an invalid session", async () => {
|
||||
const response = await request("/", { cookies: { auth: "not-a-jwt" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("clears the session of an account that no longer exists", async () => {
|
||||
const user = await createUser();
|
||||
db.query("DELETE FROM users WHERE id = ?").run(user.id);
|
||||
|
||||
const response = await request("/", { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
expect(setCookie(response, "auth")).toContain("Max-Age=0");
|
||||
// no job is started for the deleted account
|
||||
expect(jobsOf(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
test("starts a new job and remembers it in the jobId cookie", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/", { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const jobs = jobsOf(user.id);
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]?.status).toBe("not started");
|
||||
expect(Date.now() - Date.parse(jobs[0]?.date_created ?? "")).toBeLessThan(60_000);
|
||||
|
||||
expect(setCookieValue(response, "jobId")).toBe(String(jobs[0]?.id));
|
||||
expect(setCookie(response, "jobId")).toContain("HttpOnly");
|
||||
expect(setCookie(response, "jobId")).toContain("Secure");
|
||||
expect(setCookie(response, "jobId")).toContain("SameSite=Strict");
|
||||
expect(setCookie(response, "jobId")).toContain(`Max-Age=${24 * 60 * 60}`);
|
||||
});
|
||||
|
||||
test("starts a fresh job on every visit", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const first = await request("/", { cookies: { auth: user.token } });
|
||||
const second = await request("/", { cookies: { auth: user.token } });
|
||||
|
||||
expect(jobsOf(user.id)).toHaveLength(2);
|
||||
expect(Number(setCookieValue(second, "jobId"))).toBeGreaterThan(
|
||||
Number(setCookieValue(first, "jobId")),
|
||||
);
|
||||
});
|
||||
|
||||
test("offers every target of every converter", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const body = await (await request("/", { cookies: { auth: user.token } })).text();
|
||||
|
||||
expect(body).toContain('action="/convert"');
|
||||
expect(body).toContain('<option value="csv,vcf">csv</option>');
|
||||
expect(body).toContain('data-value="png,inkscape"');
|
||||
});
|
||||
|
||||
test("links the account and logoff pages in the header", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const body = await (await request("/", { cookies: { auth: user.token } })).text();
|
||||
|
||||
expect(body).toContain('href="/account"');
|
||||
expect(body).toContain('href="/logoff"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET / without accounts (ALLOW_UNAUTHENTICATED)", () => {
|
||||
test("creates an anonymous session and a job for visitors without a session", async () => {
|
||||
setEnv({ ALLOW_UNAUTHENTICATED: true });
|
||||
|
||||
const response = await request("/");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const userId = await userIdOf(response);
|
||||
// anonymous ids start at 2^24 so they never collide with registered accounts
|
||||
expect(Number(userId)).toBeGreaterThanOrEqual(2 ** 24);
|
||||
expect(setCookie(response, "auth")).toContain(`Max-Age=${24 * 60 * 60}`);
|
||||
expect(jobsOf(userId ?? "")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("gives every anonymous visitor a different id", async () => {
|
||||
setEnv({ ALLOW_UNAUTHENTICATED: true });
|
||||
|
||||
const first = await userIdOf(await request("/"));
|
||||
const second = await userIdOf(await request("/"));
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
test("shares one anonymous id when UNAUTHENTICATED_USER_SHARING is enabled", async () => {
|
||||
setEnv({ ALLOW_UNAUTHENTICATED: true, UNAUTHENTICATED_USER_SHARING: true });
|
||||
|
||||
expect(await userIdOf(await request("/"))).toBe("0");
|
||||
expect(await userIdOf(await request("/"))).toBe("0");
|
||||
});
|
||||
|
||||
test("hides the account and logoff links", async () => {
|
||||
setEnv({ ALLOW_UNAUTHENTICATED: true });
|
||||
|
||||
const body = await (await request("/")).text();
|
||||
|
||||
expect(body).not.toContain('href="/account"');
|
||||
expect(body).not.toContain('href="/logoff"');
|
||||
});
|
||||
|
||||
// BUG: the ALLOW_UNAUTHENTICATED branch runs before the session check, so every visit
|
||||
// replaces the existing session (even a registered account's) with a new anonymous id
|
||||
// and the user loses access to their previous jobs. This regressed the fix for #114
|
||||
// ("keep unauthenticated user logged in if allowed", bc4ad49) in 538c5b6. Remove
|
||||
// `.failing` once root.tsx verifies an existing session first.
|
||||
test.failing("keeps an existing session", async () => {
|
||||
setEnv({ ALLOW_UNAUTHENTICATED: true });
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/", { cookies: { auth: user.token } });
|
||||
|
||||
expect(setCookie(response, "auth")).toBeUndefined();
|
||||
expect(jobsOf(user.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { createJob, createUser, jobDirs, request } from "./helpers/app";
|
||||
|
||||
function uploadForm(...files: File[]) {
|
||||
const form = new FormData();
|
||||
for (const file of files) {
|
||||
form.append("file", file);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
describe("POST /upload", () => {
|
||||
test("requires a session", async () => {
|
||||
const response = await request("/upload", {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json" },
|
||||
body: uploadForm(new File(["x"], "a.txt")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("redirects home without a job", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/upload", {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token },
|
||||
body: uploadForm(new File(["x"], "a.txt")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
});
|
||||
|
||||
test("refuses to upload into another user's job", async () => {
|
||||
const user = await createUser();
|
||||
const other = await createUser();
|
||||
const otherJob = createJob(other.id);
|
||||
|
||||
const response = await request("/upload", {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token, jobId: otherJob },
|
||||
body: uploadForm(new File(["x"], "a.txt")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(existsSync(jobDirs(other.id, otherJob).uploads)).toBe(false);
|
||||
expect(existsSync(jobDirs(user.id, otherJob).uploads)).toBe(false);
|
||||
});
|
||||
|
||||
test("stores a single file in the job's upload directory", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const response = await request("/upload", {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
body: uploadForm(new File(["hello world"], "notes.txt")),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ message: "Files uploaded successfully." });
|
||||
expect(await readFile(`${jobDirs(user.id, job).uploads}notes.txt`, "utf-8")).toBe(
|
||||
"hello world",
|
||||
);
|
||||
});
|
||||
|
||||
test("stores multiple files at once", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
await request("/upload", {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
body: uploadForm(new File(["1"], "one.txt"), new File(["2"], "two.txt")),
|
||||
});
|
||||
|
||||
expect(readdirSync(jobDirs(user.id, job).uploads).sort()).toEqual(["one.txt", "two.txt"]);
|
||||
});
|
||||
|
||||
test("sanitizes file names so uploads cannot escape the job directory", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
const { uploads } = jobDirs(user.id, job);
|
||||
|
||||
await request("/upload", {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
body: uploadForm(new File(["evil"], "../../../evil.txt")),
|
||||
});
|
||||
|
||||
expect(readdirSync(uploads)).toEqual(["......evil.txt"]);
|
||||
expect(existsSync(resolve(uploads, "../../../evil.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a request without files", async () => {
|
||||
const user = await createUser();
|
||||
const job = createJob(user.id);
|
||||
|
||||
const response = await request("/upload", {
|
||||
method: "POST",
|
||||
cookies: { auth: user.token, jobId: job },
|
||||
body: new FormData(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import type { User } from "../../src/db/types";
|
||||
import {
|
||||
app,
|
||||
createUser,
|
||||
db,
|
||||
request,
|
||||
resetEnv,
|
||||
setCookie,
|
||||
setCookieValue,
|
||||
setEnv,
|
||||
signToken,
|
||||
} from "./helpers/app";
|
||||
|
||||
afterEach(() => {
|
||||
resetEnv();
|
||||
});
|
||||
|
||||
function findUser(email: string) {
|
||||
return db.query("SELECT * FROM users WHERE email = ?").get(email) as User | null;
|
||||
}
|
||||
|
||||
function findUserById(id: number) {
|
||||
return db.query("SELECT * FROM users WHERE id = ?").get(id) as User | null;
|
||||
}
|
||||
|
||||
const uniqueEmail = () => `${crypto.randomUUID()}@example.com`;
|
||||
|
||||
describe("GET /register", () => {
|
||||
test("redirects to the login page while registration is closed", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: false });
|
||||
|
||||
const response = await request("/register");
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("shows the registration form when registration is open", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: true });
|
||||
|
||||
const response = await request("/register");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain('value="Register"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /register", () => {
|
||||
test("does not create an account while registration is closed", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: false });
|
||||
const email = uniqueEmail();
|
||||
|
||||
const response = await request("/register", { form: { email, password: "secret" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
expect(findUser(email)).toBeNull();
|
||||
});
|
||||
|
||||
test("creates the account, logs it in and redirects home when registration is open", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: true });
|
||||
const email = uniqueEmail();
|
||||
|
||||
const response = await request("/register", { form: { email, password: "secret" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
|
||||
const user = findUser(email);
|
||||
expect(user).not.toBeNull();
|
||||
expect(await Bun.password.verify("secret", user?.password ?? "")).toBe(true);
|
||||
|
||||
const token = setCookieValue(response, "auth") ?? "";
|
||||
expect(await app.decorator.jwt.verify(token)).toMatchObject({ id: String(user?.id) });
|
||||
expect(setCookie(response, "auth")).toContain("HttpOnly");
|
||||
expect(setCookie(response, "auth")).toContain("Secure");
|
||||
expect(setCookie(response, "auth")).toContain("SameSite=Strict");
|
||||
expect(setCookie(response, "auth")).toContain(`Max-Age=${7 * 24 * 60 * 60}`);
|
||||
});
|
||||
|
||||
test("omits the Secure cookie flag when plain HTTP is allowed", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: true, HTTP_ALLOWED: true });
|
||||
|
||||
const response = await request("/register", {
|
||||
form: { email: uniqueEmail(), password: "secret" },
|
||||
});
|
||||
|
||||
expect(setCookie(response, "auth")).not.toContain("Secure");
|
||||
});
|
||||
|
||||
test("rejects an email that is already registered", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: true });
|
||||
const existing = await createUser();
|
||||
|
||||
const response = await request("/register", {
|
||||
form: { email: existing.email, password: "another password" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({ message: "Email already in use." });
|
||||
// the existing account keeps its password
|
||||
expect(
|
||||
await Bun.password.verify(existing.password, findUser(existing.email)?.password ?? ""),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a request without a password", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: true });
|
||||
|
||||
const response = await request("/register", { form: { email: uniqueEmail() } });
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
const expectErrorPage = async (response: Response, message: string) => {
|
||||
expect(response.headers.get("content-type")).toContain("text/html");
|
||||
const body = await response.text();
|
||||
expect(body).toContain('role="alert"');
|
||||
expect(body).toContain(message);
|
||||
};
|
||||
describe("GET /login", () => {
|
||||
test("shows the login form without a register link while registration is closed", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: false });
|
||||
|
||||
const body = await (await request("/login")).text();
|
||||
|
||||
expect(body).toContain('value="Login"');
|
||||
expect(body).not.toContain('href="/register"');
|
||||
});
|
||||
|
||||
test("links to the registration when it is open", async () => {
|
||||
setEnv({ ACCOUNT_REGISTRATION: true });
|
||||
|
||||
const body = await (await request("/login")).text();
|
||||
|
||||
expect(body).toContain('href="/register"');
|
||||
});
|
||||
|
||||
test("redirects users that are already logged in to the home page", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/login", { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
});
|
||||
|
||||
test("clears an invalid session and shows the login form", async () => {
|
||||
const response = await request("/login", { cookies: { auth: "not-a-jwt" } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(setCookie(response, "auth")).toContain("Max-Age=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /login", () => {
|
||||
test("logs in with valid credentials and redirects home", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/login", {
|
||||
form: { email: user.email, password: user.password },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
const token = setCookieValue(response, "auth") ?? "";
|
||||
expect(await app.decorator.jwt.verify(token)).toMatchObject({ id: String(user.id) });
|
||||
expect(setCookie(response, "auth")).toContain("HttpOnly");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the password is wrong", "wrong password"],
|
||||
["the password is empty", ""],
|
||||
])("rejects the login when %s", async (_, password) => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/login", { form: { email: user.email, password } });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expectErrorPage(response, "Invalid credentials.");
|
||||
expect(setCookie(response, "auth")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("gives the same answer for unknown emails, so accounts cannot be enumerated", async () => {
|
||||
const response = await request("/login", {
|
||||
form: { email: uniqueEmail(), password: "whatever" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expectErrorPage(response, "Invalid credentials.");
|
||||
});
|
||||
|
||||
test("keeps the entered email in the form after a failed login", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/login", {
|
||||
form: { email: user.email, password: "wrong password" },
|
||||
});
|
||||
|
||||
expect(await response.text()).toContain(`value="${user.email}"`);
|
||||
});
|
||||
|
||||
test("rejects a request without a password", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/login", { form: { email: user.email } });
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["GET", "POST"])("%s /logoff", (method) => {
|
||||
test("clears the session and redirects to the login page", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/logoff", { method, cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
expect(setCookie(response, "auth")).toContain("Max-Age=0");
|
||||
});
|
||||
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const response = await request("/logoff", { method });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
expect(setCookie(response, "auth")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /account", () => {
|
||||
test("redirects to the login page without a session", async () => {
|
||||
const response = await request("/account", { headers: { accept: "text/html" } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("shows the account form with the current email", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await request("/account", { cookies: { auth: user.token } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toContain(`value="${user.email}"`);
|
||||
});
|
||||
|
||||
test("redirects home when the account no longer exists", async () => {
|
||||
const response = await request("/account", { cookies: { auth: await signToken(999_999) } });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /account", () => {
|
||||
const updateAccount = (
|
||||
token: string,
|
||||
form: { email?: string; newPassword?: string; password: string },
|
||||
) =>
|
||||
request("/account", {
|
||||
cookies: { auth: token },
|
||||
form: { email: "", newPassword: "", ...form },
|
||||
});
|
||||
|
||||
test("rejects the update when the current password is wrong", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await updateAccount(user.token, {
|
||||
email: uniqueEmail(),
|
||||
password: "wrong password",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expectErrorPage(response, "Invalid credentials.");
|
||||
expect(findUserById(user.id)?.email).toBe(user.email);
|
||||
});
|
||||
|
||||
test("changes the email", async () => {
|
||||
const user = await createUser();
|
||||
const newEmail = uniqueEmail();
|
||||
|
||||
const response = await updateAccount(user.token, { email: newEmail, password: user.password });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/");
|
||||
expect(findUserById(user.id)?.email).toBe(newEmail);
|
||||
});
|
||||
|
||||
test("allows submitting the unchanged email", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const response = await updateAccount(user.token, {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(findUserById(user.id)?.email).toBe(user.email);
|
||||
});
|
||||
|
||||
test("refuses an email that belongs to another account", async () => {
|
||||
const user = await createUser();
|
||||
const other = await createUser();
|
||||
|
||||
const response = await updateAccount(user.token, {
|
||||
email: other.email,
|
||||
password: user.password,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
await expectErrorPage(response, "Email already in use.");
|
||||
expect(findUserById(user.id)?.email).toBe(user.email);
|
||||
});
|
||||
|
||||
test("changes the password", async () => {
|
||||
const user = await createUser();
|
||||
|
||||
await updateAccount(user.token, { newPassword: "new password", password: user.password });
|
||||
|
||||
const login = await request("/login", {
|
||||
form: { email: user.email, password: "new password" },
|
||||
});
|
||||
expect(login.status).toBe(302);
|
||||
const oldLogin = await request("/login", {
|
||||
form: { email: user.email, password: user.password },
|
||||
});
|
||||
expect(oldLogin.status).toBe(403);
|
||||
});
|
||||
|
||||
test("keeps everything unchanged when email and new password are left blank", async () => {
|
||||
const user = await createUser();
|
||||
const before = findUserById(user.id);
|
||||
|
||||
const response = await updateAccount(user.token, { password: user.password });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(findUserById(user.id)).toEqual(before);
|
||||
});
|
||||
|
||||
test("requires a session cookie", async () => {
|
||||
const response = await request("/account", {
|
||||
form: { email: "", newPassword: "", password: "secret" },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("redirects to the login page for an invalid session", async () => {
|
||||
const response = await updateAccount("not-a-jwt", { password: "secret" });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
});
|
||||
|
||||
test("clears the session of an account that no longer exists", async () => {
|
||||
const response = await updateAccount(await signToken(999_999), { password: "secret" });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/login");
|
||||
expect(setCookie(response, "auth")).toContain("Max-Age=0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Loaded before every test file (see bunfig.toml).
|
||||
import { afterAll, mock } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const testRoot = mkdtempSync(join(tmpdir(), "convertx-tests-"));
|
||||
|
||||
// Tests must never touch ./data/mydb.sqlite or a DB_PATH from the developer's shell.
|
||||
process.env.DB_PATH = join(testRoot, "test.sqlite");
|
||||
process.env.CONVERTX_TEST_ROOT = testRoot;
|
||||
|
||||
// Settings from the developer's shell or a .env file (which Bun loads automatically) must
|
||||
// not change what the tests see, e.g. WEBROOT would move every page. src/helpers/env.ts
|
||||
// reads them once at import; tests switch them with setEnv() from tests/pages/helpers/app.ts.
|
||||
for (const name of [
|
||||
"ACCOUNT_REGISTRATION",
|
||||
"HTTP_ALLOWED",
|
||||
"ALLOW_UNAUTHENTICATED",
|
||||
"AUTO_DELETE_EVERY_N_HOURS",
|
||||
"HIDE_HISTORY",
|
||||
"BRANDING",
|
||||
"WEBROOT",
|
||||
"LANGUAGE",
|
||||
"MAX_CONVERT_PROCESS",
|
||||
"UNAUTHENTICATED_USER_SHARING",
|
||||
]) {
|
||||
delete process.env[name];
|
||||
}
|
||||
|
||||
// TZ is read as TIMEZONE for the times on the history page. Deleting it would not help:
|
||||
// Bun would fall back to the system timezone instead of bun test's UTC default (or keep
|
||||
// the shell's zone). Assigning it switches the runtime timezone as well, so pin it.
|
||||
process.env.TZ = "UTC";
|
||||
|
||||
// Several pages import uploadsDir/outputDir from src/index.tsx. Importing the real module
|
||||
// would start the server, spawn every converter's --version and schedule the job cleanup,
|
||||
// and its relative ./data paths would mix test files with real conversions. Provide the
|
||||
// constants directly, pointing at the temp directory.
|
||||
mock.module("../src/index", () => ({
|
||||
uploadsDir: `${join(testRoot, "uploads")}/`,
|
||||
outputDir: `${join(testRoot, "output")}/`,
|
||||
}));
|
||||
|
||||
// a preload's afterAll runs once after all test files (and once per file with --isolate)
|
||||
afterAll(() => {
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { Elysia } from "elysia";
|
||||
import { WEBROOT } from "../../src/helpers/env";
|
||||
import { userService } from "../../src/services/user";
|
||||
|
||||
let protectedHandlerCalls = 0;
|
||||
|
||||
const app = new Elysia()
|
||||
.use(userService)
|
||||
.get(
|
||||
"/me",
|
||||
({ user }) => {
|
||||
protectedHandlerCalls++;
|
||||
return user;
|
||||
},
|
||||
{ auth: true },
|
||||
)
|
||||
.post("/me", ({ user }) => user, { auth: true })
|
||||
.get("/public", () => "public")
|
||||
.post("/sign-in", ({ body }) => body, { body: "signIn" })
|
||||
.get("/session", ({ cookie: { auth, jobId } }) => ({ auth: auth.value, jobId: jobId.value }), {
|
||||
cookie: "session",
|
||||
});
|
||||
|
||||
const signer = app.decorator.jwt;
|
||||
|
||||
// tokens the service must reject
|
||||
const foreignToken = await new Elysia()
|
||||
.use(jwt({ name: "jwt", secret: randomUUID() }))
|
||||
.decorator.jwt.sign({ id: "42" });
|
||||
const expiredToken = await signer.sign({ id: "42", exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
// payload violates the { id: string } schema
|
||||
const wrongSchemaToken = await signer.sign({ id: 42 as unknown as string });
|
||||
|
||||
function request(path: string, init: RequestInit & { cookie?: string } = {}) {
|
||||
const { cookie, headers, ...rest } = init;
|
||||
return app.handle(
|
||||
new Request(`http://localhost${path}`, {
|
||||
...rest,
|
||||
headers: { ...(cookie ? { cookie } : {}), ...headers },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const htmlPage = { accept: "text/html" };
|
||||
const apiCall = { accept: "application/json" };
|
||||
|
||||
function expectAuthCookieCleared(response: Response) {
|
||||
const setCookie = response.headers.get("set-cookie") ?? "";
|
||||
expect(setCookie).toMatch(/^auth=;/);
|
||||
expect(setCookie).toContain("Max-Age=0");
|
||||
}
|
||||
|
||||
describe("auth macro with a valid token", () => {
|
||||
test("resolves the token payload as user", async () => {
|
||||
const token = await signer.sign({ id: "42" });
|
||||
|
||||
const response = await request("/me", { cookie: `auth=${token}` });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ id: "42" });
|
||||
});
|
||||
|
||||
test("issues tokens that expire after seven days", async () => {
|
||||
const token = await signer.sign({ id: "42" });
|
||||
|
||||
const user = (await (await request("/me", { cookie: `auth=${token}` })).json()) as {
|
||||
exp: number;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
expect(user.exp - user.iat).toBe(7 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
test("accepts an optional jobId cookie", async () => {
|
||||
const token = await signer.sign({ id: "42" });
|
||||
|
||||
const response = await request("/me", { cookie: `auth=${token}; jobId=7` });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("does not touch the auth cookie", async () => {
|
||||
const token = await signer.sign({ id: "42" });
|
||||
|
||||
const response = await request("/me", { cookie: `auth=${token}` });
|
||||
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth macro without a token", () => {
|
||||
test("redirects HTML page requests to the login page", async () => {
|
||||
const response = await request("/me", { headers: htmlPage, redirect: "manual" });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
|
||||
expect(await response.json()).toEqual({ success: false, message: "Redirecting to login" });
|
||||
});
|
||||
|
||||
test("redirects HEAD page requests to the login page", async () => {
|
||||
const response = await request("/me", { method: "HEAD", headers: htmlPage });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
|
||||
});
|
||||
|
||||
test("returns 401 JSON for API requests", async () => {
|
||||
const response = await request("/me", { headers: apiCall });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
|
||||
});
|
||||
|
||||
test("returns 401 for non-GET requests even if they accept HTML", async () => {
|
||||
const response = await request("/me", { method: "POST", headers: htmlPage });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("treats an empty auth cookie as missing", async () => {
|
||||
const response = await request("/me", { cookie: "auth=", headers: htmlPage });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
// there is no session to clear
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
test("does not run the protected handler", async () => {
|
||||
const callsBefore = protectedHandlerCalls;
|
||||
|
||||
await request("/me", { headers: apiCall });
|
||||
await request("/me", { headers: htmlPage });
|
||||
|
||||
expect(protectedHandlerCalls).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth macro with an invalid token", () => {
|
||||
test.each([
|
||||
["a malformed token", "not-a-jwt"],
|
||||
["a token signed with another secret", foreignToken],
|
||||
["an expired token", expiredToken],
|
||||
["a token whose payload does not match the schema", wrongSchemaToken],
|
||||
])("rejects %s and clears the auth cookie", async (_, token) => {
|
||||
const response = await request("/me", { cookie: `auth=${token}`, headers: apiCall });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ success: false, message: "Unauthorized" });
|
||||
expectAuthCookieCleared(response);
|
||||
});
|
||||
|
||||
test("redirects HTML page requests to login and clears the auth cookie", async () => {
|
||||
const response = await request("/me", { cookie: "auth=not-a-jwt", headers: htmlPage });
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe(`${WEBROOT}/login`);
|
||||
expectAuthCookieCleared(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes without auth", () => {
|
||||
test("are reachable without a token", async () => {
|
||||
const response = await request("/public", { headers: htmlPage });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("public");
|
||||
});
|
||||
});
|
||||
|
||||
describe("models", () => {
|
||||
const postJson = (body: unknown) =>
|
||||
request("/sign-in", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test("signIn accepts an email and a password", async () => {
|
||||
const response = await postJson({ email: "user@example.com", password: "secret" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ email: "user@example.com", password: "secret" });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the password is missing", { email: "user@example.com" }],
|
||||
["the email is missing", { password: "secret" }],
|
||||
["the email is not a string", { email: 42, password: "secret" }],
|
||||
])("signIn rejects the body when %s", async (_, body) => {
|
||||
const response = await postJson(body);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("session requires an auth cookie", async () => {
|
||||
const response = await request("/session", { cookie: "jobId=3" });
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
test("session accepts an auth cookie with an optional jobId", async () => {
|
||||
const withJob = await request("/session", { cookie: "auth=token; jobId=3" });
|
||||
const withoutJob = await request("/session", { cookie: "auth=token" });
|
||||
|
||||
expect(await withJob.json()).toEqual({ auth: "token", jobId: "3" });
|
||||
expect(await withoutJob.json()).toEqual({ auth: "token" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user