import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { CatalogExportQueue, publishCatalogFiles, recoverCatalogQueue, sqlValue, } from "./catalog-git-core"; describe("catalog export", () => { it("recovers queue entries owned by a terminated local process", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "catalog-recovery-")); const deadPid = Number( execFileSync( process.execPath, ["-e", "process.stdout.write(String(process.pid))"], { encoding: "utf8" }, ), ); const owner = JSON.stringify({ pid: deadPid, host: os.hostname() }); await writeFile(path.join(root, "12345678.active"), owner); await writeFile(path.join(root, "worker.lock"), owner); await recoverCatalogQueue(root); const q = new CatalogExportQueue(root); expect(await q.batch()).toEqual(["12345678.pending"]); expect(await q.entries()).not.toContain("worker.lock"); }); it("keeps new requests pending when an older snapshot completes", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "catalog-queue-")); const q = new CatalogExportQueue(root); const finish = await q.begin(); expect(await q.batch()).toBeNull(); await finish(); const batch = await q.batch(); expect(batch).toHaveLength(1); await q.request(); if (!batch) throw new Error("Expected pending batch"); await q.complete(batch); expect(await q.batch()).toHaveLength(1); }); it("encodes SQL strings without quote or backslash ambiguity", () => { expect(sqlValue("Valentine's \\ Day\n")).toBe( "CONVERT(X'56616c656e74696e652773205c204461790a' USING utf8mb4)", ); expect(sqlValue(null)).toBe("NULL"); expect(sqlValue(42)).toBe("42"); expect(() => sqlValue(Number.NaN)).toThrow(); }); it("publishes to a bare remote, retries a failed push and avoids empty commits", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "catalog-git-")); const remote = path.join(root, "remote.git"); const checkout = path.join(root, "checkout"); const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); git(root, "init", "--bare", remote); git(root, "clone", remote, checkout); git(checkout, "checkout", "-b", "Beta-3"); git(checkout, "config", "user.name", "Catalog test"); git(checkout, "config", "user.email", "catalog@example.test"); await writeFile(path.join(checkout, "readme.md"), "preserved"); git(checkout, "add", "readme.md"); git(checkout, "commit", "-m", "initial"); git(checkout, "push", "-u", "origin", "Beta-3"); const source = path.join(root, "chair.nitro"); await writeFile(source, "bundle"); const files = [ { source, target: "Gamedata/bundled/furniture/chair.nitro" }, ]; const options = { checkout, remote, branch: "Beta-3", files }; const first = await publishCatalogFiles(options); expect(await publishCatalogFiles(options)).toBe(first); await writeFile(source, "updated"); git( checkout, "remote", "set-url", "--push", "origin", path.join(root, "missing.git"), ); await expect(publishCatalogFiles(options)).rejects.toThrow(); git(checkout, "remote", "set-url", "--push", "origin", remote); const retried = await publishCatalogFiles(options); expect(retried).not.toBe(first); expect(git(remote, "rev-parse", "Beta-3")).toBe(retried); expect(await readFile(path.join(checkout, "readme.md"), "utf8")).toBe( "preserved", ); await expect( publishCatalogFiles({ ...options, files: [{ source, target: "../escape" }], }), ).rejects.toThrow(); await mkdir(path.join(checkout, "unrelated")); await writeFile(path.join(checkout, "unrelated", "local.txt"), "local"); await expect(publishCatalogFiles(options)).rejects.toThrow(/clean/); // This integration test launches multiple real Git processes on the CI runner. }, 60_000); });