- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field. - Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability. - Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications. - Enhanced the `package.json` with a new migration script for setting up the audit log functionality. This feature improves accountability and transparency in the management of delivery notes and orders.
396 lines
16 KiB
TypeScript
396 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,
|
|
);
|
|
});
|