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:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
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-test-'));
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 });
});
// Create an account (Mullvad-style) and return an agent with the session set,
// plus the user object {id, display_name} and the raw token.
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 };
}
// ---------------------------------------------------------------------------
// Auth (account tokens)
// ---------------------------------------------------------------------------
test('account creation returns a grouped token and a generated display name', async () => {
const { user, token } = await createAccount();
assert.deepEqual(Object.keys(user).sort(), ['display_name', 'id']);
assert.match(token, /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}(-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}){3}$/);
assert.match(user.display_name, /^[a-z]+-[a-z]+$/);
});
test('account / me / logout lifecycle', async () => {
const { agent, user } = await createAccount();
const me = await agent.get('/api/auth/me');
assert.equal(me.status, 200);
assert.deepEqual(me.body.user, user);
const out = await agent.post('/api/auth/logout');
assert.equal(out.status, 204);
const meAfter = await agent.get('/api/auth/me');
assert.equal(meAfter.status, 401);
assert.deepEqual(meAfter.body, { error: 'unauthorized' });
});
test('me requires auth', async () => {
const res = await request(app).get('/api/auth/me');
assert.equal(res.status, 401);
assert.equal(res.body.error, 'unauthorized');
});
test('login with token (normalized) succeeds; bad token 401', async () => {
const { user, token } = await createAccount();
// Fresh agent logs in with the token, dashes/lowercase/spaces tolerated.
const agent = request.agent(app);
const messy = ` ${token.toLowerCase().replace(/-/g, '')} `;
const ok = await agent.post('/api/auth/login').send({ token: messy });
assert.equal(ok.status, 200);
assert.deepEqual(ok.body.user, user);
const me = await agent.get('/api/auth/me');
assert.equal(me.body.user.id, user.id);
const bad = await request(app).post('/api/auth/login').send({ token: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ' });
assert.equal(bad.status, 401);
const empty = await request(app).post('/api/auth/login').send({});
assert.equal(empty.status, 401);
});
test('patch display_name validates 1-40 chars after trim', async () => {
const { agent, user } = await createAccount();
const ok = await agent.patch('/api/auth/me').send({ display_name: ' Anna the Explorer ' });
assert.equal(ok.status, 200);
assert.equal(ok.body.user.display_name, 'Anna the Explorer');
assert.equal(ok.body.user.id, user.id);
const empty = await agent.patch('/api/auth/me').send({ display_name: ' ' });
assert.equal(empty.status, 400);
const tooLong = await agent.patch('/api/auth/me').send({ display_name: 'x'.repeat(41) });
assert.equal(tooLong.status, 400);
const noAuth = await request(app).patch('/api/auth/me').send({ display_name: 'nope' });
assert.equal(noAuth.status, 401);
});
// ---------------------------------------------------------------------------
// Trips: CRUD + validation
// ---------------------------------------------------------------------------
test('create trip and list it', async () => {
const { agent, user } = await createAccount();
const create = await agent
.post('/api/trips')
.send({ name: 'Thailand', start_date: '2026-08-01', end_date: '2026-08-10' });
assert.equal(create.status, 201);
assert.equal(create.body.trip.name, 'Thailand');
assert.equal(create.body.trip.owner_id, user.id);
// join_code is present on the created trip, in grouped display form.
assert.match(create.body.trip.join_code, /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$/);
const list = await agent.get('/api/trips');
assert.equal(list.status, 200);
assert.equal(list.body.trips.length, 1);
assert.equal(list.body.trips[0].role, 'owner');
assert.equal(list.body.trips[0].member_count, 1);
assert.equal(list.body.trips[0].entry_count, 0);
});
test('trip validation: bad dates and oversized range', async () => {
const { agent } = await createAccount();
const emptyName = await agent.post('/api/trips').send({ name: '', start_date: '2026-08-01', end_date: '2026-08-02' });
assert.equal(emptyName.status, 400);
const badDate = await agent.post('/api/trips').send({ name: 'X', start_date: '2026-13-40', end_date: '2026-08-02' });
assert.equal(badDate.status, 400);
const reversed = await agent.post('/api/trips').send({ name: 'X', start_date: '2026-08-10', end_date: '2026-08-01' });
assert.equal(reversed.status, 400);
const tooLong = await agent.post('/api/trips').send({ name: 'X', start_date: '2026-01-01', end_date: '2027-06-01' });
assert.equal(tooLong.status, 400);
});
test('get / patch / delete trip', async () => {
const { agent } = await createAccount();
const created = (await agent.post('/api/trips').send({ name: 'Trip', start_date: '2026-08-01', end_date: '2026-08-05' })).body.trip;
const got = await agent.get(`/api/trips/${created.id}`);
assert.equal(got.status, 200);
assert.equal(got.body.trip.name, 'Trip');
assert.ok(got.body.trip.join_code);
assert.equal(got.body.members.length, 1);
assert.deepEqual(Object.keys(got.body.members[0]).sort(), ['display_name', 'id', 'role']);
assert.deepEqual(got.body.entries, []);
const patched = await agent.patch(`/api/trips/${created.id}`).send({ name: 'Renamed', end_date: '2026-08-08' });
assert.equal(patched.status, 200);
assert.equal(patched.body.trip.name, 'Renamed');
assert.equal(patched.body.trip.end_date, '2026-08-08');
const del = await agent.delete(`/api/trips/${created.id}`);
assert.equal(del.status, 204);
const gone = await agent.get(`/api/trips/${created.id}`);
assert.equal(gone.status, 404);
});
test('unknown trip returns 404', async () => {
const { agent } = await createAccount();
const res = await agent.get('/api/trips/9999');
assert.equal(res.status, 404);
assert.equal(res.body.error, 'not found');
});
// ---------------------------------------------------------------------------
// Membership via join code
// ---------------------------------------------------------------------------
test('join by code: non-member 404, join as editor, idempotent, unknown 404', async () => {
const owner = await createAccount();
const guest = await createAccount();
const trip = (await owner.agent.post('/api/trips').send({ name: 'Shared', start_date: '2026-08-01', end_date: '2026-08-03' })).body.trip;
const code = trip.join_code;
// Non-member cannot see the trip.
assert.equal((await guest.agent.get(`/api/trips/${trip.id}`)).status, 404);
// Unknown code -> 404.
assert.equal((await guest.agent.post('/api/trips/join').send({ code: 'ZZZZ-ZZZZ' })).status, 404);
// Join with the code (lowercase/spacing tolerated) -> 200, joined as editor.
const joined = await guest.agent.post('/api/trips/join').send({ code: ` ${code.toLowerCase()} ` });
assert.equal(joined.status, 200);
assert.equal(joined.body.trip.id, trip.id);
const view = await guest.agent.get(`/api/trips/${trip.id}`);
assert.equal(view.status, 200);
const guestMember = view.body.members.find((m) => m.id === guest.user.id);
assert.equal(guestMember.role, 'editor');
// Idempotent: joining again still 200, membership count unchanged.
const again = await guest.agent.post('/api/trips/join').send({ code });
assert.equal(again.status, 200);
const memberCount = (await owner.agent.get('/api/trips')).body.trips[0].member_count;
assert.equal(memberCount, 2);
});
test('join-code regeneration is owner-only and invalidates the old code', async () => {
const owner = await createAccount();
const guest = await createAccount();
const trip = (await owner.agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-03' })).body.trip;
const oldCode = trip.join_code;
// Non-member cannot regenerate (they can't even see the trip) -> 404.
assert.equal((await guest.agent.post(`/api/trips/${trip.id}/join-code`)).status, 404);
const regen = await owner.agent.post(`/api/trips/${trip.id}/join-code`);
assert.equal(regen.status, 200);
assert.notEqual(regen.body.trip.join_code, oldCode);
// Old code no longer works; new one does.
assert.equal((await guest.agent.post('/api/trips/join').send({ code: oldCode })).status, 404);
assert.equal((await guest.agent.post('/api/trips/join').send({ code: regen.body.trip.join_code })).status, 200);
// A member who is not the owner cannot regenerate -> 403.
const nonOwnerRegen = await guest.agent.post(`/api/trips/${trip.id}/join-code`);
assert.equal(nonOwnerRegen.status, 403);
});
test('owner-only member removal; owner cannot remove self', async () => {
const owner = await createAccount();
const guest = await createAccount();
const trip = (await owner.agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-03' })).body.trip;
await guest.agent.post('/api/trips/join').send({ code: trip.join_code });
// Non-owner cannot delete the trip.
assert.equal((await guest.agent.delete(`/api/trips/${trip.id}`)).status, 403);
// Owner removes the guest.
assert.equal((await owner.agent.delete(`/api/trips/${trip.id}/members/${guest.user.id}`)).status, 204);
assert.equal((await guest.agent.get(`/api/trips/${trip.id}`)).status, 404);
// Owner cannot remove self.
assert.equal((await owner.agent.delete(`/api/trips/${trip.id}/members/${owner.user.id}`)).status, 400);
});
// ---------------------------------------------------------------------------
// Entries: CRUD + validation
// ---------------------------------------------------------------------------
async function makeTrip(agent) {
return (await agent.post('/api/trips').send({ name: 'E', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
}
test('entry CRUD and full-row shape', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const create = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01',
type: 'flight',
title: 'BKK -> CNX',
start_time: '09:30',
location_name: 'Chiang Mai',
lat: 18.79,
lng: 98.98,
});
assert.equal(create.status, 201);
const entry = create.body.entry;
assert.deepEqual(
Object.keys(entry).sort(),
['date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'trip_id', 'type'].sort()
);
assert.equal(entry.details, '');
assert.equal(entry.lat, 18.79);
assert.equal(entry.price, null);
assert.equal(entry.paid_by, null);
assert.equal(entry.split_mode, 'equal');
assert.deepEqual(entry.participants, []);
assert.equal(entry.segments, null);
assert.equal(entry.rental, null);
const patch = await agent.patch(`/api/entries/${entry.id}`).send({ title: 'BKK to CNX', details: 'window seat' });
assert.equal(patch.status, 200);
assert.equal(patch.body.entry.title, 'BKK to CNX');
assert.equal(patch.body.entry.details, 'window seat');
const list = await agent.get(`/api/trips/${trip.id}/entries`);
assert.equal(list.body.entries.length, 1);
const del = await agent.delete(`/api/entries/${entry.id}`);
assert.equal(del.status, 204);
const listAfter = await agent.get(`/api/trips/${trip.id}/entries`);
assert.equal(listAfter.body.entries.length, 0);
});
test('entry validation: type, title, lat/lng pairing and ranges', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const badType = await agent.post(base).send({ date: '2026-08-01', type: 'teleport', title: 'x' });
assert.equal(badType.status, 400);
const badDate = await agent.post(base).send({ date: '08/01/2026', type: 'note', title: 'x' });
assert.equal(badDate.status, 400);
const noTitle = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: '' });
assert.equal(noTitle.status, 400);
const latOnly = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'x', lat: 10 });
assert.equal(latOnly.status, 400);
const badLat = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'x', lat: 99, lng: 10 });
assert.equal(badLat.status, 400);
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'x', lat: 10, lng: 20 });
assert.equal(ok.status, 201);
});
test('non-member cannot add or view entries', async () => {
const owner = await createAccount();
const guest = await createAccount();
const trip = await makeTrip(owner.agent);
const post = await guest.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'note', title: 'x' });
assert.equal(post.status, 404);
const list = await guest.agent.get(`/api/trips/${trip.id}/entries`);
assert.equal(list.status, 404);
});
// ---------------------------------------------------------------------------
// Route + summary math
// ---------------------------------------------------------------------------
test('route computes legs, totalKm and summary counts', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent); // 2026-08-01 .. 2026-08-10 => 10 days
// Bangkok
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'flight', title: 'Depart', sort_order: 0,
location_name: 'Bangkok', lat: 13.7563, lng: 100.5018,
});
// Chiang Mai
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'Hotel CNX', sort_order: 1,
location_name: 'Chiang Mai', lat: 18.7883, lng: 98.9853,
});
// A second flight + hotel + activities + a travel leg (no coords) to exercise counts
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'flight', title: 'F2' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'hotel', title: 'H2' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'travel', title: 'Drive' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-03', type: 'activity', title: 'A1' });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-03', type: 'activity', title: 'A2' });
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.status, 200);
// Two located stops -> one leg. BKK<->CNX great-circle ~ 585 km.
assert.equal(res.body.stops.length, 2);
assert.equal(res.body.legs.length, 1);
assert.ok(res.body.legs[0].km > 570 && res.body.legs[0].km < 600, `unexpected km ${res.body.legs[0].km}`);
assert.equal(res.body.totalKm, res.body.legs[0].km);
const s = res.body.summary;
assert.equal(s.days, 10);
assert.equal(s.nights, 9);
assert.equal(s.flights, 2);
assert.equal(s.hotels, 2);
assert.equal(s.travelLegs, 1);
assert.equal(s.activities, 2);
assert.deepEqual(s.locations, ['Bangkok', 'Chiang Mai']);
});
test('route skips zero-distance legs but keeps the stop', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'activity', title: 'A', sort_order: 0, location_name: 'Same', lat: 10, lng: 10 });
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'activity', title: 'B', sort_order: 1, location_name: 'Same', lat: 10, lng: 10 });
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.body.stops.length, 2);
assert.equal(res.body.legs.length, 0);
assert.equal(res.body.totalKm, 0);
assert.deepEqual(res.body.summary.locations, ['Same']);
});
// ---------------------------------------------------------------------------
// Geocode (mocked upstream — never hits the real Nominatim)
// ---------------------------------------------------------------------------
test('geocode proxies and caches results (mocked fetch)', async () => {
const { agent } = await createAccount();
const original = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => [
{ display_name: 'Chiang Mai, Thailand', lat: '18.7883', lon: '98.9853' },
],
};
};
try {
const first = await agent.get('/api/geocode?q=Chiang%20Mai');
assert.equal(first.status, 200);
assert.equal(first.body.results.length, 1);
assert.deepEqual(first.body.results[0], { name: 'Chiang Mai, Thailand', lat: 18.7883, lng: 98.9853 });
// Second identical query is served from cache -> fetch not called again.
const second = await agent.get('/api/geocode?q=Chiang%20Mai');
assert.equal(second.status, 200);
assert.equal(calls, 1);
} finally {
global.fetch = original;
}
});
test('geocode returns 502 on upstream failure (mocked fetch)', async () => {
const { agent } = await createAccount();
const original = global.fetch;
global.fetch = async () => {
throw new Error('network down');
};
try {
const res = await agent.get('/api/geocode?q=Nowhere');
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'geocoding unavailable' });
} finally {
global.fetch = original;
}
});
test('geocode requires auth and a query', async () => {
const noAuth = await request(app).get('/api/geocode?q=x');
assert.equal(noAuth.status, 401);
const { agent } = await createAccount();
const noQuery = await agent.get('/api/geocode');
assert.equal(noQuery.status, 400);
});
// ---------------------------------------------------------------------------
// Unknown /api route -> JSON 404
// ---------------------------------------------------------------------------
test('unknown api path returns JSON 404', async () => {
const { agent } = await createAccount();
const res = await agent.get('/api/does-not-exist');
assert.equal(res.status, 404);
assert.deepEqual(res.body, { error: 'not found' });
});
+220
View File
@@ -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));
});
+227
View File
@@ -0,0 +1,227 @@
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-flights-'));
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 };
}
async function makeTrip(agent) {
return (await agent.post('/api/trips').send({ name: 'F', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
}
const CNX = { code: 'CNX', name: 'Chiang Mai Intl', lat: 18.77, lng: 98.96 };
const BKK = { code: 'BKK', name: 'Suvarnabhumi', lat: 13.68, lng: 100.75 };
const DXB = { code: 'DXB', name: 'Dubai Intl', lat: 25.25, lng: 55.36 };
// ---------------------------------------------------------------------------
// Segments validation
// ---------------------------------------------------------------------------
test('segments: accepted on flight, parsed back, codes normalized to uppercase', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'flight', title: 'CNX-BKK-DXB',
segments: [
{ flight_no: 'TG103', dep_time: '10:30', arr_time: '11:45', from: { code: 'cnx', ...{ name: CNX.name, lat: CNX.lat, lng: CNX.lng } }, to: BKK },
{ flight_no: 'EK385', from: BKK, to: DXB },
],
});
assert.equal(res.status, 201);
assert.equal(res.body.entry.segments.length, 2);
assert.equal(res.body.entry.segments[0].from.code, 'CNX'); // normalized
assert.equal(res.body.entry.segments[0].flight_no, 'TG103');
assert.equal(res.body.entry.segments[1].to.code, 'DXB');
});
test('segments: rejected on non-flight entries', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'note', title: 'Nope',
segments: [{ from: CNX, to: BKK }],
});
assert.equal(res.status, 400);
});
test('segments: bad code and out-of-range count rejected', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const badCode = await agent.post(base).send({
date: '2026-08-01', type: 'flight', title: 'x',
segments: [{ from: { code: 'TOOLONG' }, to: BKK }],
});
assert.equal(badCode.status, 400);
const empty = await agent.post(base).send({
date: '2026-08-01', type: 'flight', title: 'x', segments: [],
});
assert.equal(empty.status, 400);
const tooMany = await agent.post(base).send({
date: '2026-08-01', type: 'flight', title: 'x',
segments: Array.from({ length: 9 }, () => ({ from: CNX, to: BKK })),
});
assert.equal(tooMany.status, 400);
});
test('segments: PATCH with null clears them', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const entry = (await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'flight', title: 'x', segments: [{ from: CNX, to: BKK }],
})).body.entry;
assert.equal(entry.segments.length, 1);
const cleared = await agent.patch(`/api/entries/${entry.id}`).send({ segments: null });
assert.equal(cleared.status, 200);
assert.equal(cleared.body.entry.segments, null);
});
// ---------------------------------------------------------------------------
// Route expansion
// ---------------------------------------------------------------------------
test('route expands a CNX-BKK-DXB flight into airport stops and legs', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'flight', title: 'CNX-BKK-DXB',
segments: [
{ flight_no: 'TG103', from: CNX, to: BKK },
{ flight_no: 'EK385', from: BKK, to: DXB },
],
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.status, 200);
// from(CNX) + to(BKK) + to(DXB) = 3 stops, consecutive dup (BKK) collapsed.
assert.equal(res.body.stops.length, 3);
assert.deepEqual(res.body.stops.map((s) => s.code), ['CNX', 'BKK', 'DXB']);
assert.ok(res.body.stops.every((s) => s.kind === 'airport'));
assert.equal(res.body.legs.length, 2);
assert.ok(res.body.legs.every((l) => l.km > 0));
// Both legs connect airport stops from the same flight entry -> air.
assert.ok(res.body.legs.every((l) => l.mode === 'air'));
assert.ok(res.body.totalKm > 0);
assert.equal(res.body.summary.flights, 1);
assert.equal(res.body.summary.flightSegments, 2);
assert.equal(res.body.summary.kmAir, res.body.totalKm);
assert.equal(res.body.summary.kmDriven, 0);
});
test('route: mixed ground transfers + air segments split kmAir / kmDriven', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
// Hotel near CNX (ground), then the CNX-BKK-DXB flight, then a hotel near DXB.
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'CNX Hotel', sort_order: 0,
location_name: 'Chiang Mai', lat: 18.79, lng: 98.99,
});
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'flight', title: 'CNX-BKK-DXB', sort_order: 1,
segments: [
{ flight_no: 'TG103', from: CNX, to: BKK },
{ flight_no: 'EK385', from: BKK, to: DXB },
],
});
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-02', type: 'hotel', title: 'DXB Hotel', sort_order: 2,
location_name: 'Dubai', lat: 25.2, lng: 55.27,
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.status, 200);
// Stops: CNX-hotel, CNX, BKK, DXB, DXB-hotel = 5.
assert.equal(res.body.stops.length, 5);
assert.deepEqual(res.body.legs.map((l) => l.mode), ['ground', 'air', 'air', 'ground']);
const s = res.body.summary;
assert.ok(s.kmAir > 0 && s.kmDriven > 0);
// Air = the two inter-airport legs; driven = the two hotel<->airport transfers.
assert.ok(Math.abs(s.kmAir - (res.body.legs[1].km + res.body.legs[2].km)) < 0.11);
assert.ok(Math.abs(s.kmDriven - (res.body.legs[0].km + res.body.legs[3].km)) < 0.11);
assert.ok(Math.abs(s.kmAir + s.kmDriven - res.body.totalKm) < 0.2);
});
test('route: segmentless flight falls back to entry lat/lng', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'flight', title: 'plain', location_name: 'Bangkok', lat: 13.68, lng: 100.75,
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.body.stops.length, 1);
assert.equal(res.body.stops[0].kind, undefined);
assert.equal(res.body.summary.flightSegments, 0);
// No legs -> both per-mode sums are 0.
assert.equal(res.body.legs.length, 0);
assert.equal(res.body.summary.kmAir, 0);
assert.equal(res.body.summary.kmDriven, 0);
});
// ---------------------------------------------------------------------------
// Airports lookup
// ---------------------------------------------------------------------------
test('airports: exact IATA match ranks first; requires auth', async () => {
const noAuth = await request(app).get('/api/airports?q=CNX');
assert.equal(noAuth.status, 401);
const { agent } = await createAccount();
const res = await agent.get('/api/airports?q=CNX');
assert.equal(res.status, 200);
assert.ok(res.body.results.length >= 1);
assert.ok(res.body.results.length <= 8);
assert.equal(res.body.results[0].code, 'CNX');
assert.match(res.body.results[0].name, /Chiang Mai/i);
assert.deepEqual(
Object.keys(res.body.results[0]).sort(),
['city', 'code', 'country', 'lat', 'lng', 'name'].sort()
);
});
test('airports: case-insensitive and empty query returns empty list', async () => {
const { agent } = await createAccount();
const lower = await agent.get('/api/airports?q=cnx');
assert.equal(lower.body.results[0].code, 'CNX');
const empty = await agent.get('/api/airports');
assert.equal(empty.status, 200);
assert.deepEqual(empty.body.results, []);
});
+12
View File
@@ -0,0 +1,12 @@
// Test barrel. The package.json `test` script runs `node --test tests/`, which
// on this Node build resolves the directory to `tests/index.js` and runs it as
// a single test file rather than scanning the directory. Importing each test
// module here registers its tests with the runner. Add new test files below.
//
// These run in one process, so each test file scopes its fixtures with
// top-level beforeEach/afterEach that touch only its own module-level
// app/tmpDir — keeping the files independent.
import './api.test.js';
import './costs.test.js';
import './flights.test.js';
import './rental.test.js';
+153
View File
@@ -0,0 +1,153 @@
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-rental-'));
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 };
}
async function makeTrip(agent) {
return (await agent.post('/api/trips').send({ name: 'R', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
}
const RENTAL = {
brand: 'Toyota', model: 'Yaris Cross', car_type: 'SUV',
booking_ref: 'RC-889231', included_km: 1500,
pickup: { date: '2026-08-01', time: '09:00', location_name: 'CNX Airport', lat: 18.77, lng: 98.96 },
dropoff: { date: '2026-08-07', time: '18:00', location_name: 'Old Town', lat: 18.79, lng: 98.98 },
};
// ---------------------------------------------------------------------------
// Rental validation
// ---------------------------------------------------------------------------
test('rental: accepted on rental entry, parsed back, cleared via null', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'rental', title: 'Car', rental: RENTAL,
});
assert.equal(res.status, 201);
const r = res.body.entry.rental;
assert.equal(r.brand, 'Toyota');
assert.equal(r.included_km, 1500);
assert.equal(r.pickup.location_name, 'CNX Airport');
assert.equal(r.dropoff.date, '2026-08-07');
const cleared = await agent.patch(`/api/entries/${res.body.entry.id}`).send({ rental: null });
assert.equal(cleared.status, 200);
assert.equal(cleared.body.entry.rental, null);
});
test('rental: rejected on non-rental entries', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'hotel', title: 'Nope', rental: RENTAL,
});
assert.equal(res.status, 400);
});
test('rental: bad shapes rejected', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const longBrand = await agent.post(base).send({
date: '2026-08-01', type: 'rental', title: 'x', rental: { brand: 'z'.repeat(61) },
});
assert.equal(longBrand.status, 400);
const negKm = await agent.post(base).send({
date: '2026-08-01', type: 'rental', title: 'x', rental: { included_km: -5 },
});
assert.equal(negKm.status, 400);
const noDate = await agent.post(base).send({
date: '2026-08-01', type: 'rental', title: 'x', rental: { pickup: { time: '09:00' } },
});
assert.equal(noDate.status, 400);
const halfCoord = await agent.post(base).send({
date: '2026-08-01', type: 'rental', title: 'x', rental: { pickup: { date: '2026-08-01', lat: 18.77 } },
});
assert.equal(halfCoord.status, 400);
});
test('rental: minimal object with only included_km null is accepted', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'rental', title: 'Car', rental: { brand: 'Kia', included_km: null },
});
assert.equal(res.status, 201);
assert.equal(res.body.entry.rental.included_km, null);
});
// ---------------------------------------------------------------------------
// Summary + costs
// ---------------------------------------------------------------------------
test('route summary: rentals count and includedKm sum', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'rental', title: 'Car A', rental: { included_km: 1500 },
});
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-05', type: 'rental', title: 'Car B', rental: { included_km: 300 },
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.status, 200);
assert.equal(res.body.summary.rentals, 2);
assert.equal(res.body.summary.includedKm, 1800);
});
test('route summary: includedKm is null when no rental specifies one', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'rental', title: 'Car', rental: { brand: 'Kia' },
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.body.summary.rentals, 1);
assert.equal(res.body.summary.includedKm, null);
});
test('costs byType includes rental', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
await agent.post(`/api/trips/${trip.id}/entries`).send({
date: '2026-08-01', type: 'rental', title: 'Car', price: 240, rental: { brand: 'Kia' },
});
const c = (await agent.get(`/api/trips/${trip.id}/costs`)).body;
assert.equal(c.totalCost, 240);
assert.equal(c.byType.rental, 240);
});