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- 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, []); });