- Type set is now activity/stay/transport/flight/rental/note; hotel, travel and immigration are removed with idempotent startup data migrations (hotel->stay, travel->transport, immigration->activity with flag prefix) - Transport entries carry an optional mode (train/bus/ferry/taxi/drive/other) that drives the chip/map icon; route stops expose transport_mode - Creating a stay auto-creates a bridging transport to its neighbouring stays unless a transport/flight already covers the gap (one-shot) - Summary: transports count replaces hotels/travelLegs
228 lines
8.6 KiB
JavaScript
228 lines
8.6 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-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: 'stay', 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: 'stay', 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, []);
|
|
});
|