WFDASH-WEBAPP/src/lib/pdf/render.test.ts
Dave ff58af7586 fix(pdf): render through renderToBuffer, not pdf().toString()
Every renderer funneled the PDF through a JS string and re-encoded it as
latin1. Three separate problems, only the first of which was ever real:

  1. `pdf(el).toBuffer()` is NOT the buffer API — it resolves to the PDFKit
     *stream* (react-pdf carries a TODO to rename it `toStream`), and under
     jsdom it resolves to a bare PDFDocument handle. That is the jsdom
     breakage an old comment described, but it applies to that method, not
     to the module-level `renderToBuffer()` helper.
  2. `toString()` renders with `compress: false`. react-pdf's own source
     says why: "when rendering to string if compress=true the document is
     blank". The method is flagged upstream as buggy (#2112, #2095).
  3. The string round-trip mangles binary streams. Text is ASCII in the
     content stream so it survives; a raster image is a compressed blob
     that does not — the image object lands in the file but is never
     painted. Poppler was already reporting "Bad FCHECK in flate stream".

It stayed latent because the only image in these documents is the letterhead
logo, and that logo is an SVG. SVG is text, so it round-tripped perfectly and
hid the whole thing. `0xFD` in the old comments is the fingerprint: latin1's
truncation of U+FFFD, the Unicode replacement char.

`renderToBuffer()` fixes all three in a single pass and works under jsdom, so
no fallback is needed. Note you cannot "try toBuffer, fall back to toString" —
calling `toBuffer()` first consumes the render and a later `toString()` on the
same instance returns a document with the content missing.

Three more instances of the same pattern lived outside render.tsx and are fixed
here too: the print-bundle, delivery-note-print and picking-slip routes.

`render-image-pipeline.test.ts` is the regression guard. Its size assertion is
expressed against the old uncompressed baseline rather than a round number
picked when the document was smaller — an absolute byte count is a tripwire
that fires on every feature addition, which is how a real regression gets
tuned away. Guard verified by reintroducing the old path: 2 tests fail.
2026-10-04 13:14:59 +02:00

447 lines
No EOL
14 KiB
TypeScript

