PR 2 of the behandlingshistorik plan (stacked on #1787). - lib/reports/behandlingshistorik-pdf-template.tsx: landscape A4 react-pdf document. Fixed header (räkenskapsår, urval, legal reference, company) and footer (page x of y, generated in Europe/Stockholm), repeated table header, wrap={false} rows, no `break` props. Two sections in the order the reader needs them: "Ändringar i bokföringssystemet" (p. 9.16 second paragraph) then "Bokföringsposter i registreringsordning" (first paragraph). Meta row: generated, programversion, antal händelser, källor. Details as one wrapped paragraph per row (real-data render 371 events: 1.5 s, 23 pages). Glyphs the bundled Helvetica lacks (arrow, true minus) are mapped to ASCII. - GET /api/reports/behandlingshistorik?format=pdf with a 4 000-event guard (413 REPORT_PDF_TOO_LARGE, CSV/XLSX remain complete); PDF first in the export menu; catalog exports pdf+xlsx. - lib/reports/app-version.ts shared by the route and the archive: revision/systemdokumentation.json now carries system.version and a behandlingshistorik block (where and how it is produced, p. 9.15); the shipped systemdokumentation template §9.3 points at Rapporter > Behandlingshistorik (PDF/CSV/Excel) as well as the backup ZIP. - Settings values that are objects render as "key: value" pairs in every format; report carries category_filter so the document states its urval. - Tests: 4 PDF template tests (valid PDF, empty report, filtered range, 220-row pagination), route pdf 200 + 413, route "unknown format" moved off pdf. Prod read-only render verified visually (header, sections, paging). Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
109 lines
4.8 KiB
TypeScript
109 lines
4.8 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import { renderToBuffer } from '@react-pdf/renderer'
|
||
import { BehandlingshistorikPDF, pdfText } from '../behandlingshistorik-pdf-template'
|
||
import type { BehandlingshistorikEvent, BehandlingshistorikReport } from '../behandlingshistorik-types'
|
||
|
||
// Real @react-pdf/renderer layout is CPU-heavy; give it room on a saturated runner.
|
||
const RENDER_TIMEOUT = 30_000
|
||
|
||
function event(overrides: Partial<BehandlingshistorikEvent>): BehandlingshistorikEvent {
|
||
return {
|
||
id: `ev-${Math.random().toString(36).slice(2, 8)}`,
|
||
occurred_at: '2026-03-10T09:30:00.000Z',
|
||
category: 'verifikation',
|
||
code: 'journal_entry.committed',
|
||
event: 'Verifikation bokförd',
|
||
object: 'A12',
|
||
actor: { type: 'user', user_id: 'user-1', label: 'anna@example.se' },
|
||
details: ['Datum: 2026-03-09', 'Text: Hyra mars', 'Källa: Manuell'],
|
||
source: 'journal_entries',
|
||
count: 1,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function report(overrides: Partial<BehandlingshistorikReport> = {}): BehandlingshistorikReport {
|
||
const events = overrides.events ?? [
|
||
event({ id: 'a1', category: 'kontoplan', code: 'account.created.bulk', event: 'Kontoplan upplagd', object: '41 konton', details: ['Konton 1510 till 8410'], source: 'audit_log', count: 41, occurred_at: '2026-01-05T08:00:00.000Z' }),
|
||
event({ id: 'a2', category: 'installningar', code: 'settings.updated', event: 'Företagsinställningar ändrade', object: null, details: ['Momsperiod: Kvartal → Helår'], source: 'audit_log', occurred_at: '2026-02-01T08:00:00.000Z' }),
|
||
event({ id: 'e1' }),
|
||
event({ id: 'e2', object: 'A13', code: 'journal_entry.reversed', event: 'Verifikation makulerad (storno)', actor: { type: 'api_key', user_id: null, label: 'API-nyckel: Revisorn' }, occurred_at: '2026-04-02T10:00:00.000Z' }),
|
||
]
|
||
const by_category = { verifikation: 0, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 }
|
||
for (const e of events) by_category[e.category] += 1
|
||
return {
|
||
company: { name: 'Testbolaget AB', org_number: '5566778899' },
|
||
period: { id: 'p', name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' },
|
||
range: { from: '2026-01-01', to: '2026-12-31' },
|
||
mode: 'fiscal_year',
|
||
generated_at: '2026-08-21T12:00:00.000Z',
|
||
app_version: 'abc123def456',
|
||
total_events: events.length,
|
||
by_category,
|
||
events,
|
||
category_filter: null,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
describe('pdfText', () => {
|
||
it('maps glyphs the bundled Helvetica lacks to ASCII', () => {
|
||
expect(pdfText('Momsperiod: Kvartal → Helår')).toBe('Momsperiod: Kvartal -> Helår')
|
||
expect(pdfText('−1 200,00')).toBe('-1 200,00')
|
||
expect(pdfText('åäö ÅÄÖ … ·')).toBe('åäö ÅÄÖ … ·')
|
||
})
|
||
})
|
||
|
||
describe('BehandlingshistorikPDF', () => {
|
||
it(
|
||
'renders a valid PDF with both sections',
|
||
async () => {
|
||
const buffer = await renderToBuffer(BehandlingshistorikPDF({ report: report() }))
|
||
expect(buffer).toBeInstanceOf(Buffer)
|
||
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
||
expect(buffer.length).toBeGreaterThan(1000)
|
||
},
|
||
RENDER_TIMEOUT,
|
||
)
|
||
|
||
it(
|
||
'renders an empty report (both sections show the empty line) and a category-filtered date range',
|
||
async () => {
|
||
const empty = await renderToBuffer(
|
||
BehandlingshistorikPDF({
|
||
report: report({ events: [], total_events: 0, by_category: { verifikation: 0, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 } }),
|
||
}),
|
||
)
|
||
expect(empty.slice(0, 5).toString()).toBe('%PDF-')
|
||
|
||
const filtered = await renderToBuffer(
|
||
BehandlingshistorikPDF({
|
||
report: report({ mode: 'date_range', range: { from: '2026-03-01', to: '2026-03-31' }, category_filter: ['verifikation'], app_version: null }),
|
||
}),
|
||
)
|
||
expect(filtered.slice(0, 5).toString()).toBe('%PDF-')
|
||
},
|
||
RENDER_TIMEOUT,
|
||
)
|
||
|
||
it(
|
||
'paginates a long report without splitting rows (no break props: must not hang)',
|
||
async () => {
|
||
const events = Array.from({ length: 220 }, (_, i) =>
|
||
event({
|
||
id: `e${i}`,
|
||
object: `A${i + 1}`,
|
||
occurred_at: new Date(Date.parse('2026-01-01T08:00:00.000Z') + i * 3_600_000).toISOString(),
|
||
details: ['Datum: 2026-01-01', `Text: Rad ${i} med en ganska lång beskrivning som ska radbrytas i detaljkolumnen`, 'Källa: Banktransaktion'],
|
||
}),
|
||
)
|
||
const buffer = await renderToBuffer(BehandlingshistorikPDF({ report: report({ events, total_events: events.length }) }))
|
||
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
||
// A 220-row landscape table is several pages; /Type /Page objects prove pagination happened.
|
||
const pages = (buffer.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length
|
||
expect(pages).toBeGreaterThan(3)
|
||
},
|
||
RENDER_TIMEOUT,
|
||
)
|
||
})
|