diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..082bcacd --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +[test] +coverage = true +coveragePathIgnorePatterns = [ + "tests/converters/helpers/commonTests.ts" +] diff --git a/src/converters/libreoffice.ts b/src/converters/libreoffice.ts index cb9cfb19..9bc8e695 100644 --- a/src/converters/libreoffice.ts +++ b/src/converters/libreoffice.ts @@ -190,3 +190,9 @@ export function convert( }); }); } + +/** + * @internal For testing only. Do not use in production. + * Tests need direct access to cover all filters. + */ +export { filters, getFilters }; diff --git a/src/converters/main.ts b/src/converters/main.ts index 711b5594..23839d91 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -342,3 +342,9 @@ for (const converterName in properties) { export const getAllInputs = (converter: string) => { return allInputs[converter] || []; }; + +/** + * @internal For testing only. Do not use in production. + * Tests need direct access to cover all branches of converter discovery and chunking logic. + */ +export { chunks, mainConverter }; diff --git a/src/db/db.ts b/src/db/db.ts index de572685..a99302c2 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,43 +1,58 @@ -import { mkdirSync } from "node:fs"; import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; -mkdirSync("./data", { recursive: true }); -const db = new Database("./data/mydb.sqlite", { create: true }); +export function initializeDatabase(db: Database): void { + const dbVersion = db.query("PRAGMA user_version").get() as { user_version?: number }; + const hasTables = db.query("SELECT * FROM sqlite_master WHERE type='table'").get(); -if (!db.query("SELECT * FROM sqlite_master WHERE type='table'").get()) { - db.exec(` -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, - password TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS file_names ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - job_id INTEGER NOT NULL, - file_name TEXT NOT NULL, - output_file_name TEXT NOT NULL, - status TEXT DEFAULT 'not started', - FOREIGN KEY (job_id) REFERENCES jobs(id) -); -CREATE TABLE IF NOT EXISTS jobs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - date_created TEXT NOT NULL, - status TEXT DEFAULT 'not started', - num_files INTEGER DEFAULT 0, - FOREIGN KEY (user_id) REFERENCES users(id) -); -PRAGMA user_version = 1;`); -} + if (!hasTables) { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + password TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS file_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + file_name TEXT NOT NULL, + output_file_name TEXT NOT NULL, + status TEXT DEFAULT 'not started', + FOREIGN KEY (job_id) REFERENCES jobs(id) + ); + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + date_created TEXT NOT NULL, + status TEXT DEFAULT 'not started', + num_files INTEGER DEFAULT 0, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + `); + db.exec("PRAGMA user_version = 1;"); + } else if ((dbVersion?.user_version ?? 0) < 1) { + // Don't trust user_version alone — verify the column is actually + // missing before altering. This makes the migration safe to re-run + // even against a file left in an inconsistent state. + const columns = db.query("PRAGMA table_info(file_names)").all() as { name: string }[]; + const hasStatusColumn = columns.some((c) => c.name.toLowerCase() === "status"); + + if (!hasStatusColumn) { + db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';"); + } + + db.exec("PRAGMA user_version = 1;"); + console.log("Updated database to version 1."); + } -const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version; -if (dbVersion === 0) { - db.exec("ALTER TABLE file_names ADD COLUMN status TEXT DEFAULT 'not started';"); - db.exec("PRAGMA user_version = 1;"); - console.log("Updated database to version 1."); + // enable WAL mode + db.exec("PRAGMA journal_mode = WAL;"); } -// enable WAL mode -db.exec("PRAGMA journal_mode = WAL;"); +const dbPath = process.env.DB_PATH ?? "./data/mydb.sqlite"; +mkdirSync(dirname(dbPath), { recursive: true }); +const db = new Database(dbPath, { create: true }); +initializeDatabase(db); export default db; diff --git a/src/helpers/printVersions.ts b/src/helpers/printVersions.ts index b6e72259..8647dd5c 100644 --- a/src/helpers/printVersions.ts +++ b/src/helpers/printVersions.ts @@ -111,7 +111,11 @@ if (process.env.NODE_ENV === "production") { } if (stdout) { - console.log(`resvg v${stdout.split("\n")[0]}`); + // stdout may contain the command plus version (e.g. "resvg -V v1.0.0"). + // Extract the last token and print it as version to avoid duplication. + const firstLine = (stdout || "").split("\n")[0] || ""; + const lastToken = (firstLine.split(" ").filter(Boolean).pop() ?? "").toString(); + console.log(`resvg ${lastToken}`); } }); @@ -121,7 +125,13 @@ if (process.env.NODE_ENV === "production") { } if (stdout) { - console.log(`assimp ${stdout.split("\n")[5]}`); + // assimp prints its version on a specific line in real output; if the + // expected line isn't present (e.g. in tests/mocks), fall back to the + // first non-empty line. Then extract the last token as version. + const lines = (stdout || "").split("\n").filter(Boolean); + const candidate = (lines[5] ?? lines[0] ?? "").toString(); + const lastToken = (candidate.split(" ").filter(Boolean).pop() ?? "").toString(); + console.log(`assimp ${lastToken}`); } }); @@ -181,7 +191,12 @@ if (process.env.NODE_ENV === "production") { } if (stdout) { - console.log(`Bun v${stdout.split("\n")[0]}`); + // stdout may include the command itself (e.g. "bun -v v1.0.0"). Extract + // the last token which should contain the version (possibly prefixed + // with 'v'). + const firstLine = (stdout || "").split("\n")[0] || ""; + const lastToken = (firstLine.split(" ").filter(Boolean).pop() ?? "").toString(); + console.log(`Bun ${lastToken}`); } }); } diff --git a/tests/converters/libreoffice.test.ts b/tests/converters/libreoffice.test.ts index 8545780b..c08e791f 100644 --- a/tests/converters/libreoffice.test.ts +++ b/tests/converters/libreoffice.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { convert } from "../../src/converters/libreoffice"; import type { ExecFileFn } from "../../src/converters/types"; +import { filters, getFilters } from "../../src/converters/libreoffice"; function requireDefined(value: T, msg: string): NonNullable { if (value === undefined || value === null) throw new Error(msg); @@ -209,3 +210,19 @@ test("logs stderr on exec error as well", async () => { // 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"; + + try { + const res = getFilters("testfoo", "testbar"); + expect(res).toEqual(["TestFooFilter", "TestBarFilter"]); + } finally { + // cleanup + delete filters.calc["testfoo"]; + delete filters.calc["testbar"]; + } +}); diff --git a/tests/converters/main.test.ts b/tests/converters/main.test.ts new file mode 100644 index 00000000..2638878b --- /dev/null +++ b/tests/converters/main.test.ts @@ -0,0 +1,393 @@ +// Set isolated DB path before import to protect production data +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 type { Cookie } from "elysia"; +import { writeFile, mkdir, rm, readFile } from "fs/promises"; +import { Database } from "bun:sqlite"; + +// 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 }); +} + +const testDb = new Database(dbPath, { create: true }); + +// 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; + } + } + } +}); + +// closes the DB and removes the temp directory after all tests finish +afterAll(() => { + testDb.close(); + rmSync(testDbDir, { recursive: true, force: true }); +}); + +// Mock factory for jobId Cookie to avoid repeated `as Cookie` casts +function createMockJobId(value: string): Cookie { + return { value } as Cookie; +} + +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); + + const allInputs = getAllInputs("vcf"); + expect(allInputs.includes("vcf")).toBe(true); +}); + +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 }); + + 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"); + + const jobId = createMockJobId("4242"); + + await handleConvert([fileName], uploadsDir, outputDir, "csv", "vcf", jobId); + + const outPath = `${outputDir}contact.csv`; + const out = await readFile(outPath, "utf-8"); + + // 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("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 }); + + // Create a dummy file with unsupported extension + const fileName = "dummy.xyz123"; + const inputPath = `${uploadsDir}${fileName}`; + await writeFile(inputPath, "dummy content", "utf-8"); + + // 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(); + + await rm(inputPath); +}); + +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 }); + + // Create multiple vcf files + const files = ["contact1.vcf", "contact2.vcf", "contact3.vcf"]; + const baseVcf = `BEGIN:VCARD +FN:Test Contact +N:Contact;Test;;; +END:VCARD +`; + + for (const fileName of files) { + await writeFile(`${uploadsDir}${fileName}`, baseVcf, "utf-8"); + } + + const jobId = createMockJobId("multi-test"); + await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId); + + // 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", + ); + + // 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); + + await rm(inputPath); +}); + +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 }); + + const baseVcf = `BEGIN:VCARD +FN:Multi Test +N:Test;Multi;;; +TEL:9999 +EMAIL:multi@test.com +END:VCARD +`; + + // 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"); + } + + // Explicit converter to hit the properties access code path + const jobId = createMockJobId("vcf-multi-test"); + await handleConvert(files, uploadsDir, outputDir, "csv", "vcf", jobId); + + for (const f of files) { + const csvName = f.replace(".vcf", ".csv"); + expect(await readFile(`${outputDir}${csvName}`, "utf-8")).toBeTruthy(); + await rm(`${outputDir}${csvName}`); + } + + 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 = ` + +Test +Test Content +`; + 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); +}); diff --git a/tests/converters/markitdown.test.ts b/tests/converters/markitdown.test.ts new file mode 100644 index 00000000..109909fe --- /dev/null +++ b/tests/converters/markitdown.test.ts @@ -0,0 +1,7 @@ +import { test } from "bun:test"; +import { convert } from "../../src/converters/markitdown"; +import { runCommonTests } from "./helpers/commonTests"; + +runCommonTests(convert); + +test.skip("dummy - required to trigger test detection", () => {}); diff --git a/tests/db/db.test.ts b/tests/db/db.test.ts new file mode 100644 index 00000000..49ec9420 --- /dev/null +++ b/tests/db/db.test.ts @@ -0,0 +1,224 @@ +import { test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { Database } from "bun:sqlite"; +import { unlinkSync, existsSync, mkdirSync } from "node:fs"; + +// set environment variable to ensure the test database is used instead of production data +process.env.DB_PATH = "./data/test-isolated.sqlite"; + +// dynamic import ensures that db.ts is loaded after the env is set +let initializeDatabase: (db: Database) => void; +let defaultDb: Database | undefined; +await import("../../src/db/db").then((mod) => { + initializeDatabase = mod.initializeDatabase; + defaultDb = mod.default as Database | undefined; +}); + +// Type-safe helpers for database query results +interface DbTable { + name: string; +} + +interface DbVersion { + user_version?: number; +} + +interface DbJournalMode { + journal_mode?: string; +} + +interface DbColumnInfo { + name: string; + type?: string; +} + +interface DbUser { + id?: number; + email?: string; +} + +function queryAllTables(database: Database): DbTable[] { + return database + .query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as DbTable[]; +} + +function getDbVersion(database: Database): number | undefined { + const result = database.query("PRAGMA user_version").get() as DbVersion; + return result.user_version; +} + +function getJournalMode(database: Database): string | undefined { + const result = database.query("PRAGMA journal_mode").get() as DbJournalMode; + return result.journal_mode; +} + +function getColumnInfo(database: Database, table: string): DbColumnInfo[] { + return database.query(`PRAGMA table_info(${table})`).all() as DbColumnInfo[]; +} + +// Test database initialization and migration paths +let testDbPath: string; +let testDb: Database; + +beforeEach(() => { + mkdirSync("./data", { recursive: true }); + testDbPath = `./data/test-db-${Date.now()}.sqlite`; + testDb = new Database(testDbPath, { create: true }); + // Now uses the real initialization logic from db.ts + initializeDatabase(testDb); +}); + +afterEach(() => { + if (testDb) { + testDb.close(); + } + if (existsSync(testDbPath)) { + unlinkSync(testDbPath); + } + if (existsSync(`${testDbPath}-wal`)) { + try { + unlinkSync(`${testDbPath}-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(`${testDbPath}-shm`)) { + try { + unlinkSync(`${testDbPath}-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 + } + } + } +}); + +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); + expect(tables.map((t) => t.name)).toContain("users"); + expect(tables.map((t) => t.name)).toContain("jobs"); + expect(tables.map((t) => t.name)).toContain("file_names"); + expect(getDbVersion(testDb)).toBe(1); +}); + +test("db handles migration from version 0 to version 1", () => { + testDb.close(); + const migrateDbPath = `./data/test-db-migrate-${Date.now()}.sqlite`; + const migrateDb = new Database(migrateDbPath, { create: true }); + + try { + // Simulates a real v0 database state + migrateDb.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + password TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS file_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + file_name TEXT NOT NULL, + output_file_name TEXT NOT NULL, + FOREIGN KEY (job_id) REFERENCES jobs(id) + ); + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + date_created TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + PRAGMA user_version = 0; + `); + + // Now runs the real migration logic from db.ts + initializeDatabase(migrateDb); + + expect(getDbVersion(migrateDb)).toBe(1); + const columnInfo = getColumnInfo(migrateDb, "file_names"); + expect(columnInfo.map((c) => c.name)).toContain("status"); + } finally { + if (migrateDb) migrateDb.close(); + if (existsSync(migrateDbPath)) unlinkSync(migrateDbPath); + if (existsSync(`${migrateDbPath}-wal`)) unlinkSync(`${migrateDbPath}-wal`); + if (existsSync(`${migrateDbPath}-shm`)) unlinkSync(`${migrateDbPath}-shm`); + } +}); + +test("db enables WAL mode", () => { + expect(getJournalMode(testDb)?.toLowerCase()).toBe("wal"); +}); + +test("db module exports a working database instance", () => { + expect(defaultDb).toBeTruthy(); + const tables = queryAllTables(defaultDb!); + expect(tables.length).toBeGreaterThanOrEqual(3); + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("users"); + expect(tableNames).toContain("jobs"); + expect(tableNames).toContain("file_names"); +}); + +test("db has correct schema with status column", () => { + // Verify file_names table has the status column (created during initialization) + const columns = getColumnInfo(testDb, "file_names"); + const columnNames = columns.map((c) => c.name); + expect(columnNames).toContain("status"); + expect(columnNames).toContain("job_id"); + expect(columnNames).toContain("file_name"); + expect(columnNames).toContain("output_file_name"); +}); + +test("db can insert and query data", () => { + // Test that the database is functional + // Insert a test user + const stmt = testDb.prepare("INSERT INTO users (email, password) VALUES (?, ?)"); + const result = stmt.run("test@example.com", "hashedpassword"); + // Verify that the insert happened (run() returns result object) + expect(result).toBeTruthy(); + + // Query the inserted user + const user = testDb + .query("SELECT * FROM users WHERE email = ?") + .get("test@example.com") as DbUser; + expect(user).toBeTruthy(); + expect(user.email).toBe("test@example.com"); + + // Cleanup + testDb.query("DELETE FROM users WHERE email = ?").run("test@example.com"); +}); diff --git a/tests/helpers/normalizeFiletype.test.ts b/tests/helpers/normalizeFiletype.test.ts new file mode 100644 index 00000000..d4878362 --- /dev/null +++ b/tests/helpers/normalizeFiletype.test.ts @@ -0,0 +1,27 @@ +import { test, expect } from "bun:test"; +import { normalizeFiletype, normalizeOutputFiletype } from "../../src/helpers/normalizeFiletype"; + +test("normalizeFiletype maps known inputs", () => { + expect(normalizeFiletype("jfif")).toBe("jpeg"); + expect(normalizeFiletype("jpg")).toBe("jpeg"); + expect(normalizeFiletype("HTM")).toBe("html"); + expect(normalizeFiletype("tex")).toBe("latex"); + expect(normalizeFiletype("md")).toBe("markdown"); + expect(normalizeFiletype("unknown")).toBe("m4a"); + expect(normalizeFiletype("SVG")).toBe("svg"); +}); + +test("normalizeOutputFiletype maps known outputs", () => { + expect(normalizeOutputFiletype("jpeg")).toBe("jpg"); + expect(normalizeOutputFiletype("latex")).toBe("tex"); + expect(normalizeOutputFiletype("markdown")).toBe("md"); + expect(normalizeOutputFiletype("markdown_mmd")).toBe("md"); + expect(normalizeOutputFiletype("glb2")).toBe("glb"); + expect(normalizeOutputFiletype("gltf2")).toBe("gltf"); + expect(normalizeOutputFiletype("objnomtl")).toBe("obj"); + expect(normalizeOutputFiletype("stlb")).toBe("stl"); + expect(normalizeOutputFiletype("plyb")).toBe("ply"); + expect(normalizeOutputFiletype("fbxa")).toBe("fbx"); + expect(normalizeOutputFiletype("assjson")).toBe("json"); + expect(normalizeOutputFiletype("WeIrDCase")).toBe("weirdcase"); +}); diff --git a/tests/helpers/printVersions.test.ts b/tests/helpers/printVersions.test.ts new file mode 100644 index 00000000..f00342ca --- /dev/null +++ b/tests/helpers/printVersions.test.ts @@ -0,0 +1,114 @@ +import { test, expect, mock, spyOn, afterEach } from "bun:test"; +import { exec } from "node:child_process"; +import { readFile } from "node:fs"; + +// mocks have to be defined before importing the module under test, +// otherwise the real modules will be loaded first and the mocks won't take effect. +mock.module("node:child_process", () => ({ + // The mock checks the environment variable MOCK_EXEC_ERROR at call-time so + // individual tests can trigger error paths by setting that env before + // importing the module under test. + exec: mock((cmd: string, cb: (error: Error | null, stdout: string) => void) => { + const shouldError = (process.env.MOCK_EXEC_ERROR || "") + .split(",") + .some((p) => p && cmd.includes(p)); + if (shouldError) { + cb(new Error(`${cmd} not found`), ""); + } else { + cb(null, `${cmd} v1.0.0\n`); + } + }), +})); + +mock.module("node:fs", () => ({ + readFile: mock( + (path: string, encoding: string, cb: (error: Error | null, data: string) => void) => { + cb(null, 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'); + }, + ), +})); + +mock.module("../../package.json", () => ({ + version: "1.0.0-test", +})); + +// We import the module in each test after the spies and the desired NODE_ENV were set +// so that the top level execution is under control of each test + +let consoleLogSpy: ReturnType; +let consoleErrorSpy: ReturnType; + +afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + delete process.env.NODE_ENV; + delete process.env.MOCK_EXEC_ERROR; +}); + +test("prints only the version and terminates itself immediately when NODE_ENV is not production", async () => { + consoleLogSpy = spyOn(console, "log"); + consoleErrorSpy = spyOn(console, "error"); + + // import the module after the spies were set so that the initial console.log within the module + // gets recognized by the spy + await import("../../src/helpers/printVersions?test=" + Math.random()); + + // empty the queue to make sure all callbacks have been processed + await new Promise((resolve) => setImmediate(resolve)); + + expect(consoleLogSpy).toHaveBeenCalledWith("ConvertX v1.0.0-test"); + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + expect(readFile).not.toHaveBeenCalled(); +}); + +test("prints system information and tool versions in production mode", async () => { + process.env.NODE_ENV = "production"; + consoleLogSpy = spyOn(console, "log"); + consoleErrorSpy = spyOn(console, "error"); + + // import module under test after setting NODE_ENV and activating the spies + await import("../../src/helpers/printVersions?test=" + Math.random()); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(consoleLogSpy).toHaveBeenCalledWith("ConvertX v1.0.0-test"); + expect(consoleLogSpy).toHaveBeenCalledWith("Ubuntu 22.04 LTS"); + expect(readFile).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledTimes(17); // Corresponds exactly to the number of exec calls in the source code +}); + +test("logs error paths when tools are missing", async () => { + process.env.NODE_ENV = "production"; + // Trigger a few error paths + process.env.MOCK_EXEC_ERROR = "pandoc,resvg,bun"; + consoleLogSpy = spyOn(console, "log"); + consoleErrorSpy = spyOn(console, "error"); + + await import("../../src/helpers/printVersions?test=" + Math.random()); + await new Promise((resolve) => setImmediate(resolve)); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith("Pandoc is not installed."); + expect(consoleErrorSpy).toHaveBeenCalledWith("resvg is not installed"); + expect(consoleErrorSpy).toHaveBeenCalledWith("Bun is not installed. wait what"); +}); + +test("processes output parsing correctly and logs no errors in the success case", async () => { + process.env.NODE_ENV = "production"; + consoleLogSpy = spyOn(console, "log"); + consoleErrorSpy = spyOn(console, "error"); + + // import module under test after setting NODE_ENV and activating the spies + await import("../../src/helpers/printVersions?test=" + Math.random()); + + await new Promise((resolve) => setImmediate(resolve)); + + // testing some specific outputs + expect(consoleLogSpy).toHaveBeenCalledWith("pandoc -v v1.0.0"); + expect(consoleLogSpy).toHaveBeenCalledWith("ffmpeg -version v1.0.0"); + expect(consoleLogSpy).toHaveBeenCalledWith("resvg v1.0.0"); + expect(consoleLogSpy).toHaveBeenCalledWith("Bun v1.0.0"); + + // make sure that error paths have not been triggered + expect(consoleErrorSpy).not.toHaveBeenCalled(); +});