397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
|
|
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,
|
||
|
|
);
|
||
|
|
});
|