From 86dff758980cf4f6f10ec8d923255e8130bf6603 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:54:55 +0200 Subject: [PATCH] test(accounting): cover export scope, unique-violation detector, PDF page cap Closes the test gaps from the PR #622 work + the export-scope feature: - export scope: scopeLedger/normalizeScope (exported via _internal) unit tests + renderTaxReportCsv income/cost/all output assertions (income drops supplier rows, cost drops invoice rows, filename gets the scope tag). - isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint failed" message, false for FK + nullish (the IMAP claim-first race detector). - getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before touching pdftoppm/disk (the per-file resource bound). --- .../services/rasterizePageCap.test.js | 10 +++ .../services/taxReportService.test.js | 78 +++++++++++++++++++ backend/__tests__/utils/dbErrors.test.js | 19 +++++ backend/src/services/taxReportService.js | 2 +- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/services/rasterizePageCap.test.js create mode 100644 backend/__tests__/utils/dbErrors.test.js diff --git a/backend/__tests__/services/rasterizePageCap.test.js b/backend/__tests__/services/rasterizePageCap.test.js new file mode 100644 index 00000000..cd5715b7 --- /dev/null +++ b/backend/__tests__/services/rasterizePageCap.test.js @@ -0,0 +1,10 @@ +const rasterizeService = require('../../src/services/rasterizeService'); + +// The page-range guard runs BEFORE any fs/pdftoppm work, so these reject +// without touching the binary or disk (PR #622 concern 6). +describe('getRenderedPagePath page-range guard', () => { + it.each([0, -1, 201, 1000, 1.5, NaN])('rejects out-of-range page %p', async (page) => { + await expect(rasterizeService.getRenderedPagePath(1, '/tmp/does-not-exist.pdf', page)) + .rejects.toMatchObject({ statusCode: 400, code: 'PAGE_OUT_OF_RANGE' }); + }); +}); diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js index 50eb9fdd..5a87fd39 100644 --- a/backend/__tests__/services/taxReportService.test.js +++ b/backend/__tests__/services/taxReportService.test.js @@ -451,3 +451,81 @@ describe('getTaxReport', () => { expect(out.summary.costGrossMinor).toBe(0); }); }); + +// ----- export scope (income/cost split) -------------------------------- +describe('export scope helpers', () => { + const { scopeLedger, normalizeScope } = taxReportService._internal; + const ledger = [ + { type: 'outgoing', reference: 'R-1' }, + { type: 'incoming', reference: 'IN-1' }, + { type: 'expense', reference: 'EXP-1' }, + ]; + + it('normalizeScope defaults unknown/empty to "all"', () => { + expect(normalizeScope('all')).toBe('all'); + expect(normalizeScope('income')).toBe('income'); + expect(normalizeScope('cost')).toBe('cost'); + expect(normalizeScope('bogus')).toBe('all'); + expect(normalizeScope(undefined)).toBe('all'); + }); + + it('scopeLedger income keeps only outgoing rows', () => { + expect(scopeLedger(ledger, 'income').map((r) => r.type)).toEqual(['outgoing']); + }); + + it('scopeLedger cost keeps incoming + expense rows', () => { + expect(scopeLedger(ledger, 'cost').map((r) => r.type)).toEqual(['incoming', 'expense']); + }); + + it('scopeLedger all keeps everything; null-safe', () => { + expect(scopeLedger(ledger, 'all')).toHaveLength(3); + expect(scopeLedger(null, 'income')).toEqual([]); + }); +}); + +describe('renderTaxReportCsv scope', () => { + beforeEach(() => { + costTablesPresent = true; + invoiceRowsForRun = [{ + id: 1, invoice_number: 'R-2026-0001', issue_date: '2026-01-15', + currency: 'CHF', status: 'paid', vat_rate: 8.1, + net_amount_minor: 100000, vat_amount_minor: 8100, total_amount_minor: 108100, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME', event_name: 'Wedding A', + }]; + inboundRowsForRun = [{ + id: 5, invoice_date: '2026-01-20', created_at: '2026-01-21 09:00:00', + supplier_name: 'Lab AG', description: 'Prints', disposition: 'eigener_aufwand', + tax_treatment: 'domestic', status: 'categorized', event_id: 7, + net_amount_minor: 20000, vat_amount_minor: 1620, total_amount_minor: 21620, + event_name: 'Wedding A', + }]; + }); + + it('income scope keeps the invoice row, drops the supplier cost row', async () => { + const { content, filename } = await taxReportService.renderTaxReportCsv({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'income', + }); + expect(content).toContain('R-2026-0001'); + expect(content).not.toContain('Lab AG'); + expect(filename).toContain('income_'); + }); + + it('cost scope keeps the supplier row, drops the invoice row', async () => { + const { content, filename } = await taxReportService.renderTaxReportCsv({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', scope: 'cost', + }); + expect(content).toContain('Lab AG'); + expect(content).not.toContain('R-2026-0001'); + expect(filename).toContain('cost_'); + }); + + it('all scope (default) keeps both', async () => { + const { content, filename } = await taxReportService.renderTaxReportCsv({ + from: '2026-01-01', to: '2026-03-31', currency: 'CHF', + }); + expect(content).toContain('R-2026-0001'); + expect(content).toContain('Lab AG'); + expect(filename).not.toMatch(/income_|cost_/); + }); +}); diff --git a/backend/__tests__/utils/dbErrors.test.js b/backend/__tests__/utils/dbErrors.test.js new file mode 100644 index 00000000..bbb8c382 --- /dev/null +++ b/backend/__tests__/utils/dbErrors.test.js @@ -0,0 +1,19 @@ +const { isUniqueViolation } = require('../../src/utils/dbErrors'); + +describe('isUniqueViolation (PR #622 blocker 2 race-safety detector)', () => { + it('true for Postgres SQLSTATE 23505', () => { + expect(isUniqueViolation({ code: '23505' })).toBe(true); + }); + it('true for node-sqlite3 SQLITE_CONSTRAINT code', () => { + expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(true); + }); + it('true for a better-sqlite3 "UNIQUE constraint failed" message', () => { + expect(isUniqueViolation({ message: 'UNIQUE constraint failed: received_emails.message_id' })).toBe(true); + }); + it('false for unrelated errors and nullish', () => { + expect(isUniqueViolation({ code: '23503' })).toBe(false); // FK violation + expect(isUniqueViolation({ message: 'connection refused' })).toBe(false); + expect(isUniqueViolation(null)).toBe(false); + expect(isUniqueViolation(undefined)).toBe(false); + }); +}); diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index 63a5c384..60f2fadb 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -1121,5 +1121,5 @@ module.exports = { renderTaxReportPdf, renderTaxReportCsv, // Exposed for unit tests. - _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts }, + _internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts, scopeLedger, normalizeScope }, };