Files
picpeak/backend/__tests__/services/taxReportPdf.test.js
T
Luca b584aaf7a6 test(accounting): update tax-report CSV tests for the unified ledger format
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
2026-06-15 19:54:18 +02:00

264 lines
11 KiB
JavaScript

/**
* Smoke tests for taxReportService.renderTaxReportPdf and
* renderTaxReportCsv. We deep-mock the db (canned invoice rows) +
* businessProfileService (canned issuer) and assert that the
* rendered output meets a few hard requirements:
*
* - PDF starts with the %PDF magic bytes, is non-empty
* - CSV header contains the localised column names
* - CSV body contains the invoice numbers in order
* - CSV totals row contains the grand totals
*/
let invoiceRowsForRun = [];
let replacementsRowsForRun = [];
let callCount = 0;
function makeChain(initialRows) {
return {
_rows: initialRows,
then(onResolve, onReject) {
return Promise.resolve(this._rows).then(onResolve, onReject);
},
leftJoin: jest.fn(function () { return this; }),
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNotIn: jest.fn(function () { return this; }),
whereBetween: jest.fn(function () { return this; }),
whereRaw: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
orderByRaw: jest.fn(function () { return this; }),
select: jest.fn(function () { return Promise.resolve(this._rows); }),
};
}
const mockDbFn = jest.fn((tableName) => {
// Route by table name when supplied — the Skonto aggregate (added
// by migration 126) queries `invoice_payment_log`; the #4 cost side
// queries `inbound_documents` + `expenses`; everything else (main
// listing, replacements lookup) hits `invoices`.
if (tableName === 'invoice_payment_log') return makeChain([]);
if (tableName === 'inbound_documents') return makeChain([]);
if (tableName === 'expenses') return makeChain([]);
callCount += 1;
if (callCount === 1) return makeChain(invoiceRowsForRun);
return makeChain(replacementsRowsForRun);
});
// `.raw()` is used in the .select() column list for the event_name
// COALESCE (migration 123). The chain's select() ignores its
// arguments so the raw() return value just needs to exist.
mockDbFn.raw = jest.fn((sql) => sql);
// #4 loadCosts schema-guards each cost table; default the PDF/CSV
// fixtures to "no accounting tables" so these renderers exercise the
// revenue path unchanged.
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({
profile: {
company_name: 'ACME Test GmbH',
address_line1: 'Teststrasse 1',
postal_code: '8000',
city: 'Zürich',
country_code: 'CH',
email: 'hello@example.com',
default_locale: 'de',
default_currency: 'CHF',
pdf_show_logo: 1,
pdf_show_company_name: 1,
pdf_logo_height: 56,
pdf_company_name_inline: 0,
pdf_folding_marks: 'none',
logo_path: null,
pdf_font_ttf_path: null,
},
bankAccounts: [],
})),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => ({ format: 'DD.MM.YYYY' })),
}));
const taxReportService = require('../../src/services/taxReportService');
beforeEach(() => {
invoiceRowsForRun = [];
replacementsRowsForRun = [];
callCount = 0;
mockDbFn.mockClear();
});
const SAMPLE_ROW = (override = {}) => ({
id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15',
currency: 'CHF', status: 'paid', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'Test Kunde GmbH', customer_first_name: null,
customer_last_name: null, customer_display_name: null, customer_email: null,
event_name: 'Hochzeit Müller',
...override,
});
describe('renderTaxReportPdf', () => {
it('produces a non-empty PDF buffer with the %PDF magic header', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders a header even when no invoices are in the period', async () => {
invoiceRowsForRun = [];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders successfully when cancelled rows are present', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001', status: 'cancelled' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002', replaces_invoice_id: 1 }),
];
replacementsRowsForRun = [{ replaces_invoice_id: 1, invoice_number: 'R-2026-0002' }];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('renders without throwing when a row has long text that must wrap', async () => {
// Customer + event labels long enough to force multi-line wrap
// in their narrow columns. The dynamic row-height logic should
// grow the row to fit rather than overlapping the next one.
invoiceRowsForRun = [
SAMPLE_ROW({
customer_company_name: 'Sehr lange Firmenbezeichnung mit Adresszusatz GmbH & Co. KG',
event_name: 'Hochzeit Müller & Schmidt — ganztägige Reportage inkl. Empfang und Trauung',
}),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(buf.length).toBeGreaterThan(500);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
it('honours the locale parameter (en) without throwing', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// Regression: the page-number footer used to place its baseline
// inside the bottom margin, which made PDFKit auto-paginate one
// empty page per existing page (so a 1-page report ended up as 2,
// and so on). Counting `/Type /Page` markers in the raw PDF bytes
// is the cheapest way to detect a recurrence without parsing the
// PDF — every page object in the xref table carries that marker
// exactly once.
it('does not duplicate pages when stamping the page-number footer', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const buf = await taxReportService.renderTaxReportPdf({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
const pageMarkers = buf.toString('binary').match(/\/Type\s*\/Page\b(?!s)/g) || [];
// Small single-row report should fit on a single page. The
// previous buggy renderer produced 2 (1 content + 1 footer-only).
expect(pageMarkers.length).toBe(1);
});
});
describe('renderTaxReportCsv', () => {
it('returns a CSV blob with the de localised header row', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content, filename, contentType } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'de',
});
expect(contentType).toMatch(/text\/csv/);
expect(filename).toBe('tax_report_2026-01-01_to_2026-03-31_CHF.csv');
const lines = content.split('\r\n');
// Unified ledger CSV: Typ / Referenz / Kunde-Lieferant columns replace the
// old Rechnung/Kunde split.
expect(lines[0]).toContain('Typ'); // de header for tax_col_type
expect(lines[0]).toContain('Referenz'); // de header for tax_col_reference
expect(lines[0]).toContain('Kunde'); // "Kunde / Lieferant"
expect(lines[0]).toContain('Netto');
});
it('lists each invoice on its own row in order', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ id: 1, invoice_number: 'R-2026-0001' }),
SAMPLE_ROW({ id: 2, invoice_number: 'R-2026-0002' }),
SAMPLE_ROW({ id: 3, invoice_number: 'R-2026-0003' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
const idxA = content.indexOf('R-2026-0001');
const idxB = content.indexOf('R-2026-0002');
const idxC = content.indexOf('R-2026-0003');
expect(idxA).toBeGreaterThan(0);
expect(idxB).toBeGreaterThan(idxA);
expect(idxC).toBeGreaterThan(idxB);
});
it('appends a trailing totals row with the grand totals', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
}),
SAMPLE_ROW({
id: 2, invoice_number: 'R-2026-0002',
net_amount_minor: 5000, vat_amount_minor: 385, total_amount_minor: 5385,
}),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Grand totals: net = 150.00, vat = 11.55, total = 161.55.
expect(content).toMatch(/"150\.00"/);
expect(content).toMatch(/"11\.55"/);
expect(content).toMatch(/"161\.55"/);
});
it('flags a cancelled row with a "(Cancelled)" suffix on its Reference cell', async () => {
invoiceRowsForRun = [
SAMPLE_ROW({ status: 'cancelled' }),
];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
// Unified ledger CSV has no separate cancelled column — a cancelled row is
// flagged by appending the localised "(Cancelled)" tag to its Reference.
const dataRow = content.split('\r\n')[1];
expect(dataRow).toContain('R-2026-0001 (Cancelled)');
});
it('uses CRLF line endings (RFC 4180) and BOM-free body', async () => {
invoiceRowsForRun = [SAMPLE_ROW()];
const { content } = await taxReportService.renderTaxReportCsv({
from: '2026-01-01', to: '2026-03-31', currency: 'CHF', locale: 'en',
});
expect(content).toContain('\r\n');
// The route wraps the BOM around the content; the service output
// itself is BOM-free so callers (tests) get a clean string.
expect(content.charCodeAt(0)).not.toBe(0xFEFF);
});
});