feat(eoi): in-app pathway fills the same source PDF as Documenso
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>
This commit is contained in:
176
tests/unit/pdf/fill-eoi-form.test.ts
Normal file
176
tests/unit/pdf/fill-eoi-form.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user