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, []); });