b584aaf7a6
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.
264 lines
11 KiB
JavaScript
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);
|
|
});
|
|
});
|