import { describe, it, expect } from "vitest"; import { execSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { compressAttachment, compressFile, compressBuffer, compressPdf, compressImage, shouldCompress, __test, } from "./attachment-compressor"; // Real PB-derived PDF fixtures (copies of operator quote PDFs on // disk — full corpus sees ~85% reduction with `gs /ebook`). Kept // in the repo as docs/ so the test stays self-contained without // needing network or PB access. const REAL_PDF_PATH = resolve( __dirname, "../../../docs/example-quotes/sample-cnl.pdf", ); const SMALL_PDF_PATH = resolve( __dirname, "../../../docs/example-quotes/live-charmaine-cnl.pdf", ); // Synchronous binary detection — `describe.skipIf` runs at file // load time, so we can't use the async `beforeAll` for that // decision. `execSync` with `stdio: "ignore"` + a tiny exit-code // capture gives us "is it on PATH?" in one round trip. A missing // binary throws ENOENT, which we convert to false. function binAvailable(cmd: string): boolean { try { execSync(`${cmd} --version`, { stdio: "ignore" }); return true; } catch { return false; } } const HAS_GS = binAvailable("gs"); const HAS_SIPS = binAvailable("sips"); // ── Pure routing logic (no binaries) ─────────────────────────── describe("shouldCompress", () => { it("returns kind:'pdf' for .pdf filenames", () => { expect(shouldCompress("invoice.pdf", 100 * 1024)).toEqual({ kind: "pdf", reason: null, }); }); it("returns kind:'image' for JPG/PNG filenames", () => { expect(shouldCompress("photo.jpg", 200 * 1024).kind).toBe("image"); expect(shouldCompress("photo.jpeg", 200 * 1024).kind).toBe("image"); expect(shouldCompress("photo.png", 200 * 1024).kind).toBe("image"); }); it("is case-insensitive on the extension (PB filenames can be)", () => { expect(shouldCompress("Invoice.PDF", 100 * 1024).kind).toBe("pdf"); expect(shouldCompress("Photo.JPG", 200 * 1024).kind).toBe("image"); }); it("flags unsupported extensions (.docx, .txt, .csv, etc.)", () => { for (const ext of ["docx", "txt", "csv", "zip", "xlsx", "html"]) { expect(shouldCompress(`file.${ext}`, 200 * 1024)).toEqual({ kind: null, reason: "unsupported_type", }); } }); it("flags filenames with no extension as unsupported", () => { expect(shouldCompress("noextension", 200 * 1024)).toEqual({ kind: null, reason: "unsupported_type", }); }); it("flags PDFs under SKIP_MIN_BYTES as already_small", () => { expect(shouldCompress("tiny.pdf", 1024)).toEqual({ kind: null, reason: "already_small", }); expect(shouldCompress("tiny.jpg", 1024).reason).toBe("already_small"); // Boundary: exactly 50 KB (50 * 1024) should NOT be already_small — // the strict `<` check means 50 KB is processed. Verified by // guarding the math here so future tuning doesn't drift silently. expect(shouldCompress("edge.pdf", 50 * 1024).reason).toBeNull(); expect(shouldCompress("edge-minus-one.pdf", 50 * 1024 - 1).reason).toBe( "already_small", ); }); it("flags files above SKIP_MAX_BYTES (PB hard cap is 25 MB) as too_large", () => { expect(shouldCompress("huge.pdf", 25 * 1024 * 1024).reason).toBe("too_large"); expect(shouldCompress("huge.pdf", 24 * 1024 * 1024).reason).toBeNull(); expect(shouldCompress("huge.pdf", 24 * 1024 * 1024 + 1).reason).toBe("too_large"); }); it("size bounds are checked before extension (cheap check first)", () => { // 1 KB .docx — would normally fail on extension, but a tiny // .docx is `already_small` first. Confirms ordering matches the // implementation (`compressAttachment` checks size before // extension). expect(shouldCompress("doc.docx", 1024).reason).toBe("already_small"); }); }); // ── Tunable bounds are exported for ops sanity-checking ──────── describe("__test internal constants", () => { it("SKIP_MIN_BYTES matches the bulk-pass script's filter", () => { // scripts/compress-attachments-once.mjs uses: // const SKIP_MIN_BYTES = 50 * 1024; // const SKIP_MAX_BYTES = 24 * 1024 * 1024; // Drift between the two would let the on-upload hook and the // bulk-pass script disagree on which files to touch. expect(__test.SKIP_MIN_BYTES).toBe(50 * 1024); expect(__test.SKIP_MAX_BYTES).toBe(24 * 1024 * 1024); }); }); // ── compressAttachment: shape contract (no binaries) ─────────── describe("compressAttachment — shape contract", () => { it("throws on non-Buffer input (programmer error, not data error)", async () => { await expect( compressAttachment({ buffer: "not-a-buffer" as unknown as Buffer, filename: "x.pdf", }), ).rejects.toThrow(/buffer must be a Buffer/); }); it("throws on missing filename", async () => { await expect( compressAttachment({ buffer: Buffer.from("%PDF-1.3"), filename: "" }), ).rejects.toThrow(/filename is required/); }); it("returns the original buffer with reason:already_small for tiny files", async () => { const tiny = Buffer.from("%PDF-1.3\n%EOF\n"); const result = await compressAttachment({ buffer: tiny, filename: "tiny.pdf" }); expect(result.skipped).toBe(true); expect(result.reason).toBe("already_small"); expect(result.originalBytes).toBe(tiny.byteLength); expect(result.compressedBytes).toBe(tiny.byteLength); expect(result.buffer).toBe(tiny); // identity check — same object, no copy expect(result.mimeType).toBe("application/pdf"); }); it("returns the original buffer with reason:unsupported_type for non-image/PDF", async () => { const buf = Buffer.alloc(100 * 1024, "x"); // 100 KB of 'x' const result = await compressAttachment({ buffer: buf, filename: "spreadsheet.xlsx", }); expect(result.skipped).toBe(true); expect(result.reason).toBe("unsupported_type"); expect(result.buffer).toBe(buf); // mimeType is left as octet-stream when unrecognised expect(result.mimeType).toBe("application/octet-stream"); }); }); // ── compressAttachment — real-binary tests (skip if no gs/sips) ─ describe.skipIf(!HAS_GS)("compressAttachment — PDF (real Ghostscript)", () => { it("compresses a real quote PDF measurably smaller", async () => { const original = readFileSync(REAL_PDF_PATH); expect(original.byteLength).toBeGreaterThan(50 * 1024); // sanity: ≥50 KB const result = await compressAttachment({ buffer: original, filename: "sample-cnl.pdf", }); expect(result.reason).toBe("compressed"); expect(result.skipped).toBe(false); expect(result.compressedBytes).toBeLessThan(original.byteLength); // Confirm the magnitude: the dry-run measured ~85% reduction on // the corpus. We assert a soft floor (>40% reduction) so the // test stays meaningful across preset / Ghostscript version // changes without being fragile on the upper bound. const ratio = result.compressedBytes / result.originalBytes; expect(ratio).toBeLessThan(0.6); // Output must still be a valid PDF magic header. expect(result.buffer.slice(0, 5).toString("ascii")).toBe("%PDF-"); }, 30_000); it("contract: returned buffer's byteLength matches compressedBytes regardless of reason", async () => { // Drive the same data through three calls that take different // paths through the code, then assert that for each call // result.buffer.byteLength === result.compressedBytes // holds even when the skip guard fires. (Earlier revisions of // `no_meaningful_savings` had a 1-byte contract leak that this // test pins down.) const original = readFileSync(REAL_PDF_PATH); const firstPass = await compressAttachment({ buffer: original, filename: "sample-cnl.pdf", }); expect(firstPass.buffer.byteLength).toBe(firstPass.compressedBytes); // Run the first pass output through again. Expect either // - reason:"compressed" (rare — gs found more to drop) // - reason:"no_meaningful_savings" (typical — gs was already optimal) const secondPass = await compressAttachment({ buffer: firstPass.buffer, filename: "sample-cnl.pdf", }); expect(["compressed", "no_meaningful_savings"]).toContain(secondPass.reason); expect(secondPass.buffer.byteLength).toBe(secondPass.compressedBytes); // The second pass must NEVER make the file larger — if it did // the no-savings guard didn't fire and we'd silently bump PB // URLs for no reason on every duplicate pass. expect(secondPass.compressedBytes).toBeLessThanOrEqual( firstPass.compressedBytes, ); }, 60_000); it("returns the original with reason:already_small for a <50 KB PDF", async () => { // The "live-charmaine-cnl" fixture is ~22 KB. const small = readFileSync(SMALL_PDF_PATH); const result = await compressAttachment({ buffer: small, filename: "small.pdf", }); expect(result.reason).toBe("already_small"); expect(result.buffer).toBe(small); }); }); describe("compressAttachment — image (sips not exercised here)", () => { // Image compression needs a fixture of sufficient size (>50 KB) // for the routing logic to let it through. The repo doesn't ship // one without bloating the diff, and sips binary paths vary by // platform. The routing-logic tests above prove the image branch // is reached; a follow-up can wire an image fixture when one is // needed. The compressPdf primitive is tested separately below. it("still documents the gap so future fixtures have a hook", () => { expect(HAS_SIPS).toBeDefined(); }); }); // ── Bare-command helpers are exposed for potential mocking ───── describe("bare helpers re-exported", () => { it("exports compressPdf and compressImage as standalone functions", () => { expect(typeof compressPdf).toBe("function"); expect(typeof compressImage).toBe("function"); }); it.skipIf(!HAS_GS)("compressPdf primitive actually shrinks a real PDF", async () => { const dir = await mkdtemp(join(tmpdir(), "wfdash-compress-test-")); const src = join(dir, "in.pdf"); const dst = join(dir, "out.pdf"); await writeFile(src, readFileSync(REAL_PDF_PATH)); const r = await compressPdf(src, dst); expect(r.ok).toBe(true); const out = await readFile(dst); const srcSize = (await readFile(src)).byteLength; expect(out.byteLength).toBeLessThan(srcSize); }, 30_000); }); // ── Caller-facing wrappers ────────────────────────────────────── // // The wrappers exist so route handlers / services can compress // without manually converting File/Buffer to the (Buffer, filename, // mimeType) triple that `compressAttachment` requires. They also // rebuild a `File` or pass back a `{ buffer, filename }` shaped // for direct use with `fd.append(field, ...)` / // `new Blob([...], { type })`. describe("compressFile — wraps a File for multipart FormData", () => { it("preserves a too-small File untouched and rebuilds a File with the same bytes", async () => { const original = new File(["%PDF-tiny"], "tiny.pdf", { type: "application/pdf", }); const originalBytes = new Uint8Array(await original.arrayBuffer()); const { file, result } = await compressFile(original); expect(result.reason).toBe("already_small"); expect(result.skipped).toBe(true); // The returned File is a new instance — the input is not mutated. expect(file).not.toBe(original); expect(file.name).toBe("tiny.pdf"); expect(file.type).toBe("application/pdf"); // Bytes must match the input exactly — round-tripping through // Buffer → File must not lose or transform a single byte. const bytes = new Uint8Array(await file.arrayBuffer()); expect(bytes).toEqual(originalBytes); // Sanity: input has its expected shape (guards against File // constructor string encoding turning "%PDF-tiny" into something // weird). expect(originalBytes.byteLength).toBeGreaterThan(0); const head = new TextDecoder().decode(originalBytes.slice(0, 5)); expect(head).toBe("%PDF-"); }); it.skipIf(!HAS_GS)( "actually shrinks a real PDF and returns a new File with reduced bytes", async () => { const pdfBytes = readFileSync(REAL_PDF_PATH); const original = new File([pdfBytes], "real.pdf", { type: "application/pdf", }); const { file, result } = await compressFile(original); expect(result.reason).toBe("compressed"); expect(result.skipped).toBe(false); expect(result.compressedBytes).toBeLessThan(result.originalBytes); const recompressedBytes = new Uint8Array(await file.arrayBuffer()); // The new File carries exactly the recompressed bytes — // PB will store those (smaller) bytes on disk. expect(recompressedBytes.byteLength).toBe(result.compressedBytes); // And it preserves the %PDF- header PB / the proxy rely on. const head = new TextDecoder().decode(recompressedBytes.slice(0, 5)); expect(head).toBe("%PDF-"); }, 30_000, ); it("preserves an unsupported extension untouched", async () => { const original = new File( [new Uint8Array(100 * 1024)], // 100 KB, well above the 50 KB floor "data.zip", { type: "application/zip" }, ); const { file, result } = await compressFile(original); expect(result.reason).toBe("unsupported_type"); expect(result.skipped).toBe(true); expect(file.name).toBe("data.zip"); }); it("does not mutate the original File's bytes", async () => { const bytes = new Uint8Array(80 * 1024); bytes.fill(0xff); const original = new File([bytes], "blanks.bin", { type: "application/octet-stream", }); const before = new Uint8Array(await original.arrayBuffer()); await compressFile(original); const after = new Uint8Array(await original.arrayBuffer()); expect(after).toEqual(before); }); }); describe("compressBuffer — wraps generated bytes (PDF render path)", () => { it("preserves a too-small Buffer untouched", async () => { const tiny = Buffer.from("%PDF-small"); const { buffer, filename, mimeType, result } = await compressBuffer( tiny, "small.pdf", "application/pdf", ); expect(result.reason).toBe("already_small"); expect(buffer).toBe(tiny); // identity — same Buffer instance expect(filename).toBe("small.pdf"); expect(mimeType).toBe("application/pdf"); }); it("accepts a Uint8Array and returns a Buffer", async () => { const u8 = new Uint8Array(1024); u8.fill(0xab); const { buffer } = await compressBuffer(u8, "raw.bin", "application/pdf"); expect(Buffer.isBuffer(buffer)).toBe(true); expect(buffer.byteLength).toBe(1024); }); it.skipIf(!HAS_GS)( "actually shrinks a real PDF generated in-memory", async () => { const pdfBytes = readFileSync(REAL_PDF_PATH); const { buffer, filename, mimeType, result } = await compressBuffer( pdfBytes, "rendered.pdf", "application/pdf", ); expect(result.reason).toBe("compressed"); expect(result.compressedBytes).toBeLessThan(result.originalBytes); expect(buffer.byteLength).toBe(result.compressedBytes); expect(filename).toBe("rendered.pdf"); expect(mimeType).toBe("application/pdf"); // The Buffer is suitable for `new Blob([new Uint8Array(buffer)], { type: mimeType })` // without further transformation. const blob = new Blob([new Uint8Array(buffer)], { type: mimeType }); expect(blob.size).toBe(buffer.byteLength); }, 30_000, ); });