Files
pn-new-crm/tests/unit/pdf-brand-kit.test.tsx

122 lines
3.5 KiB
TypeScript
Raw Permalink Normal View History

import { describe, expect, it } from 'vitest';
import { Page, Text } from '@react-pdf/renderer';
import { renderPdf } from '@/lib/pdf/render';
import {
Badge,
BarChart,
DataTable,
DocumentShell,
FunnelChart,
KeyValueGrid,
LineChart,
PDF_TOKENS,
PieChart,
Section,
} from '@/lib/pdf/brand-kit';
describe('pdf brand kit', () => {
it('exposes canonical design tokens', () => {
expect(PDF_TOKENS.colors.headerBand).toBe('#0f172a');
expect(PDF_TOKENS.sizes.body).toBe(10);
expect(PDF_TOKENS.spacing.logoMaxWidth).toBe(200);
});
it('renders a kitchen-sink PDF without throwing', async () => {
const tree = (
<DocumentShell
portName="Port Test"
docTitle="Smoke Report"
docMeta="2026-05-12"
logoBuffer={null}
>
<Section title="Summary">
<KeyValueGrid
rows={[
{ label: 'Total', value: 247 },
{ label: 'Active', value: 'Yes' },
]}
/>
</Section>
<Section title="Charts">
<BarChart
data={[
{ label: 'Mon', value: 10 },
{ label: 'Tue', value: 20 },
]}
/>
<LineChart
data={[
{ label: 'Jan', value: 5 },
{ label: 'Feb', value: 8 },
]}
/>
<PieChart
data={[
{ label: 'A', value: 30 },
{ label: 'B', value: 70 },
]}
/>
<FunnelChart
data={[
{ label: 'Lead', value: 100 },
{ label: 'Closed', value: 25 },
]}
/>
</Section>
<Section title="Table">
<Badge text="Active" tone="success" />
feat(branding): port logo upload pipeline for internal PDFs Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the brand-kit Header pulls in for every internal-only PDF. Server pipeline (src/lib/services/logo.service.ts): - magic-byte format check via sharp metadata - rejects animated/multi-frame inputs - SVGs sanitized via svgo preset-default + post-pass regex check (rejects <script>, on*=, javascript:, external href, <foreignObject>), then rasterized to PNG at 300 DPI - HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp - optional crop coords applied server-side (bounds-checked first) - auto-trim near-white borders - resize so longest edge <= 1200px, sRGB, palette-PNG - rejects undersized output (< 200px any side) or > 1MB - atomic system_settings upsert; soft-archives prior file row + storage object API: GET /api/v1/admin/branding/logo current logo metadata POST /api/v1/admin/branding/logo multipart upload + crop DELETE /api/v1/admin/branding/logo clear; future PDFs fall back to port-name text header GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx with the current logo so admins can spot-check letterboxing in real shell UI: src/components/admin/branding/pdf-logo-uploader.tsx - react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle - file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB) - dark-band preview swatch shows how the logo lands in the header - post-upload warnings panel surfaces every server-side normalization (resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.) - "Test with sample PDF" button streams a real PDF for spot-check - "Remove" tears down the file + storage object + setting Wired into the existing /admin/branding settings page beneath the Identity and Email-branding cards. Audit: Two new AuditAction enum values added: branding.logo.uploaded and branding.logo.archived. Captured per upload + per archived prior logo. Tests: tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path, undersized rejection, empty/oversized rejection, non-image rejection, out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG with embedded script rejection, SVG with external href rejection, JPEG-with-no-alpha warning collection. 1308/1308 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:51:49 +02:00
<DataTable<{ name: string; score: number }>
columns={[
feat(branding): port logo upload pipeline for internal PDFs Phase 1 / commit 2 of 14 — adds the admin-facing logo upload that the brand-kit Header pulls in for every internal-only PDF. Server pipeline (src/lib/services/logo.service.ts): - magic-byte format check via sharp metadata - rejects animated/multi-frame inputs - SVGs sanitized via svgo preset-default + post-pass regex check (rejects <script>, on*=, javascript:, external href, <foreignObject>), then rasterized to PNG at 300 DPI - HEIC/HEIF/AVIF/WEBP all auto-converted to PNG by sharp - optional crop coords applied server-side (bounds-checked first) - auto-trim near-white borders - resize so longest edge <= 1200px, sRGB, palette-PNG - rejects undersized output (< 200px any side) or > 1MB - atomic system_settings upsert; soft-archives prior file row + storage object API: GET /api/v1/admin/branding/logo current logo metadata POST /api/v1/admin/branding/logo multipart upload + crop DELETE /api/v1/admin/branding/logo clear; future PDFs fall back to port-name text header GET /api/v1/admin/branding/logo/sample-pdf renders branding-sample.tsx with the current logo so admins can spot-check letterboxing in real shell UI: src/components/admin/branding/pdf-logo-uploader.tsx - react-image-crop with Wide 3:1 / Square 1:1 / Freeform aspect toggle - file picker accepts PNG/JPEG/WEBP/SVG/HEIC/HEIF/AVIF (up to 5 MB) - dark-band preview swatch shows how the logo lands in the header - post-upload warnings panel surfaces every server-side normalization (resized, trimmed, JPEG no-alpha warning, SVG rasterized, etc.) - "Test with sample PDF" button streams a real PDF for spot-check - "Remove" tears down the file + storage object + setting Wired into the existing /admin/branding settings page beneath the Identity and Email-branding cards. Audit: Two new AuditAction enum values added: branding.logo.uploaded and branding.logo.archived. Captured per upload + per archived prior logo. Tests: tests/unit/logo-service.test.ts (11 tests): sharp pipeline happy path, undersized rejection, empty/oversized rejection, non-image rejection, out-of-bounds crop rejection, in-bounds crop, SVG rasterization, SVG with embedded script rejection, SVG with external href rejection, JPEG-with-no-alpha warning collection. 1308/1308 vitest green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:51:49 +02:00
{ header: 'Name', render: (r) => r.name },
{ header: 'Score', align: 'right', render: (r) => String(r.score) },
]}
rows={[
{ name: 'Alpha', score: 1 },
{ name: 'Beta', score: 2 },
]}
totals={['Total', '3']}
/>
</Section>
</DocumentShell>
);
const bytes = await renderPdf(tree);
expect(bytes).toBeInstanceOf(Buffer);
expect(bytes.length).toBeGreaterThan(1000);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('falls back gracefully when no chart data is provided', async () => {
const tree = (
<DocumentShell portName="Port Empty" docTitle="Empty" logoBuffer={null}>
<BarChart data={[]} />
<LineChart data={[]} />
<PieChart data={[]} />
<FunnelChart data={[]} />
<DataTable columns={[{ header: 'X', render: () => '' }]} rows={[]} />
</DocumentShell>
);
const bytes = await renderPdf(tree);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
});
// Belt-and-suspenders: a minimal direct Page render to confirm @react-pdf/renderer
// is actually installed and produces a real PDF stream, not just our tree.
describe('react-pdf renderer wiring', () => {
it('renders a bare <Page> to bytes', async () => {
const { Document } = await import('@react-pdf/renderer');
const tree = (
<Document>
<Page size="A4">
<Text>hello</Text>
</Page>
</Document>
);
const bytes = await renderPdf(tree);
expect(bytes.length).toBeGreaterThan(500);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
});