import { test } from 'node:test'; import assert from 'node:assert/strict'; import { buildExpenseCsv } from '../src/server/util/expenseCsv.js'; import { BOM, readCsv } from './helpers/csv.js'; // Unit tests for the pure CSV builder. The endpoint that serves its output is // covered in tests/expenses-export.test.js. const TRIP = { id: 3, name: 'Trip', currency: 'EUR' }; const MEMBERS = [{ id: 1, display_name: 'anna' }, { id: 2, display_name: 'ben' }]; function csvRows(rows, members = MEMBERS) { return readCsv(buildExpenseCsv({ trip: TRIP, members, rows })); } function baseRow(over = {}) { return { date: '2026-08-01', source: 'expense', category: 'food', description: 'Lunch', amount: 100, paid_by: 1, split_mode: 'equal', participants: [], ...over, }; } test('buildExpenseCsv: header row, BOM and CRLF with no data rows', () => { const csv = buildExpenseCsv({ trip: TRIP, members: MEMBERS, rows: [] }); assert.ok(csv.startsWith(BOM), 'the builder returns the complete file, BOM included'); assert.ok(csv.endsWith('\r\n'), 'CRLF line endings'); assert.ok(!csv.includes('\n\n')); const { header, rows } = readCsv(csv); assert.deepEqual(header, [ 'date', 'source', 'category', 'description', 'amount', 'currency', 'paid_by', 'split', 'participants', 'share: anna', 'share: ben', ]); assert.deepEqual(rows, []); }); test('buildExpenseCsv: quotes fields containing a comma, a quote, or a newline', () => { const raw = buildExpenseCsv({ trip: TRIP, members: MEMBERS, rows: [ baseRow({ description: 'Dinner, "the good" place' }), baseRow({ description: 'Two\r\nlines' }), baseRow({ description: 'Plain' }), ], }); // Round-trips through a strict reader with the values intact. const { rows } = readCsv(raw); assert.deepEqual(rows.map((r) => r.description), [ 'Dinner, "the good" place', 'Two\r\nlines', 'Plain', ]); // And the raw bytes use RFC 4180 escaping, not stripping or backslashes. assert.ok(raw.includes('"Dinner, ""the good"" place"'), 'embedded quotes are doubled, whole field quoted'); assert.ok(!raw.includes('\\"'), 'no backslash escaping'); assert.ok(raw.includes(',Plain,'), 'a field needing no quoting is left bare'); }); test('buildExpenseCsv: guards every leading formula character, and only the leading one', () => { const raw = buildExpenseCsv({ trip: TRIP, members: MEMBERS, rows: [ baseRow({ description: '=SUM(A1)' }), baseRow({ description: '+1 tip' }), baseRow({ description: '-5 refund' }), baseRow({ description: '@here' }), // Tab and CR can only arrive through the pure function — the route trims // them off the description before storing. baseRow({ description: '\tTabbed' }), baseRow({ description: '\rCarriage' }), baseRow({ description: 'Total = 5' }), baseRow({ description: 'a-b+c' }), baseRow({ description: '2 coffees' }), baseRow({ description: 'Lunch' }), ], }); const { rows } = readCsv(raw); assert.deepEqual(rows.map((r) => r.description), [ "'=SUM(A1)", "'+1 tip", "'-5 refund", "'@here", "'\tTabbed", "'\rCarriage", // Values starting with a digit or a letter are left exactly as typed. 'Total = 5', 'a-b+c', '2 coffees', 'Lunch', ]); assert.deepEqual(rows.map((r) => r.amount), Array(10).fill('100.00'), 'amount is never guarded'); // Unquoted where no RFC 4180 character is present — the guard alone does not // force quoting. assert.ok(raw.includes(",'=SUM(A1),"), 'guarded but not quoted'); assert.ok(raw.includes(',Total = 5,'), 'a non-leading = is untouched and unquoted'); }); test('buildExpenseCsv: a guarded field with a comma is both prefixed and quoted', () => { const raw = buildExpenseCsv({ trip: TRIP, members: MEMBERS, rows: [ baseRow({ description: '=A,B' }), baseRow({ description: '=HYPERLINK("http://evil"), click' }), ], }); assert.ok(raw.includes('"\'=A,B"'), 'guard runs first, then the whole field is quoted'); assert.ok( raw.includes('"\'=HYPERLINK(""http://evil""), click"'), 'guard prefix inside the quotes, embedded quotes doubled' ); const { rows } = readCsv(raw); assert.deepEqual(rows.map((r) => r.description), [ '\'=A,B', '\'=HYPERLINK("http://evil"), click', ]); }); test('buildExpenseCsv: category, payer and participant names are guarded too', () => { const members = [ { id: 1, display_name: '=evil-one' }, { id: 2, display_name: '+evil-two' }, ]; const { header, rows } = csvRows( [baseRow({ category: '=food', paid_by: 1, participants: [1, 2] })], members ); assert.equal(rows[0].category, "'=food"); assert.equal(rows[0].paid_by, "'=evil-one"); assert.equal(rows[0].participants, "'=evil-one;+evil-two", 'the joined string is guarded once, on its first character'); // The `share:` header cells are deliberately NOT prefixed: the guard looks at // the first character of the whole cell, and "share: " already puts a letter // there, so Excel can never read it as a formula. Prefixing would corrupt the // column name for no gain. assert.deepEqual(header.slice(-2), ['share: =evil-one', 'share: +evil-two']); }); test('buildExpenseCsv: quotes a member display name containing a comma in the header', () => { const { header } = csvRows([], [{ id: 1, display_name: 'Ann, Marie' }]); assert.equal(header[header.length - 1], 'share: Ann, Marie'); const raw = buildExpenseCsv({ trip: TRIP, members: [{ id: 1, display_name: 'Ann, Marie' }], rows: [] }); assert.ok(raw.includes('"share: Ann, Marie"'), 'header cell is quoted too'); }); test('buildExpenseCsv: effective totals and per-member shares for each split mode', () => { const { rows } = csvRows([ baseRow({ description: 'Equal', amount: 100 }), baseRow({ description: 'Own', amount: 100, split_mode: 'own', paid_by: null }), baseRow({ description: 'Payer', amount: 100, split_mode: 'payer', paid_by: 2 }), baseRow({ description: 'Subset', amount: 100, participants: [2] }), ]); const [equal, own, payer, subset] = rows; assert.deepEqual([equal.amount, equal['share: anna'], equal['share: ben']], ['100.00', '50.00', '50.00']); assert.deepEqual([own.amount, own['share: anna'], own['share: ben']], ['200.00', '100.00', '100.00']); assert.deepEqual([payer.amount, payer['share: anna'], payer['share: ben']], ['100.00', '0.00', '100.00']); assert.deepEqual([subset.amount, subset['share: anna'], subset['share: ben']], ['100.00', '0.00', '100.00']); assert.equal(equal.participants, 'all'); assert.equal(subset.participants, 'ben'); assert.equal(own.paid_by, '', 'null payer renders as an empty cell'); assert.equal(equal.currency, 'EUR', 'currency comes from the trip'); }); test('buildExpenseCsv: shares are 2dp even when the split does not divide evenly', () => { const members = [ { id: 1, display_name: 'anna' }, { id: 2, display_name: 'ben' }, { id: 3, display_name: 'carol' }, ]; const { rows } = csvRows([baseRow({ amount: 100, participants: [] })], members); assert.deepEqual( [rows[0]['share: anna'], rows[0]['share: ben'], rows[0]['share: carol']], ['33.33', '33.33', '33.33'] ); assert.equal(rows[0].amount, '100.00'); }); test('buildExpenseCsv: rows are emitted in the order given (the route pre-sorts)', () => { const { rows } = csvRows([ baseRow({ date: '2026-08-01', description: 'first' }), baseRow({ date: '2026-08-02', description: 'second', source: 'entry', category: 'stay' }), baseRow({ date: '2026-08-03', description: 'third' }), ]); assert.deepEqual(rows.map((r) => r.description), ['first', 'second', 'third']); assert.deepEqual(rows.map((r) => r.source), ['expense', 'entry', 'expense']); assert.equal(rows[1].category, 'stay'); });