import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PDFDocument } from 'pdf-lib'; import { fillEoiFormFields, loadEoiTemplatePdf } from '@/lib/pdf/fill-eoi-form'; import type { EoiContext } from '@/lib/services/eoi-context'; // ─── Test PDF builder (synthetic source PDF with the same field names) ─────── async function buildSyntheticEoiPdf(): Promise { const doc = await PDFDocument.create(); const page = doc.addPage([600, 800]); const form = doc.getForm(); const textFieldNames = [ 'Name', 'Email', 'Address', 'Yacht Name', 'Length', 'Width', 'Draft', 'Berth Number', ]; textFieldNames.forEach((name, i) => { const f = form.createTextField(name); f.addToPage(page, { x: 50, y: 700 - i * 40, width: 300, height: 24 }); }); for (const name of ['Lease_10', 'Purchase']) { const cb = form.createCheckBox(name); cb.addToPage(page, { x: 400, y: 700 - (name === 'Purchase' ? 0 : 40), width: 12, height: 12 }); } return doc.save(); } function makeContext(overrides: Partial = {}): EoiContext { return { client: { id: 'client-test-1', fullName: 'Alice Smith', nationality: 'US', primaryEmail: 'alice@example.com', primaryPhone: '+1-555-0100', address: { street: '123 Main St', city: 'Austin', country: 'USA' }, }, yacht: { id: 'yacht-test-1', name: 'Sea Breeze', lengthFt: '45', widthFt: '14', draftFt: '6', lengthM: null, widthM: null, draftM: null, hullNumber: 'HN-1', flag: 'US', yearBuilt: 2020, }, company: null, owner: { type: 'client', name: 'Alice Smith' }, berth: { mooringNumber: 'A12', area: 'North', lengthFt: '50', price: '1000', priceCurrency: 'USD', tenureType: 'permanent', }, eoiBerthRange: '', interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null }, port: { name: 'Port Nimara', defaultCurrency: 'USD' }, date: { today: '2026-04-26', year: '2026' }, ...overrides, }; } // ─── fillEoiFormFields ──────────────────────────────────────────────────────── describe('fillEoiFormFields', () => { it('flattens the form and writes metadata (Title/Author/Lang)', async () => { const sourcePdf = await buildSyntheticEoiPdf(); const filled = await fillEoiFormFields(sourcePdf, makeContext()); const out = await PDFDocument.load(filled); // Flatten removes the interactive fields from the form — the values // are baked into the page content stream so the signer can't edit // them after the fact. expect(out.getForm().getFields()).toEqual([]); // PDF metadata (M-1). pdf-lib doesn't expose `getLanguage`, but the // other setters round-trip through standard PDF info-dict getters. expect(out.getTitle()).toBe('EOI – Alice Smith'); expect(out.getAuthor()).toBe('Port Nimara'); expect(out.getSubject()).toBe('Expression of Interest'); }); it('produces a non-empty PDF and a single page with the synthetic source', async () => { const sourcePdf = await buildSyntheticEoiPdf(); const filled = await fillEoiFormFields(sourcePdf, makeContext()); // Round-trip the saved bytes. With the AcroForm flattened, the doc // still loads as a valid PDF and retains the original page count — // the text-field widgets have been baked into the content stream. const out = await PDFDocument.load(filled); expect(out.getPageCount()).toBe(1); expect(filled.byteLength).toBeGreaterThan(500); }); it('handles null primary email and null address gracefully', async () => { const sourcePdf = await buildSyntheticEoiPdf(); const filled = await fillEoiFormFields( sourcePdf, makeContext({ client: { id: 'client-test-2', fullName: 'Bob', nationality: null, primaryEmail: null, primaryPhone: null, address: null, }, }), ); // Still flattens cleanly and round-trips as a valid PDF even with // null email/address — the doc doesn't error out. const out = await PDFDocument.load(filled); expect(out.getForm().getFields()).toEqual([]); expect(out.getTitle()).toBe('EOI – Bob'); }); it('skips fields silently if the source PDF lacks them', async () => { // Build a PDF with only a subset of fields and ensure no error. const doc = await PDFDocument.create(); const page = doc.addPage([600, 800]); const form = doc.getForm(); const f = form.createTextField('Name'); f.addToPage(page, { x: 50, y: 700, width: 300, height: 24 }); const sparse = await doc.save(); await expect(fillEoiFormFields(sparse, makeContext())).resolves.toBeInstanceOf(Uint8Array); }); }); // ─── loadEoiTemplatePdf ─────────────────────────────────────────────────────── describe('loadEoiTemplatePdf', () => { let tmpFile: string; const originalEnv = process.env.EOI_TEMPLATE_PDF_PATH; beforeAll(async () => { const sourcePdf = await buildSyntheticEoiPdf(); tmpFile = path.join(os.tmpdir(), `eoi-template-${Date.now()}.pdf`); await fs.writeFile(tmpFile, sourcePdf); }); afterAll(async () => { if (originalEnv === undefined) delete process.env.EOI_TEMPLATE_PDF_PATH; else process.env.EOI_TEMPLATE_PDF_PATH = originalEnv; await fs.unlink(tmpFile).catch(() => undefined); }); it('reads the PDF from EOI_TEMPLATE_PDF_PATH override', async () => { process.env.EOI_TEMPLATE_PDF_PATH = tmpFile; const bytes = await loadEoiTemplatePdf(); expect(bytes.byteLength).toBeGreaterThan(100); // Round-trip: should re-load as a valid PDF. await expect(PDFDocument.load(bytes)).resolves.toBeInstanceOf(PDFDocument); }); it('throws a clear error with instructions when the file is missing', async () => { process.env.EOI_TEMPLATE_PDF_PATH = '/nope/does-not-exist.pdf'; await expect(loadEoiTemplatePdf()).rejects.toThrow(/EOI source PDF not found/); }); });