Files
pn-new-crm/tests/unit/pdf/fill-eoi-form.test.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

191 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: {
id: 'client-test-1',
fullName: 'Alice Smith',
nationality: 'US',
primaryEmail: 'alice@example.com',
primaryPhone: '+1-555-0100',
address: {
street: '123 Main St',
city: 'Austin',
subdivision: 'TX',
postalCode: '78701',
country: 'United States',
countryIso: 'US',
},
},
yacht: {
id: 'yacht-test-1',
name: 'Sea Breeze',
lengthFt: '45',
widthFt: '14',
draftFt: '6',
lengthM: null,
widthM: null,
draftM: null,
lengthUnit: 'ft' as const,
widthUnit: 'ft' as const,
draftUnit: 'ft' as const,
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/);
});
});