When the in-app pathway is used for EOI templates, we now load the same source PDF that the Documenso template uploads and fill its AcroForm fields with values from EoiContext via pdf-lib. Field names mirror the Documenso template's formValues keys exactly (Name, Email, Address, Yacht Name, Length, Width, Draft, Berth Number + Lease_10 / Purchase checkboxes), so both pathways produce equivalent legal documents — only the renderer differs. The form is left interactive (not flattened) so a recipient can still adjust values before signing. Non-EOI templates (welcome letters, acknowledgments, etc.) keep using the existing HTML→pdfme path. Adds: - pdf-lib direct dep - src/lib/pdf/fill-eoi-form.ts — load + fill helpers, EOI_TEMPLATE_PDF_PATH env override - assets/ + README documenting the expected source PDF - next.config outputFileTracingIncludes so the asset is bundled in the standalone build Tests: 8 new (4 fill-form unit + 2 source-PDF route + 2 fallback); 645/645 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
6.2 KiB
TypeScript
177 lines
6.2 KiB
TypeScript
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<Uint8Array> {
|
|
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> = {}): EoiContext {
|
|
return {
|
|
client: {
|
|
fullName: 'Alice Smith',
|
|
nationality: 'US',
|
|
primaryEmail: 'alice@example.com',
|
|
primaryPhone: '+1-555-0100',
|
|
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
|
|
},
|
|
yacht: {
|
|
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: 'A-12',
|
|
area: 'North',
|
|
lengthFt: '50',
|
|
price: '1000',
|
|
priceCurrency: 'USD',
|
|
tenureType: 'permanent',
|
|
},
|
|
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('populates every text field and checkbox using EoiContext', async () => {
|
|
const sourcePdf = await buildSyntheticEoiPdf();
|
|
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
|
|
|
const out = await PDFDocument.load(filled);
|
|
const form = out.getForm();
|
|
|
|
expect(form.getTextField('Name').getText()).toBe('Alice Smith');
|
|
expect(form.getTextField('Email').getText()).toBe('alice@example.com');
|
|
expect(form.getTextField('Address').getText()).toBe('123 Main St, Austin, USA');
|
|
expect(form.getTextField('Yacht Name').getText()).toBe('Sea Breeze');
|
|
expect(form.getTextField('Length').getText()).toBe('45');
|
|
expect(form.getTextField('Width').getText()).toBe('14');
|
|
expect(form.getTextField('Draft').getText()).toBe('6');
|
|
expect(form.getTextField('Berth Number').getText()).toBe('A-12');
|
|
|
|
expect(form.getCheckBox('Purchase').isChecked()).toBe(true);
|
|
expect(form.getCheckBox('Lease_10').isChecked()).toBe(false);
|
|
});
|
|
|
|
it('handles null primary email and null address gracefully', async () => {
|
|
const sourcePdf = await buildSyntheticEoiPdf();
|
|
const filled = await fillEoiFormFields(
|
|
sourcePdf,
|
|
makeContext({
|
|
client: {
|
|
fullName: 'Bob',
|
|
nationality: null,
|
|
primaryEmail: null,
|
|
primaryPhone: null,
|
|
address: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const out = await PDFDocument.load(filled);
|
|
const form = out.getForm();
|
|
expect(form.getTextField('Email').getText()).toBe(undefined);
|
|
expect(form.getTextField('Address').getText()).toBe(undefined);
|
|
expect(form.getTextField('Name').getText()).toBe('Bob');
|
|
});
|
|
|
|
it('leaves the form interactive (not flattened) so values can be edited', async () => {
|
|
const sourcePdf = await buildSyntheticEoiPdf();
|
|
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
|
|
|
const out = await PDFDocument.load(filled);
|
|
// Field still present and reachable as a TextField → not flattened.
|
|
expect(() => out.getForm().getTextField('Name')).not.toThrow();
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|