- Transport entries carry an optional ordered waypoints array of lat/lng/name points; /route attaches them to the ground leg the transport bridges, and /api/directions accepts a via param so the drawn road route detours through them - Day editor gains a geocoded "Scenic waypoints" list on transport entries; the map draws leg-coloured waypoint dots - Escape waypoint names in the Leaflet tooltip (stored-XSS fix flagged by security review: names are user-typed and Leaflet renders string tooltips as HTML)
692 lines
27 KiB
JavaScript
692 lines
27 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');
|
|
});
|
|
|
|
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(),
|
|
['auto_ref', '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', 'waypoints'].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);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Directions (mocked upstream — never hits the real OSRM)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('directions 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 () => ({
|
|
code: 'Ok',
|
|
routes: [
|
|
{
|
|
distance: 12345.6,
|
|
geometry: {
|
|
coordinates: [
|
|
[98.9853, 18.7883],
|
|
[99.0, 18.8],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
};
|
|
try {
|
|
const first = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
|
assert.equal(first.status, 200);
|
|
assert.equal(first.body.km, 12.3);
|
|
assert.deepEqual(first.body.geometry, [
|
|
[18.7883, 98.9853],
|
|
[18.8, 99.0],
|
|
]);
|
|
|
|
// Second identical request is served from cache -> fetch not called again.
|
|
const second = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
|
assert.equal(second.status, 200);
|
|
assert.equal(calls, 1);
|
|
} finally {
|
|
global.fetch = original;
|
|
}
|
|
});
|
|
|
|
test('directions validates from/to params', async () => {
|
|
const { agent } = await createAccount();
|
|
|
|
const missing = await agent.get('/api/directions?from=18.7883,98.9853');
|
|
assert.equal(missing.status, 400);
|
|
assert.deepEqual(missing.body, { error: 'from and to must be lat,lng' });
|
|
|
|
const malformed = await agent.get('/api/directions?from=abc&to=18.8,99.0');
|
|
assert.equal(malformed.status, 400);
|
|
|
|
const outOfRange = await agent.get('/api/directions?from=999,98.9853&to=18.8,99.0');
|
|
assert.equal(outOfRange.status, 400);
|
|
});
|
|
|
|
test('directions returns 502 on non-Ok OSRM code and on fetch failure', async () => {
|
|
const { agent } = await createAccount();
|
|
const original = global.fetch;
|
|
|
|
global.fetch = async () => ({
|
|
ok: true,
|
|
json: async () => ({ code: 'NoRoute', routes: [] }),
|
|
});
|
|
try {
|
|
const noRoute = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
|
assert.equal(noRoute.status, 502);
|
|
assert.deepEqual(noRoute.body, { error: 'directions unavailable' });
|
|
} finally {
|
|
global.fetch = original;
|
|
}
|
|
|
|
global.fetch = async () => {
|
|
throw new Error('network down');
|
|
};
|
|
try {
|
|
const res = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
|
assert.equal(res.status, 502);
|
|
assert.deepEqual(res.body, { error: 'directions unavailable' });
|
|
} finally {
|
|
global.fetch = original;
|
|
}
|
|
});
|
|
|
|
test('directions requires auth', async () => {
|
|
const noAuth = await request(app).get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
|
assert.equal(noAuth.status, 401);
|
|
});
|
|
|
|
test('directions: via param routes through the extra points, in order (mocked fetch)', async () => {
|
|
const { agent } = await createAccount();
|
|
const original = global.fetch;
|
|
let requestedUrl;
|
|
global.fetch = async (url) => {
|
|
requestedUrl = url;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
code: 'Ok',
|
|
routes: [{ distance: 1000, geometry: { coordinates: [[98.9853, 18.7883], [99.0, 18.8]] } }],
|
|
}),
|
|
};
|
|
};
|
|
try {
|
|
const res = await agent.get(
|
|
'/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=46.5,10.45|46.6,10.5'
|
|
);
|
|
assert.equal(res.status, 200);
|
|
// OSRM coordinate order is {lng},{lat}, from -> via... -> to.
|
|
assert.ok(requestedUrl.includes('98.9853,18.7883;10.45,46.5;10.5,46.6;99,18.8'));
|
|
} finally {
|
|
global.fetch = original;
|
|
}
|
|
});
|
|
|
|
test('directions: malformed via is rejected with 400', async () => {
|
|
const { agent } = await createAccount();
|
|
|
|
const malformed = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=abc');
|
|
assert.equal(malformed.status, 400);
|
|
|
|
const outOfRange = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=999,10');
|
|
assert.equal(outOfRange.status, 400);
|
|
|
|
const tooMany = await agent.get(
|
|
`/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=${Array.from({ length: 9 }, () => '1,1').join('|')}`
|
|
);
|
|
assert.equal(tooMany.status, 400);
|
|
});
|
|
|
|
test('directions: cache distinguishes requests with different via points', async () => {
|
|
const { agent } = await createAccount();
|
|
const original = global.fetch;
|
|
let calls = 0;
|
|
global.fetch = async () => {
|
|
calls += 1;
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
code: 'Ok',
|
|
routes: [{ distance: 1000, geometry: { coordinates: [[98.9853, 18.7883], [99.0, 18.8]] } }],
|
|
}),
|
|
};
|
|
};
|
|
try {
|
|
const noVia = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
|
assert.equal(noVia.status, 200);
|
|
assert.equal(calls, 1);
|
|
|
|
const withVia = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=46.5,10.45');
|
|
assert.equal(withVia.status, 200);
|
|
assert.equal(calls, 2, 'a different via should not hit the no-via cache entry');
|
|
|
|
const sameVia = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0&via=46.5,10.45');
|
|
assert.equal(sameVia.status, 200);
|
|
assert.equal(calls, 2, 'identical via should be served from cache');
|
|
} finally {
|
|
global.fetch = original;
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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' });
|
|
});
|