Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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 { computeCosts } from '../src/server/util/costs.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-costs-'));
|
||||
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, token: res.body.token };
|
||||
}
|
||||
|
||||
// Build a trip owned by `owner`; each member agent joins via the join code.
|
||||
async function costTrip(owner, memberAccounts = []) {
|
||||
const trip = (await owner.agent.post('/api/trips').send({
|
||||
name: 'Costs', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'USD',
|
||||
})).body.trip;
|
||||
for (const m of memberAccounts) {
|
||||
const res = await m.agent.post('/api/trips/join').send({ code: trip.join_code });
|
||||
assert.equal(res.status, 200);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
function findUser(costs, userId) {
|
||||
return costs.perUser.find((u) => u.userId === userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Currency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('currency: defaults to USD, validates format, patchable', async () => {
|
||||
const { agent } = await createAccount();
|
||||
|
||||
const def = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05' });
|
||||
assert.equal(def.status, 201);
|
||||
assert.equal(def.body.trip.currency, 'USD');
|
||||
|
||||
const bad = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'us' });
|
||||
assert.equal(bad.status, 400);
|
||||
|
||||
const eur = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'EUR' });
|
||||
assert.equal(eur.status, 201);
|
||||
assert.equal(eur.body.trip.currency, 'EUR');
|
||||
|
||||
const patched = await agent.patch(`/api/trips/${eur.body.trip.id}`).send({ currency: 'THB' });
|
||||
assert.equal(patched.status, 200);
|
||||
assert.equal(patched.body.trip.currency, 'THB');
|
||||
|
||||
const listed = await agent.get('/api/trips');
|
||||
assert.ok(listed.body.trips.every((t) => typeof t.currency === 'string'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Costs & splitting (endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('costs: equal split with payer produces net balances and a settlement', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
// anna pays 950 (hotel), ben pays 500 (travel); both split equally between the two.
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Hotel', price: 950, paid_by: anna.user.id,
|
||||
});
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-02', type: 'travel', title: 'Van', price: 500, paid_by: ben.user.id,
|
||||
});
|
||||
|
||||
const res = await anna.agent.get(`/api/trips/${trip.id}/costs`);
|
||||
assert.equal(res.status, 200);
|
||||
const c = res.body;
|
||||
assert.equal(c.currency, 'USD');
|
||||
assert.equal(c.totalCost, 1450);
|
||||
assert.deepEqual(c.byType, { hotel: 950, travel: 500 });
|
||||
assert.equal(c.unassigned, 0);
|
||||
|
||||
const a = findUser(c, anna.user.id);
|
||||
const b = findUser(c, ben.user.id);
|
||||
assert.equal(typeof a.displayName, 'string');
|
||||
assert.deepEqual([a.share, a.paid, a.net], [725, 950, 225]);
|
||||
assert.deepEqual([b.share, b.paid, b.net], [725, 500, -225]);
|
||||
|
||||
assert.equal(c.settlements.length, 1);
|
||||
assert.deepEqual(c.settlements[0], { fromUserId: ben.user.id, toUserId: anna.user.id, amount: 225 });
|
||||
});
|
||||
|
||||
test('costs: own mode creates no debt and totals price x participants', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'Flights', price: 300, split_mode: 'own',
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 600); // 300 per person x 2
|
||||
assert.deepEqual(c.byType, { flight: 600 });
|
||||
for (const u of c.perUser) {
|
||||
assert.deepEqual([u.share, u.paid, u.net], [300, 300, 0]);
|
||||
}
|
||||
assert.deepEqual(c.settlements, []);
|
||||
assert.equal(c.unassigned, 0);
|
||||
});
|
||||
|
||||
test('costs: payer mode is a personal expense (requires paid_by)', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
// payer without paid_by -> validation error
|
||||
const bad = await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'activity', title: 'Solo tour', price: 100, split_mode: 'payer',
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'activity', title: 'Solo tour', price: 100, split_mode: 'payer', paid_by: ben.user.id,
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 100);
|
||||
const a = findUser(c, anna.user.id);
|
||||
const b = findUser(c, ben.user.id);
|
||||
assert.deepEqual([a.share, a.paid, a.net], [0, 0, 0]);
|
||||
assert.deepEqual([b.share, b.paid, b.net], [100, 100, 0]);
|
||||
assert.deepEqual(c.settlements, []);
|
||||
});
|
||||
|
||||
test('costs: participants subset only splits among the chosen members', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const carol = await createAccount();
|
||||
const trip = await costTrip(anna, [ben, carol]);
|
||||
|
||||
// 90 split equally between anna & ben only (carol excluded), anna pays.
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'travel', title: 'Taxi', price: 90, paid_by: anna.user.id,
|
||||
participants: [anna.user.id, ben.user.id],
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 90);
|
||||
const a = findUser(c, anna.user.id);
|
||||
assert.deepEqual([a.share, a.paid, a.net], [45, 90, 45]);
|
||||
assert.deepEqual([findUser(c, ben.user.id).share, findUser(c, ben.user.id).net], [45, -45]);
|
||||
assert.deepEqual([findUser(c, carol.user.id).share, findUser(c, carol.user.id).net], [0, 0]);
|
||||
assert.equal(c.settlements.length, 1);
|
||||
assert.deepEqual(c.settlements[0], { fromUserId: ben.user.id, toUserId: anna.user.id, amount: 45 });
|
||||
});
|
||||
|
||||
test('costs: equal with no payer accumulates into unassigned', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Hotel', price: 200, // paid_by omitted (null)
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 200);
|
||||
assert.equal(c.unassigned, 200);
|
||||
assert.deepEqual(c.byType, { hotel: 200 });
|
||||
for (const u of c.perUser) {
|
||||
assert.deepEqual([u.share, u.paid, u.net], [100, 0, -100]);
|
||||
}
|
||||
// Nobody paid, so there is no creditor to settle toward.
|
||||
assert.deepEqual(c.settlements, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Costs computation (pure unit test — greedy minimal-transfer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('computeCosts: greedy settlement matches largest debtor with largest creditor', () => {
|
||||
const members = [
|
||||
{ id: 1, display_name: 'brave-otter' },
|
||||
{ id: 2, display_name: 'calm-heron' },
|
||||
{ id: 3, display_name: 'wise-lynx' },
|
||||
];
|
||||
// id 1 pays 300 for a 3-way equal split (100 each); id 1 is owed 200 total.
|
||||
const c = computeCosts({
|
||||
currency: 'USD',
|
||||
members,
|
||||
entries: [{ type: 'hotel', price: 300, paid_by: 1, split_mode: 'equal', participants: [] }],
|
||||
});
|
||||
assert.equal(c.totalCost, 300);
|
||||
assert.equal(findUser(c, 1).displayName, 'brave-otter');
|
||||
assert.equal(findUser(c, 1).net, 200);
|
||||
assert.equal(findUser(c, 2).net, -100);
|
||||
assert.equal(findUser(c, 3).net, -100);
|
||||
// Two transfers of 100 into the single creditor (id 1).
|
||||
assert.equal(c.settlements.length, 2);
|
||||
assert.ok(c.settlements.every((s) => s.toUserId === 1 && s.amount === 100));
|
||||
});
|
||||
Reference in New Issue
Block a user