Standalone expenses (date, description, category, amount) with the same equal/own/payer split machinery as entry costs, merged into the costs panel and settle-up as a single expense bucket. Self-fetching card with per-day grouping, client-side sorting, and quick-add. CSV export interleaves expenses with priced entries, one share column per member (UTF-8 BOM, RFC 4180, formula-injection guard on text cells). Also fixes trip deletion, which hit a foreign-key violation and rolled back for any trip with checklist items.
190 lines
7.7 KiB
JavaScript
190 lines
7.7 KiB
JavaScript
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');
|
|
});
|