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'); }); test('PATCH /api/trips/:id/order reorders the dashboard list for the caller', async () => { const { agent } = await createAccount(); const a = (await agent.post('/api/trips').send({ name: 'A', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; const b = (await agent.post('/api/trips').send({ name: 'B', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; const c = (await agent.post('/api/trips').send({ name: 'C', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; // Default order is newest first: C, B, A. const initial = await agent.get('/api/trips'); assert.deepEqual(initial.body.trips.map((t) => t.id), [c.id, b.id, a.id]); assert.ok(initial.body.trips.every((t) => t.sort_order === 0)); const setA = await agent.patch(`/api/trips/${a.id}/order`).send({ sort_order: 0 }); assert.equal(setA.status, 200); assert.deepEqual(setA.body, { sort_order: 0 }); await agent.patch(`/api/trips/${b.id}/order`).send({ sort_order: 2 }); await agent.patch(`/api/trips/${c.id}/order`).send({ sort_order: 1 }); const reordered = await agent.get('/api/trips'); assert.deepEqual(reordered.body.trips.map((t) => t.id), [a.id, c.id, b.id]); }); test('trip order is per-user', async () => { const owner = await createAccount(); const guest = await createAccount(); const t1 = (await owner.agent.post('/api/trips').send({ name: 'T1', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; const t2 = (await owner.agent.post('/api/trips').send({ name: 'T2', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; await guest.agent.post('/api/trips/join').send({ code: t1.join_code }); await guest.agent.post('/api/trips/join').send({ code: t2.join_code }); // Owner reorders their own list; guest's list is unaffected. const guestBefore = (await guest.agent.get('/api/trips')).body.trips.map((t) => t.id); await owner.agent.patch(`/api/trips/${t1.id}/order`).send({ sort_order: 5 }); const guestAfter = (await guest.agent.get('/api/trips')).body.trips.map((t) => t.id); assert.deepEqual(guestAfter, guestBefore); const ownerList = (await owner.agent.get('/api/trips')).body.trips; assert.equal(ownerList.find((t) => t.id === t1.id).sort_order, 5); }); test('order endpoint validation: non-integer 400, non-member 404', async () => { const owner = await createAccount(); const outsider = await createAccount(); const trip = (await owner.agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-02' })).body.trip; const badBody = await owner.agent.patch(`/api/trips/${trip.id}/order`).send({ sort_order: 'first' }); assert.equal(badBody.status, 400); assert.equal(badBody.body.error, 'sort_order must be an integer'); const missing = await owner.agent.patch(`/api/trips/${trip.id}/order`).send({}); assert.equal(missing.status, 400); const notMember = await outsider.agent.patch(`/api/trips/${trip.id}/order`).send({ sort_order: 1 }); assert.equal(notMember.status, 404); }); // --------------------------------------------------------------------------- // 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', 'end_date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'transport_mode', 'trip_id', 'type'].sort() ); assert.equal(entry.end_date, null); 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('legacy entry types (hotel, travel, immigration) are rejected', async () => { const { agent } = await createAccount(); const trip = await makeTrip(agent); const base = `/api/trips/${trip.id}/entries`; for (const type of ['hotel', 'travel', 'immigration']) { const res = await agent.post(base).send({ date: '2026-08-01', type, title: 'x' }); assert.equal(res.status, 400, `expected ${type} to be rejected`); } }); 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: 'stay', title: 'Hotel CNX', sort_order: 1, location_name: 'Chiang Mai', lat: 18.7883, lng: 98.9853, }); // A second flight + stay + activities + a transport 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: 'stay', title: 'H2' }); await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'transport', 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.transports, 1); assert.equal(s.hotels, undefined); assert.equal(s.travelLegs, undefined); 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' }); });