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); });