WFDASH-WEBAPP/src/lib/server/attachment-compressor.test.ts

397 lines
16 KiB
TypeScript
Raw Normal View History

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,
);
});