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