Files
pn-new-crm/tests/unit/pdf-report-renderer.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

226 lines
6.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { DashboardReport } from '@/lib/pdf/reports/dashboard-report';
import { ClientListReport } from '@/lib/pdf/reports/client-list-report';
import { BerthListReport } from '@/lib/pdf/reports/berth-list-report';
import { InterestListReport } from '@/lib/pdf/reports/interest-list-report';
import type { ReportBranding } from '@/lib/pdf/reports/types';
const branding: ReportBranding = {
logoUrl: null,
primaryColor: '#0F4C81',
portName: 'Port Nimara',
};
describe('PDF report renderer', () => {
it('renders a dashboard report with all sections to a non-empty PDF buffer', async () => {
const element = createElement(DashboardReport, {
title: 'Test report',
subtitle: 'Unit-test fixture',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: {
kind: 'dashboard',
widgetIds: [
'kpi_overview',
'pipeline_funnel',
'berth_status',
'source_conversion',
'hot_deals',
],
},
data: {
kpis: {
totalClients: 142,
activeInterests: 27,
pipelineValue: 1250000,
pipelineValueCurrency: 'USD',
occupancyRate: 64.3,
},
pipelineCounts: [
{ stage: 'enquiry', count: 12 },
{ stage: 'qualified', count: 8 },
{ stage: 'eoi', count: 4 },
{ stage: 'reservation', count: 2 },
{ stage: 'deposit_paid', count: 1 },
],
berthStatus: {
total: 120,
available: 80,
underOffer: 10,
maintenance: 5,
sold: 25,
},
sourceConversion: [
{ source: 'website', total: 60, won: 12, lost: 30, conversionRate: 0.2 },
{ source: 'referral', total: 25, won: 8, lost: 10, conversionRate: 0.32 },
],
hotDeals: [
{
id: 'i1',
clientName: 'Acme Corp',
mooringNumber: 'A3',
stage: 'reservation',
lastContact: '2026-05-18T09:00:00.000Z',
},
],
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(2_000);
// PDF files start with `%PDF-` magic bytes - sanity-check that
// the renderer produced an actual PDF, not an error blob or
// empty buffer.
const head = buf.subarray(0, 5).toString('utf-8');
expect(head).toBe('%PDF-');
}, 30_000);
it('skips sections whose widget id is absent from widgetIds', async () => {
const element = createElement(DashboardReport, {
title: 'Sparse report',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: {
kind: 'dashboard',
widgetIds: ['kpi_overview'],
},
data: {
kpis: {
totalClients: 5,
activeInterests: 1,
pipelineValue: 0,
pipelineValueCurrency: 'USD',
occupancyRate: 0,
},
// Provide pipelineCounts even though widgetIds didn't ask for
// it - the renderer should still skip the section since it's
// gated on widgetIds, not data presence.
pipelineCounts: [{ stage: 'enquiry', count: 1 }],
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_000);
}, 30_000);
it('renders a client list report to a non-empty PDF buffer', async () => {
const element = createElement(ClientListReport, {
title: 'Clients',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: { kind: 'clients' },
data: {
rows: [
{
id: 'c1',
fullName: 'Acme Corp',
source: 'website',
nationality: 'GB',
primaryEmail: 'ops@acme.example',
primaryPhone: '+44 20 7946 0000',
createdAt: '2026-04-15T10:00:00Z',
},
{
id: 'c2',
fullName: 'Beta Industries',
source: 'referral',
nationality: null,
primaryEmail: null,
primaryPhone: null,
createdAt: '2026-05-01T10:00:00Z',
},
],
total: 2,
capHit: false,
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_500);
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
}, 30_000);
it('renders a berth list report', async () => {
const element = createElement(BerthListReport, {
title: 'Berths',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: { kind: 'berths' },
data: {
rows: [
{
id: 'b1',
mooringNumber: 'A1',
area: 'A',
status: 'available',
lengthFt: '40',
widthFt: '14',
draftFt: '6',
price: '120000',
priceCurrency: 'USD',
tenureType: 'permanent',
},
],
total: 1,
capHit: false,
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_500);
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
}, 30_000);
it('renders an interest pipeline report', async () => {
const element = createElement(InterestListReport, {
title: 'Pipeline',
branding,
generatedAt: '2026-05-21T12:00:00.000Z',
config: { kind: 'interests' },
data: {
rows: [
{
id: 'i1',
clientName: 'Acme Corp',
primaryMooring: 'A1',
pipelineStage: 'reservation',
source: 'website',
outcome: null,
createdAt: '2026-04-20T10:00:00Z',
},
],
total: 1,
capHit: false,
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_500);
expect(buf.subarray(0, 5).toString('utf-8')).toBe('%PDF-');
}, 30_000);
it('falls back to a stable layout when no logo URL is supplied', async () => {
const element = createElement(DashboardReport, {
title: 'Logoless',
branding: { ...branding, logoUrl: null },
generatedAt: '2026-05-21T12:00:00.000Z',
config: { kind: 'dashboard', widgetIds: ['kpi_overview'] },
data: {
kpis: {
totalClients: 0,
activeInterests: 0,
pipelineValue: 0,
pipelineValueCurrency: 'USD',
occupancyRate: 0,
},
},
});
const buf = await renderToBuffer(element as any);
expect(buf.byteLength).toBeGreaterThan(1_000);
}, 30_000);
});