Entries gain an optional inclusive end_date (backfilled via migration): flights and travel longer than 24h show a continuation marker on following days, and a new stay entry type marks a time frame in one area (e.g. 3 days Venice) rendered as continuous bands across the calendar weeks. Stays feed the map route as stops; the route summary gains stays count and a chronological areas list with day spans. 49 API tests.
460 lines
18 KiB
JavaScript
460 lines
18 KiB
JavaScript
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', 'end_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.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('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' });
|
|
});
|