Add daily expense tracking with sort/split and CSV export
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.
This commit is contained in:
@@ -231,6 +231,20 @@ test('access control: non-member gets 404 on every trip-scoped and item-scoped r
|
||||
assert.equal((await outsider.delete(`/api/checklist/${item.id}`)).status, 404);
|
||||
});
|
||||
|
||||
test('deleting a trip removes its checklist items', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/checklist`).send({ text: 'Passport' });
|
||||
|
||||
// checklist_items.trip_id has a real FK to trips(id) and foreign_keys is ON,
|
||||
// so a trip delete that forgets this table fails the whole transaction (500).
|
||||
assert.equal((await agent.delete(`/api/trips/${trip.id}`)).status, 204);
|
||||
const left = app.locals.db
|
||||
.prepare('SELECT COUNT(*) AS c FROM checklist_items WHERE trip_id = ?')
|
||||
.get(trip.id).c;
|
||||
assert.equal(left, 0);
|
||||
});
|
||||
|
||||
test('access control: unknown item id -> 404', async () => {
|
||||
const { agent } = await createAccount();
|
||||
await makeTrip(agent);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
import { BOM, readCsv } from './helpers/csv.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expense-csv-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Account with a fixed display_name so CSV columns and payer cells are deterministic.
|
||||
async function createAccount(displayName) {
|
||||
const agent = request.agent(app);
|
||||
const created = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(created.status, 201);
|
||||
const named = await agent.patch('/api/auth/me').send({ display_name: displayName });
|
||||
assert.equal(named.status, 200);
|
||||
return { agent, user: named.body.user };
|
||||
}
|
||||
|
||||
async function makeTrip(owner, members = [], overrides = {}) {
|
||||
const trip = (await owner.agent.post('/api/trips').send({
|
||||
name: 'Thailand Trip 2026!', start_date: '2026-08-01', end_date: '2026-08-10',
|
||||
currency: 'THB', ...overrides,
|
||||
})).body.trip;
|
||||
for (const m of members) {
|
||||
assert.equal((await m.agent.post('/api/trips/join').send({ code: trip.join_code })).status, 200);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
async function memberNames(account, trip) {
|
||||
const res = await account.agent.get(`/api/trips/${trip.id}`);
|
||||
assert.equal(res.status, 200);
|
||||
return res.body.members.map((m) => m.display_name);
|
||||
}
|
||||
|
||||
const exportUrl = (trip) => `/api/trips/${trip.id}/expenses/export.csv`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response headers & encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('export: content type, filename slug, BOM and CRLF endings', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna);
|
||||
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Lunch', amount: 100, category: 'food',
|
||||
});
|
||||
|
||||
const res = await anna.agent.get(exportUrl(trip));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'text/csv; charset=utf-8');
|
||||
assert.equal(
|
||||
res.headers['content-disposition'],
|
||||
'attachment; filename="thailand-trip-2026-expenses.csv"',
|
||||
'slug lowercases, collapses non-alphanumeric runs to "-" and trims them'
|
||||
);
|
||||
assert.ok(res.text.startsWith(BOM), 'UTF-8 BOM so Excel reads it as UTF-8');
|
||||
assert.ok(res.text.includes('\r\n'), 'CRLF line endings');
|
||||
// parseCsv fails on any bare LF/CR, so this is the real line-ending assertion.
|
||||
const { rows } = readCsv(res.text);
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test('export: filename falls back to trip-<id> when the name has no alphanumerics', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna, [], { name: '!!! ???' });
|
||||
|
||||
const res = await anna.agent.get(exportUrl(trip));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-disposition'], `attachment; filename="trip-${trip.id}-expenses.csv"`);
|
||||
});
|
||||
|
||||
test('export: an empty trip yields the header row only', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
const { header, rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||
assert.deepEqual(header, [
|
||||
'date', 'source', 'category', 'description', 'amount', 'currency',
|
||||
'paid_by', 'split', 'participants', 'share: anna',
|
||||
]);
|
||||
assert.deepEqual(rows, [], 'no totals row — data rows only');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns, quoting, interleaving and shares
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('export: expenses and priced entries interleave by date with correct shares', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const entries = `/api/trips/${trip.id}/entries`;
|
||||
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||
|
||||
// 08-01 entry, equal, 900 total -> 450 each.
|
||||
await anna.agent.post(entries).send({
|
||||
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 900, paid_by: anna.user.id,
|
||||
});
|
||||
// 08-02 expense with a comma AND a double quote in the description.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-02', description: 'Dinner, "the good" place', amount: 200,
|
||||
category: 'food', paid_by: ben.user.id,
|
||||
});
|
||||
// 08-03 expense, payer split -> ben alone bears it.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-03', description: 'Solo souvenir', amount: 50, category: 'shopping',
|
||||
split_mode: 'payer', paid_by: ben.user.id,
|
||||
});
|
||||
// 08-04 expense, equal but only ben participates -> ben 60, anna 0.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-04', description: 'Taxi', amount: 60, category: 'transport',
|
||||
paid_by: ben.user.id, participants: [ben.user.id],
|
||||
});
|
||||
// 08-05 entry, own split at 300/head -> effective total 600, 300 each.
|
||||
await anna.agent.post(entries).send({
|
||||
date: '2026-08-05', type: 'flight', title: 'Flights', price: 300, split_mode: 'own',
|
||||
});
|
||||
// Unpriced entry — must not appear at all.
|
||||
await anna.agent.post(entries).send({ date: '2026-08-06', type: 'activity', title: 'Free walk' });
|
||||
|
||||
const { header, rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||
assert.deepEqual(header.slice(-2), ['share: anna', 'share: ben'], 'one share column per member, in display order');
|
||||
assert.deepEqual(await memberNames(anna, trip), ['anna', 'ben']);
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.date, r.source, r.description]),
|
||||
[
|
||||
['2026-08-01', 'entry', 'Hotel'],
|
||||
['2026-08-02', 'expense', 'Dinner, "the good" place'],
|
||||
['2026-08-03', 'expense', 'Solo souvenir'],
|
||||
['2026-08-04', 'expense', 'Taxi'],
|
||||
['2026-08-05', 'entry', 'Flights'],
|
||||
],
|
||||
'chronological, sources interleaved, unpriced entry excluded'
|
||||
);
|
||||
|
||||
const [hotel, dinner, souvenir, taxi, flights] = rows;
|
||||
|
||||
assert.deepEqual(hotel, {
|
||||
date: '2026-08-01', source: 'entry', category: 'stay', description: 'Hotel',
|
||||
amount: '900.00', currency: 'THB', paid_by: 'anna', split: 'equal',
|
||||
participants: 'all', 'share: anna': '450.00', 'share: ben': '450.00',
|
||||
});
|
||||
|
||||
assert.equal(dinner.category, 'food');
|
||||
assert.equal(dinner.amount, '200.00');
|
||||
assert.equal(dinner.paid_by, 'ben');
|
||||
assert.equal(dinner.participants, 'all');
|
||||
assert.deepEqual([dinner['share: anna'], dinner['share: ben']], ['100.00', '100.00']);
|
||||
|
||||
assert.equal(souvenir.split, 'payer');
|
||||
assert.equal(souvenir.amount, '50.00');
|
||||
assert.deepEqual([souvenir['share: anna'], souvenir['share: ben']], ['0.00', '50.00']);
|
||||
|
||||
assert.equal(taxi.participants, 'ben', 'explicit subset lists display names');
|
||||
assert.deepEqual([taxi['share: anna'], taxi['share: ben']], ['0.00', '60.00'],
|
||||
'non-participants get 0.00, not an empty cell');
|
||||
|
||||
assert.equal(flights.category, 'flight', 'entry rows carry the entry type as category');
|
||||
assert.equal(flights.amount, '600.00', "'own' amount is the effective total (300 x 2)");
|
||||
assert.equal(flights.paid_by, '', 'unassigned payer is an empty cell');
|
||||
assert.deepEqual([flights['share: anna'], flights['share: ben']], ['300.00', '300.00']);
|
||||
});
|
||||
|
||||
test('export: participants column joins several display names with semicolons', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const carol = await createAccount('carol');
|
||||
const trip = await makeTrip(anna, [ben, carol]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Shared tuk-tuk', amount: 90, category: 'transport',
|
||||
paid_by: anna.user.id, participants: [carol.user.id, anna.user.id],
|
||||
});
|
||||
|
||||
const { rows } = readCsv((await anna.agent.get(exportUrl(trip))).text);
|
||||
assert.equal(rows[0].participants, 'anna;carol');
|
||||
assert.deepEqual(
|
||||
[rows[0]['share: anna'], rows[0]['share: ben'], rows[0]['share: carol']],
|
||||
['45.00', '0.00', '45.00']
|
||||
);
|
||||
});
|
||||
|
||||
test('export: formula-injection guard on text columns, numbers left alone', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const evil = await createAccount('=evil-payer');
|
||||
const trip = await makeTrip(anna, [evil]);
|
||||
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||
|
||||
const post = (date, description, amount, extra = {}) =>
|
||||
anna.agent.post(expenses).send({
|
||||
date, description, amount, category: 'food', paid_by: evil.user.id, ...extra,
|
||||
});
|
||||
|
||||
await post('2026-08-01', '=HYPERLINK("http://evil")', 380);
|
||||
await post('2026-08-02', '-50 refund', 50);
|
||||
await post('2026-08-03', '+2 beers', 20);
|
||||
await post('2026-08-04', '@lunch spot', 30);
|
||||
// Leading `=` AND an embedded comma: must be guarded *and* RFC 4180 quoted.
|
||||
await post('2026-08-05', '=SUM(1,2) sneaky', 40, { participants: [evil.user.id] });
|
||||
// `=` that is not the first character must be left exactly as typed.
|
||||
await post('2026-08-06', 'Total = 5 each', 60);
|
||||
|
||||
const res = await anna.agent.get(exportUrl(trip));
|
||||
const { rows } = readCsv(res.text);
|
||||
|
||||
assert.deepEqual(rows.map((r) => r.description), [
|
||||
"'=HYPERLINK(\"http://evil\")",
|
||||
"'-50 refund",
|
||||
"'+2 beers",
|
||||
"'@lunch spot",
|
||||
"'=SUM(1,2) sneaky",
|
||||
'Total = 5 each',
|
||||
]);
|
||||
|
||||
// Numeric columns are server-formatted and must never pick up the prefix.
|
||||
assert.deepEqual(rows.map((r) => r.amount), ['380.00', '50.00', '20.00', '30.00', '40.00', '60.00']);
|
||||
assert.ok(
|
||||
rows.every((r) => !r['share: anna'].startsWith("'") && !r['share: =evil-payer'].startsWith("'")),
|
||||
'share columns are numeric and unguarded'
|
||||
);
|
||||
|
||||
// Display names reach the spreadsheet through paid_by and participants too.
|
||||
assert.ok(rows.every((r) => r.paid_by === "'=evil-payer"), 'payer name is guarded');
|
||||
assert.equal(rows[4].participants, "'=evil-payer", 'participant names are guarded');
|
||||
|
||||
// The guard runs before quoting, so the comma row carries both.
|
||||
assert.ok(res.text.includes('"\'=SUM(1,2) sneaky"'), 'guard prefix sits inside the quoted field');
|
||||
});
|
||||
|
||||
test('export: non-member gets 404, unauthenticated gets 401', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const trip = await makeTrip(anna);
|
||||
const outsider = await createAccount('outsider');
|
||||
|
||||
assert.equal((await outsider.agent.get(exportUrl(trip))).status, 404);
|
||||
assert.equal((await request(app).get(exportUrl(trip))).status, 401);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Costs merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('costs: expenses merge with entry costs into one settle-up', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const entries = `/api/trips/${trip.id}/entries`;
|
||||
const expenses = `/api/trips/${trip.id}/expenses`;
|
||||
|
||||
// Entry: 900 equal, anna paid.
|
||||
await anna.agent.post(entries).send({
|
||||
date: '2026-08-01', type: 'stay', title: 'Hotel', price: 900, paid_by: anna.user.id,
|
||||
});
|
||||
// Expense: 200 equal, ben paid.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-02', description: 'Dinner', amount: 200, category: 'food', paid_by: ben.user.id,
|
||||
});
|
||||
// Expense: 40 own -> effective 80, each pays their own, no debt.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-02', description: 'Bus', amount: 40, category: 'transport', split_mode: 'own',
|
||||
});
|
||||
// Expense: 300 equal with no payer -> unassigned.
|
||||
await anna.agent.post(expenses).send({
|
||||
date: '2026-08-03', description: 'Cooking class', amount: 300, category: 'activities',
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.currency, 'THB');
|
||||
assert.equal(c.totalCost, 1480, '900 entry + 200 + 80 + 300 expenses');
|
||||
assert.deepEqual(c.byType, { stay: 900, expense: 580 }, 'all expenses land in one `expense` bucket');
|
||||
assert.equal(c.unassigned, 300);
|
||||
|
||||
const a = c.perUser.find((u) => u.userId === anna.user.id);
|
||||
const b = c.perUser.find((u) => u.userId === ben.user.id);
|
||||
assert.deepEqual([a.share, a.paid, a.net], [740, 940, 200]);
|
||||
assert.deepEqual([b.share, b.paid, b.net], [740, 240, -500]);
|
||||
|
||||
assert.deepEqual(c.settlements, [{ fromUserId: ben.user.id, toUserId: anna.user.id, amount: 200 }]);
|
||||
});
|
||||
|
||||
test('costs: a trip with only expenses still settles up', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Museum tickets', amount: 500, category: 'activities',
|
||||
paid_by: anna.user.id,
|
||||
});
|
||||
|
||||
const c = (await ben.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 500);
|
||||
assert.deepEqual(c.byType, { expense: 500 });
|
||||
assert.equal(c.unassigned, 0);
|
||||
assert.deepEqual(c.settlements, [{ fromUserId: ben.user.id, toUserId: anna.user.id, amount: 250 }]);
|
||||
});
|
||||
|
||||
test('costs: deleting an expense removes it from the settle-up again', async () => {
|
||||
const anna = await createAccount('anna');
|
||||
const ben = await createAccount('ben');
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
const e = (await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Museum tickets', amount: 500, category: 'activities',
|
||||
paid_by: anna.user.id,
|
||||
})).body.expense;
|
||||
|
||||
assert.equal((await anna.agent.delete(`/api/expenses/${e.id}`)).status, 204);
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 0);
|
||||
assert.deepEqual(c.byType, {});
|
||||
assert.deepEqual(c.settlements, []);
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-expenses-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createAccount() {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(res.status, 201);
|
||||
return { agent, user: res.body.user };
|
||||
}
|
||||
|
||||
// Trip owned by `owner`; every account in `members` joins via the join code.
|
||||
async function makeTrip(owner, members = []) {
|
||||
const trip = (await owner.agent.post('/api/trips').send({
|
||||
name: 'Spending', start_date: '2026-08-01', end_date: '2026-08-10', currency: 'THB',
|
||||
})).body.trip;
|
||||
for (const m of members) {
|
||||
assert.equal((await m.agent.post('/api/trips/join').send({ code: trip.join_code })).status, 200);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
async function addExpense(account, trip, body) {
|
||||
const res = await account.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Lunch', amount: 100, ...body,
|
||||
});
|
||||
assert.equal(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
|
||||
return res.body.expense;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('POST expense: defaults applied, created_by is the caller', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
const res = await anna.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: ' Street food dinner ', amount: 380,
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const e = res.body.expense;
|
||||
assert.equal(e.trip_id, trip.id);
|
||||
assert.equal(e.date, '2026-08-02');
|
||||
assert.equal(e.description, 'Street food dinner', 'description is trimmed');
|
||||
assert.equal(e.category, 'other');
|
||||
assert.equal(e.amount, 380);
|
||||
assert.equal(e.paid_by, null);
|
||||
assert.equal(e.split_mode, 'equal');
|
||||
assert.deepEqual(e.participants, []);
|
||||
assert.equal(e.created_by, anna.user.id);
|
||||
assert.ok(Number.isInteger(e.id));
|
||||
});
|
||||
|
||||
test('POST expense: every field round-trips, participants come back sorted', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
const e = await addExpense(anna, trip, {
|
||||
date: '2026-08-03', description: 'Songthaew', amount: 60.5, category: 'transport',
|
||||
paid_by: ben.user.id, split_mode: 'equal', participants: [ben.user.id, anna.user.id],
|
||||
});
|
||||
assert.equal(e.category, 'transport');
|
||||
assert.equal(e.amount, 60.5);
|
||||
assert.equal(e.paid_by, ben.user.id);
|
||||
assert.deepEqual(e.participants, [anna.user.id, ben.user.id].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('POST expense: amount 0 is allowed; every category in the enum is accepted', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
const zero = await addExpense(anna, trip, { amount: 0 });
|
||||
assert.equal(zero.amount, 0);
|
||||
|
||||
for (const category of ['food', 'drinks', 'transport', 'activities', 'shopping', 'accommodation', 'other']) {
|
||||
const e = await addExpense(anna, trip, { category });
|
||||
assert.equal(e.category, category);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List, ordering & summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('GET expenses: ordered by (date, id)', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
// Created deliberately out of date order.
|
||||
const late = await addExpense(anna, trip, { date: '2026-08-05', description: 'late' });
|
||||
const earlyA = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-a' });
|
||||
const mid = await addExpense(anna, trip, { date: '2026-08-03', description: 'mid' });
|
||||
const earlyB = await addExpense(anna, trip, { date: '2026-08-01', description: 'early-b' });
|
||||
|
||||
const res = await anna.agent.get(`/api/trips/${trip.id}/expenses`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(
|
||||
res.body.expenses.map((e) => e.id),
|
||||
[earlyA.id, earlyB.id, mid.id, late.id],
|
||||
'same-date rows fall back to id order'
|
||||
);
|
||||
});
|
||||
|
||||
test('GET expenses: summary total/byCategory/byDay; `own` counts amount x participants', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
// equal: effective total = amount (100)
|
||||
await addExpense(anna, trip, { date: '2026-08-01', description: 'Dinner', amount: 100, category: 'food', paid_by: anna.user.id });
|
||||
// own with no participants rows = both members: effective total = 50 x 2 = 100
|
||||
await addExpense(anna, trip, { date: '2026-08-01', description: 'Bus tickets', amount: 50, category: 'transport', split_mode: 'own' });
|
||||
// payer: effective total = amount (30)
|
||||
await addExpense(anna, trip, { date: '2026-08-02', description: 'Souvenir', amount: 30, category: 'shopping', split_mode: 'payer', paid_by: ben.user.id });
|
||||
|
||||
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
|
||||
assert.equal(s.total, 230);
|
||||
assert.deepEqual(s.byCategory, { food: 100, transport: 100, shopping: 30 });
|
||||
assert.deepEqual(s.byDay, [
|
||||
{ date: '2026-08-01', total: 200 },
|
||||
{ date: '2026-08-02', total: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('GET expenses: `own` with a participants subset scales by that subset only', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const carol = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben, carol]);
|
||||
|
||||
// 3 members, but only 2 participate -> 40 x 2 = 80 (not 120).
|
||||
await addExpense(anna, trip, {
|
||||
date: '2026-08-04', description: 'Cable car', amount: 40, category: 'activities',
|
||||
split_mode: 'own', participants: [anna.user.id, carol.user.id],
|
||||
});
|
||||
|
||||
const s = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.summary;
|
||||
assert.equal(s.total, 80);
|
||||
assert.deepEqual(s.byCategory, { activities: 80 });
|
||||
assert.deepEqual(s.byDay, [{ date: '2026-08-04', total: 80 }]);
|
||||
});
|
||||
|
||||
test('GET expenses: empty trip yields an empty list and a zeroed summary', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
const body = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body;
|
||||
assert.deepEqual(body.expenses, []);
|
||||
assert.equal(body.summary.total, 0);
|
||||
assert.deepEqual(body.summary.byCategory, {});
|
||||
assert.deepEqual(body.summary.byDay, []);
|
||||
});
|
||||
|
||||
test('GET expenses: all members see all expenses (no personal/hidden rows)', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
const byAnna = await addExpense(anna, trip, { description: 'Anna paid' });
|
||||
const byBen = await addExpense(ben, trip, { description: 'Ben paid' });
|
||||
|
||||
const forBen = (await ben.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
|
||||
assert.deepEqual(forBen.map((e) => e.id).sort((a, b) => a - b), [byAnna.id, byBen.id].sort((a, b) => a - b));
|
||||
assert.equal(forBen.find((e) => e.id === byAnna.id).created_by, anna.user.id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('PATCH expense: each field individually', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const e = await addExpense(anna, trip, {});
|
||||
const url = `/api/expenses/${e.id}`;
|
||||
|
||||
const date = await anna.agent.patch(url).send({ date: '2026-08-07' });
|
||||
assert.equal(date.status, 200);
|
||||
assert.equal(date.body.expense.date, '2026-08-07');
|
||||
|
||||
assert.equal((await anna.agent.patch(url).send({ description: 'Brunch' })).body.expense.description, 'Brunch');
|
||||
assert.equal((await anna.agent.patch(url).send({ amount: 12.75 })).body.expense.amount, 12.75);
|
||||
assert.equal((await anna.agent.patch(url).send({ category: 'drinks' })).body.expense.category, 'drinks');
|
||||
|
||||
const paid = await anna.agent.patch(url).send({ paid_by: ben.user.id });
|
||||
assert.equal(paid.body.expense.paid_by, ben.user.id);
|
||||
assert.equal((await anna.agent.patch(url).send({ paid_by: null })).body.expense.paid_by, null);
|
||||
|
||||
assert.equal((await anna.agent.patch(url).send({ split_mode: 'own' })).body.expense.split_mode, 'own');
|
||||
});
|
||||
|
||||
test('PATCH expense: participants replaces the whole set; [] means all members', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const carol = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben, carol]);
|
||||
const e = await addExpense(anna, trip, { participants: [anna.user.id, ben.user.id] });
|
||||
const url = `/api/expenses/${e.id}`;
|
||||
|
||||
const swapped = await anna.agent.patch(url).send({ participants: [carol.user.id] });
|
||||
assert.equal(swapped.status, 200);
|
||||
assert.deepEqual(swapped.body.expense.participants, [carol.user.id], 'replaces, does not merge');
|
||||
|
||||
const cleared = await anna.agent.patch(url).send({ participants: [] });
|
||||
assert.deepEqual(cleared.body.expense.participants, [], '[] = all members');
|
||||
|
||||
const nulled = await anna.agent.patch(url).send({ participants: [anna.user.id] });
|
||||
assert.deepEqual(nulled.body.expense.participants, [anna.user.id]);
|
||||
assert.deepEqual((await anna.agent.patch(url).send({ participants: null })).body.expense.participants, []);
|
||||
});
|
||||
|
||||
test('PATCH expense: any member may edit another member\'s expense; created_by never changes', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const e = await addExpense(anna, trip, { description: 'Anna logged this' });
|
||||
|
||||
const res = await ben.agent.patch(`/api/expenses/${e.id}`).send({ description: 'Ben corrected it' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.expense.description, 'Ben corrected it');
|
||||
assert.equal(res.body.expense.created_by, anna.user.id, 'created_by is informational and stays put');
|
||||
});
|
||||
|
||||
test('created_by is server-set and ignored in POST/PATCH bodies', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
|
||||
const created = await addExpense(anna, trip, { created_by: ben.user.id });
|
||||
assert.equal(created.created_by, anna.user.id, 'POST body created_by is ignored');
|
||||
|
||||
const patched = await anna.agent.patch(`/api/expenses/${created.id}`).send({ created_by: ben.user.id });
|
||||
assert.equal(patched.status, 200);
|
||||
assert.equal(patched.body.expense.created_by, anna.user.id, 'PATCH body created_by is ignored');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('DELETE expense -> 204, gone from the list, participant rows cleaned up', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const e = await addExpense(anna, trip, { participants: [anna.user.id] });
|
||||
|
||||
const before = app.locals.db
|
||||
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
|
||||
.get(e.id).c;
|
||||
assert.equal(before, 1);
|
||||
|
||||
assert.equal((await ben.agent.delete(`/api/expenses/${e.id}`)).status, 204, 'any member may delete');
|
||||
|
||||
const list = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses;
|
||||
assert.ok(!list.some((x) => x.id === e.id));
|
||||
|
||||
const after = app.locals.db
|
||||
.prepare('SELECT COUNT(*) AS c FROM expense_participants WHERE expense_id = ?')
|
||||
.get(e.id).c;
|
||||
assert.equal(after, 0, 'expense_participants rows are deleted too');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('validation: bad date, description, amount and category -> 400', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
const base = `/api/trips/${trip.id}/expenses`;
|
||||
const ok = { date: '2026-08-02', description: 'Lunch', amount: 100 };
|
||||
const post = (body) => anna.agent.post(base).send({ ...ok, ...body });
|
||||
|
||||
// date
|
||||
assert.equal((await anna.agent.post(base).send({ description: 'x', amount: 1 })).status, 400, 'date required');
|
||||
assert.equal((await post({ date: 'not-a-date' })).status, 400);
|
||||
assert.equal((await post({ date: '2026-13-01' })).status, 400, 'month 13');
|
||||
assert.equal((await post({ date: '2026-02-30' })).status, 400, 'impossible day');
|
||||
assert.equal((await post({ date: '2026-8-2' })).status, 400, 'unpadded');
|
||||
assert.equal((await post({ date: 20260802 })).status, 400, 'non-string');
|
||||
|
||||
// description
|
||||
assert.equal((await anna.agent.post(base).send({ date: ok.date, amount: 1 })).status, 400, 'description required');
|
||||
assert.equal((await post({ description: '' })).status, 400);
|
||||
assert.equal((await post({ description: ' ' })).status, 400, 'whitespace-only');
|
||||
assert.equal((await post({ description: 'x'.repeat(121) })).status, 400, '>120 chars');
|
||||
assert.equal((await post({ description: 'x'.repeat(120) })).status, 201, '120 chars is the limit, inclusive');
|
||||
|
||||
// amount
|
||||
assert.equal((await anna.agent.post(base).send({ date: ok.date, description: 'x' })).status, 400, 'amount required');
|
||||
assert.equal((await post({ amount: -1 })).status, 400, 'negative');
|
||||
assert.equal((await post({ amount: '100' })).status, 400, 'string is not a number');
|
||||
assert.equal((await post({ amount: null })).status, 400);
|
||||
assert.equal((await post({ amount: {} })).status, 400);
|
||||
|
||||
// category
|
||||
assert.equal((await post({ category: 'gadgets' })).status, 400, 'unknown category');
|
||||
assert.equal((await post({ category: 'Food' })).status, 400, 'enum is case-sensitive');
|
||||
assert.equal((await post({ category: 7 })).status, 400);
|
||||
});
|
||||
|
||||
test('validation: paid_by, split_mode and participants -> 400', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const outsider = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const base = `/api/trips/${trip.id}/expenses`;
|
||||
const post = (body) => anna.agent.post(base).send({ date: '2026-08-02', description: 'Lunch', amount: 100, ...body });
|
||||
|
||||
assert.equal((await post({ paid_by: outsider.user.id })).status, 400, 'paid_by must be a trip member');
|
||||
assert.equal((await post({ paid_by: 999999 })).status, 400, 'paid_by must exist');
|
||||
assert.equal((await post({ paid_by: 'anna' })).status, 400);
|
||||
|
||||
assert.equal((await post({ split_mode: 'payer' })).status, 400, "'payer' requires paid_by");
|
||||
assert.equal((await post({ split_mode: 'payer', paid_by: ben.user.id })).status, 201);
|
||||
assert.equal((await post({ split_mode: 'sideways' })).status, 400, 'unknown split_mode');
|
||||
|
||||
assert.equal((await post({ participants: [anna.user.id, outsider.user.id] })).status, 400, 'non-member participant');
|
||||
assert.equal((await post({ participants: [999999] })).status, 400);
|
||||
assert.equal((await post({ participants: 5 })).status, 400, 'not an array');
|
||||
assert.equal((await post({ participants: ['1'] })).status, 400, 'not member ids');
|
||||
});
|
||||
|
||||
test('validation: PATCH applies the same rules and merges for the payer/paid_by rule', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const outsider = await createAccount();
|
||||
const trip = await makeTrip(anna, [ben]);
|
||||
const e = await addExpense(anna, trip, {});
|
||||
const url = `/api/expenses/${e.id}`;
|
||||
const patch = (body) => anna.agent.patch(url).send(body);
|
||||
|
||||
assert.equal((await patch({ date: '2026-13-01' })).status, 400);
|
||||
assert.equal((await patch({ description: ' ' })).status, 400);
|
||||
assert.equal((await patch({ description: 'x'.repeat(121) })).status, 400);
|
||||
assert.equal((await patch({ amount: -0.01 })).status, 400);
|
||||
assert.equal((await patch({ amount: 'free' })).status, 400);
|
||||
assert.equal((await patch({ category: 'gadgets' })).status, 400);
|
||||
assert.equal((await patch({ paid_by: outsider.user.id })).status, 400);
|
||||
assert.equal((await patch({ participants: [outsider.user.id] })).status, 400);
|
||||
|
||||
// 'payer' is validated against the EFFECTIVE (merged) values, like entries.
|
||||
assert.equal((await patch({ split_mode: 'payer' })).status, 400, 'no existing paid_by to inherit');
|
||||
assert.equal((await patch({ split_mode: 'payer', paid_by: ben.user.id })).status, 200);
|
||||
assert.equal((await patch({ paid_by: null })).status, 400, 'clearing paid_by would strand a payer split');
|
||||
assert.equal((await patch({ split_mode: 'equal', paid_by: null })).status, 200);
|
||||
|
||||
// An empty PATCH body is harmless.
|
||||
assert.equal((await patch({})).status, 200);
|
||||
});
|
||||
|
||||
test('validation: a rejected request leaves the stored row untouched', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
const e = await addExpense(anna, trip, { description: 'Lunch', amount: 100 });
|
||||
|
||||
assert.equal((await anna.agent.patch(`/api/expenses/${e.id}`).send({
|
||||
description: 'Changed', amount: -5,
|
||||
})).status, 400);
|
||||
|
||||
const stored = (await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses[0];
|
||||
assert.equal(stored.description, 'Lunch');
|
||||
assert.equal(stored.amount, 100);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Access control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('access control: non-member gets 404 (never 403) everywhere', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
const e = await addExpense(anna, trip, {});
|
||||
const outsider = await createAccount();
|
||||
|
||||
const list = await outsider.agent.get(`/api/trips/${trip.id}/expenses`);
|
||||
assert.equal(list.status, 404);
|
||||
assert.deepEqual(list.body, { error: 'not found' }, 'no existence leak');
|
||||
|
||||
assert.equal((await outsider.agent.post(`/api/trips/${trip.id}/expenses`).send({
|
||||
date: '2026-08-02', description: 'Sneaky', amount: 1,
|
||||
})).status, 404);
|
||||
assert.equal((await outsider.agent.get(`/api/trips/${trip.id}/expenses/export.csv`)).status, 404);
|
||||
assert.equal((await outsider.agent.patch(`/api/expenses/${e.id}`).send({ amount: 1 })).status, 404);
|
||||
assert.equal((await outsider.agent.delete(`/api/expenses/${e.id}`)).status, 404);
|
||||
|
||||
// The expense really does still exist — the 404 was about access, not absence.
|
||||
assert.equal((await anna.agent.get(`/api/trips/${trip.id}/expenses`)).body.expenses.length, 1);
|
||||
});
|
||||
|
||||
test('access control: unknown / malformed ids -> 404', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
|
||||
assert.equal((await anna.agent.patch('/api/expenses/999999').send({ amount: 1 })).status, 404);
|
||||
assert.equal((await anna.agent.delete('/api/expenses/999999')).status, 404);
|
||||
assert.equal((await anna.agent.patch('/api/expenses/not-a-number').send({ amount: 1 })).status, 404);
|
||||
assert.equal((await anna.agent.get(`/api/trips/999999/expenses`)).status, 404);
|
||||
assert.equal((await anna.agent.get(`/api/trips/${trip.id}x/expenses`)).status, 404);
|
||||
});
|
||||
|
||||
test('access control: unauthenticated -> 401 on every expense route', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
const e = await addExpense(anna, trip, {});
|
||||
|
||||
for (const res of [
|
||||
await request(app).get(`/api/trips/${trip.id}/expenses`),
|
||||
await request(app).post(`/api/trips/${trip.id}/expenses`).send({ date: '2026-08-02', description: 'x', amount: 1 }),
|
||||
await request(app).get(`/api/trips/${trip.id}/expenses/export.csv`),
|
||||
await request(app).patch(`/api/expenses/${e.id}`).send({ amount: 1 }),
|
||||
await request(app).delete(`/api/expenses/${e.id}`),
|
||||
]) {
|
||||
assert.equal(res.status, 401);
|
||||
assert.deepEqual(res.body, { error: 'unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trip lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('deleting a trip removes its expenses', async () => {
|
||||
const anna = await createAccount();
|
||||
const trip = await makeTrip(anna);
|
||||
const e = await addExpense(anna, trip, {});
|
||||
|
||||
assert.equal((await anna.agent.delete(`/api/trips/${trip.id}`)).status, 204);
|
||||
const left = app.locals.db.prepare('SELECT COUNT(*) AS c FROM expenses WHERE id = ?').get(e.id).c;
|
||||
assert.equal(left, 0);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// Shared CSV reader for the expense-export tests (tests/expenses-export.test.js
|
||||
// and tests/expense-csv.test.js). Not a test file — it registers no tests and
|
||||
// is therefore not listed in tests/index.js.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// U+FEFF. buildExpenseCsv returns the complete file including the BOM, so both
|
||||
// the pure function's output and the HTTP body must carry it.
|
||||
export const BOM = '';
|
||||
|
||||
// A strict RFC 4180 reader. It doubles as an assertion: it rejects a bare LF or
|
||||
// CR outside a quoted field, so a file with LF endings fails here.
|
||||
export function parseCsv(text) {
|
||||
assert.ok(text.startsWith(BOM), 'CSV must be prefixed with the UTF-8 BOM');
|
||||
const body = text.slice(1);
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
|
||||
for (let i = 0; i < body.length; i += 1) {
|
||||
const ch = body[i];
|
||||
if (quoted) {
|
||||
if (ch !== '"') { field += ch; continue; }
|
||||
if (body[i + 1] === '"') { field += '"'; i += 1; continue; }
|
||||
quoted = false;
|
||||
} else if (ch === '"') {
|
||||
quoted = true;
|
||||
} else if (ch === ',') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (ch === '\r' && body[i + 1] === '\n') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
rows.push(row);
|
||||
row = [];
|
||||
i += 1;
|
||||
} else if (ch === '\n' || ch === '\r') {
|
||||
assert.fail('bare CR/LF outside a quoted field — line endings must be CRLF');
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
}
|
||||
if (field !== '' || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
assert.equal(quoted, false, 'unterminated quoted field');
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Turn the parsed grid into header + row objects keyed by column name.
|
||||
export function readCsv(text) {
|
||||
const grid = parseCsv(text);
|
||||
const header = grid[0];
|
||||
const rows = grid.slice(1).map((cells) => {
|
||||
assert.equal(cells.length, header.length, `row has ${cells.length} cells, header has ${header.length}`);
|
||||
return Object.fromEntries(cells.map((c, i) => [header[i], c]));
|
||||
});
|
||||
return { header, rows };
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import './api.test.js';
|
||||
import './checklist.test.js';
|
||||
import './checklist-suggestions.test.js';
|
||||
import './costs.test.js';
|
||||
import './expense-csv.test.js';
|
||||
import './expenses.test.js';
|
||||
import './expenses-export.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
import './stays.test.js';
|
||||
|
||||
Reference in New Issue
Block a user