import { describe, it, expect } from "vitest";
import zlib from "node:zlib";
import {
renderQuotePdf,
renderDeliveryNotePdf,
renderStockPurchaseListPdf,
renderStockItemStocktakePdf,
} from "./render";
import type { Letterhead } from "@/lib/letterhead";
import type { Customer, Quote, QuoteItem } from "@/lib/types";
import type { StockPurchaseListRow } from "@/lib/server/stock-purchases-service";
import type { StockItem } from "@/lib/server/stock-items-service";
/**
* Smoke tests for the PDF renderer. We don't render visually — we
* assert the binary is a well-formed PDF (magic bytes, sane size) so
* any regression in the render pipeline (style typo breaking JSX,
* dep misconfig, etc.) trips the test even without eyeballing output.
*/
/**
* Concatenate the inflated text of every FlateDecode stream in a PDF.
*
* Needed because `renderToBuffer()` compresses: text lives inside
* zlib-compressed content streams, so scanning the raw buffer for a
* string finds nothing. Any assertion of the form "this PDF does /
* does not contain X" has to go through here or it is vacuous.
*/
function decompressPdfText(buf: Buffer): string {
const out: string[] = [];
const marker = Buffer.from("stream");
let idx = buf.indexOf(marker);
while (idx !== -1) {
let start = idx + marker.length;
// Skip the EOL after the `stream` keyword.
if (buf[start] === 0x0d) start += 1;
if (buf[start] === 0x0a) start += 1;
const end = buf.indexOf(Buffer.from("endstream"), start);
if (end === -1) break;
try {
out.push(zlib.inflateSync(buf.subarray(start, end)).toString("latin1"));
} catch {
// Not every stream is Flate (images may be raw or DCT); skip it.
}
idx = buf.indexOf(marker, end);
}
return out.join("\n");
}
const baseLetterhead: Letterhead = {
id: "lh1",
collectionId: "pbc_1010367695",
collectionName: "letterhead_settings",
created: "2026-06-22T20:31:27.165Z",
updated: "2026-06-22T20:31:27.165Z",
display_name: "CNL Mining Supplies",
address: "47 Station Street\nCarletonville, Gauteng 2499",
phone: "+27 18 786 2897",
email: "quotes@wfsales.co.za",
vat_number: "4840229449",
vat_percent: 15,
registration_number: "2014/004024/07",
bank_name: "Standard Bank - Carletonville",
bank_account_number: "Current Account\n022196552",
bank_branch_code: "016141",
terms:
"Terms and Conditions:\n1. This quotation is valid for 30 days from the date of issue.\n2. Prices are quoted excluding VAT unless otherwise stated.\n3. Lead time will be confirmed upon receipt of order.",
default_markup_percent: 30,
footer_text: "",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#738224",
};
const baseCustomer: Customer = {
id: "cust1",
collectionId: "pbc_customers",
collectionName: "customers",
created: "2026-06-22T00:00:00Z",
updated: "2026-06-22T00:00:00Z",
customer: "Cash Sales",
email: "james@microntec.co.za",
alternative_email: "",
buyer_email: "",
number: "0615829016",
company: "",
dateadded: "",
lastvisiteddate: "",
rep: "",
coordinates: { lon: 0, lat: 0 },
description: "",
};
const baseQuote: Quote = {
id: "q1",
collectionId: "pbc_quotes",
collectionName: "quotes",
created: "2026-06-22T13:40:00Z",
updated: "2026-06-22T13:40:00Z",
quote_number: "Q009638",
quote_reference: "",
external_number: "",
rfq_number: "",
markup: 0,
customer: "cust1",
company: "",
claimed_by: "",
description: "",
status: "new",
approved_by: "",
notes: "",
email_sender: "",
email_subject: "",
email_body: "",
has_attachments: false,
company_letterhead: "CNL",
email_delivered: false,
email_delivered_at: "",
email_received_at: "",
email_opened: false,
email_opened_at: "",
email_tracking_id: "",
has_new_reply: false,
last_reply_at: "",
email_failed_at: "",
pdf_generated_at: "",
email_message_id: "",
// Backdating / template / follow-up fields — empty defaults so the
// PDF falls back to `created` for the date and the package default
// (30 days) for validity.
quote_date: "",
validity_days: null,
is_template: false,
template_source_id: "",
generated_from_template_id: "",
follow_up_due: "",
email_cc: "",
email_bcc: "",
email_subject_outbound: "",
email_message: "",
attach_auth_letters: true,
// Outbound-send tracking — empty defaults. Tests that exercise
// the failure banner seed these explicitly.
email_outbound_message_id: "",
email_outbound_attachment_ids: "",
email_error: "",
sending_started_at: "",
};
const baseItems: QuoteItem[] = [
{
id: "qi1",
collectionId: "pbc_quote_items",
collectionName: "quote_items",
created: "2026-06-22T13:40:00Z",
updated: "2026-06-22T13:40:00Z",
quote: "q1",
description: "JEICS30 SLUGGER M2-AL HSS",
quote_number: "Q009638",
quote_reference: "",
quantity: 1,
supplier: "",
cost_price: 0,
selling_price: 648.7,
notes: "",
is_processed: true,
markup: 0,
quote_pdf_id: "",
user_edited: true,
sku: "",
pricing_source: "",
matched_price_list_id: "",
pricing_notes: "",
},
{
id: "qi2",
collectionId: "pbc_quote_items",
collectionName: "quote_items",
created: "2026-06-22T13:40:00Z",
updated: "2026-06-22T13:40:00Z",
quote: "q1",
description: "JEIPILOT 1",
quote_number: "Q009638",
quote_reference: "",
quantity: 1,
supplier: "",
cost_price: 0,
selling_price: 88.36,
notes: "",
is_processed: true,
markup: 0,
quote_pdf_id: "",
user_edited: true,
sku: "",
pricing_source: "",
matched_price_list_id: "",
pricing_notes: "",
},
];
describe("renderQuotePdf", () => {
it("produces a Buffer with a PDF magic-number header", async () => {
const buf = await renderQuotePdf({
quote: baseQuote,
items: baseItems,
customer: baseCustomer,
letterhead: baseLetterhead,
logoDataUri: null,
});
expect(Buffer.isBuffer(buf)).toBe(true);
// Every PDF starts with the literal `%PDF-` (5 bytes).
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("produces a non-trivial buffer (> 1 KB) for a simple quote", async () => {
const buf = await renderQuotePdf({
quote: baseQuote,
items: baseItems,
customer: baseCustomer,
letterhead: baseLetterhead,
logoDataUri: null,
});
expect(buf.length).toBeGreaterThan(1024);
}, 30_000);
it("handles missing logo gracefully (logoDataUri=null)", async () => {
const buf = await renderQuotePdf({
quote: baseQuote,
items: baseItems,
customer: baseCustomer,
letterhead: { ...baseLetterhead, logo: "" },
logoDataUri: null,
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("renders CNL and Isherwood without throwing (different accent colors)", async () => {
const cnl = await renderQuotePdf({
quote: baseQuote,
items: baseItems,
customer: baseCustomer,
letterhead: baseLetterhead, // accent #738224
logoDataUri: null,
});
const isherwood = await renderQuotePdf({
quote: baseQuote,
items: baseItems,
customer: baseCustomer,
letterhead: { ...baseLetterhead, display_name: "ISHERWOOD MINING SUPPLIES", accent_color: "#7ed957" },
logoDataUri: null,
});
expect(cnl.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(isherwood.subarray(0, 5).toString("ascii")).toBe("%PDF-");
// Different accent color → different generated PDF body length.
// (not a strict equality — accent only affects a small region — but
// the two PDFs at least both produce valid output)
}, 30_000);
it("renders with backdated quote_date + extended validity_days without throwing", async () => {
// Office scenario: backdate to 1 March 2026, valid for 2 months.
// We can't easily grep for "1 March 2026" in the PDF byte stream
// because @react-pdf compresses text into FlateDecode streams —
// so we verify the format helper produces the right dates and
// trust the rest from the unit tests in format.test.ts.
const { effectiveQuoteDate, effectiveValidityDays, addDays } = await import("./format");
const d = effectiveQuoteDate({ quote_date: "2026-03-01T00:00:00.000Z", created: baseQuote.created });
expect(d).toBe("2026-03-01T00:00:00.000Z");
expect(effectiveValidityDays({ validity_days: 60 })).toBe(60);
expect(addDays(d, effectiveValidityDays({ validity_days: 60 }))).toBe("2026-04-30T00:00:00.000Z");
const buf = await renderQuotePdf({
quote: {
...baseQuote,
quote_date: "2026-03-01T00:00:00.000Z",
validity_days: 60,
},
items: baseItems,
customer: baseCustomer,
letterhead: baseLetterhead,
logoDataUri: null,
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(buf.length).toBeGreaterThan(1024);
}, 30_000);
});
describe("renderDeliveryNotePdf", () => {
const baseLines = [
{
id: "row-1",
description: "Wheel hub kit — 6-bolt pattern",
qty: 5,
},
{
id: "row-2",
description: "Stainless pipe 50mm SCH40 6m length 304",
qty: 2,
},
];
it("renders a valid PDF that starts with the %PDF- magic header", async () => {
const buf = await renderDeliveryNotePdf({
header: {
deliveryNoteNumber: "DN26071401",
dateIso: "2026-07-14T13:30:00.000Z",
orderNumber: "SO-1",
companyName: "Adal Engineering",
customerName: "GIELIE",
},
lines: baseLines,
letterhead: baseLetterhead,
logoDataUri: null,
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(buf.length).toBeGreaterThan(1024);
}, 30_000);
it("does not expose supplier names in the delivery-note PDF", async () => {
const buf = await renderDeliveryNotePdf({
header: {
deliveryNoteNumber: "DN26071401",
dateIso: "2026-07-14T13:30:00.000Z",
orderNumber: "SO-1",
companyName: "Adal Engineering",
customerName: "GIELIE",
},
lines: [
{ id: "row-1", description: "Wheel hub kit", qty: 1 },
],
letterhead: baseLetterhead,
logoDataUri: null,
});
// Grep the DECOMPRESSED text, not the raw bytes. The renderers
// used to funnel through `pdf().toString()`, which renders
// uncompressed — so a raw `buf.toString("latin1")` scan really did
// see the text. `renderToBuffer()` compresses, so the same scan
// would match nothing and this assertion would pass for the wrong
// reason: a test that quietly stops testing is worse than no test.
expect(decompressPdfText(buf)).not.toContain("Supplier");
}, 30_000);
it("renders with the ISHERWOOD letterhead code (no accent_color override)", async () => {
const buf = await renderDeliveryNotePdf({
header: {
deliveryNoteNumber: "DN26071401",
dateIso: "2026-07-14T13:30:00.000Z",
orderNumber: "SO-1",
companyName: "Isherwood Mining",
customerName: "GIELIE",
},
lines: baseLines,
letterhead: { ...baseLetterhead, display_name: "Isherwood Mining Supplies", accent_color: "" },
logoDataUri: null,
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});
describe("renderStockPurchaseListPdf", () => {
const baseRow: StockPurchaseListRow = {
id: "sp1",
collectionId: "pbc_stock_purchases",
collectionName: "stock_purchases",
created: "2026-08-01T09:00:00.000Z",
updated: "2026-08-01T09:00:00.000Z",
purchase_number: "PO26000001STK",
supplier: "sup1",
status: "draft",
notes: "",
email_error: "",
email_message_id: "",
item_count: 3,
ordered_value: 1234.56,
received_value: 0,
expand: {
supplier: {
id: "sup1",
collectionId: "pbc_suppliers",
collectionName: "suppliers",
created: "",
updated: "",
suppliername: "Alpha Bearings",
suppliernumber: "",
supplieraddress: "",
email: "",
supply_tags: "",
coordinates: "",
closingtime: "",
},
},
};
it("produces a Buffer with a PDF magic-number header", async () => {
const buf = await renderStockPurchaseListPdf({
rows: [baseRow],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("renders an empty rows array without throwing", async () => {
const buf = await renderStockPurchaseListPdf({
rows: [],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});
describe("renderStockItemStocktakePdf", () => {
const baseItem: StockItem = {
id: "si1",
collectionId: "pbc_stock_items",
collectionName: "stock_items",
created: "2026-08-01T09:00:00.000Z",
updated: "2026-08-01T09:00:00.000Z",
sku: "SKU-001",
name: "Wheel hub kit — 6-bolt pattern",
description: "",
unit: "ea",
location: "A1",
current_qty: 12,
min_qty: 2,
target_qty: 20,
supplier: "sup1",
is_active: true,
notes: "",
};
it("produces a Buffer with a PDF magic-number header", async () => {
const buf = await renderStockItemStocktakePdf({
items: [baseItem],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("renders an empty items array without throwing", async () => {
const buf = await renderStockItemStocktakePdf({
items: [],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});