- 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
278 lines
11 KiB
JavaScript
278 lines
11 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';
|
|
import { applyDataMigrations } from '../src/server/db.js';
|
|
|
|
let tmpDir;
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-transport-'));
|
|
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: 'T', start_date: '2026-08-01', end_date: '2026-09-30' })).body.trip;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// transport_mode validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('transport_mode: accepted on transport entries, returned in JSON', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'Bus', transport_mode: 'bus' });
|
|
assert.equal(ok.status, 201);
|
|
assert.equal(ok.body.entry.transport_mode, 'bus');
|
|
|
|
const absent = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x' });
|
|
assert.equal(absent.status, 201);
|
|
assert.equal(absent.body.entry.transport_mode, null);
|
|
});
|
|
|
|
test('transport_mode: rejected on non-transport entries', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
const res = await agent.post(base).send({ date: '2026-08-01', type: 'activity', title: 'x', transport_mode: 'bus' });
|
|
assert.equal(res.status, 400);
|
|
});
|
|
|
|
test('transport_mode: enum enforced', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
const res = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x', transport_mode: 'rocket' });
|
|
assert.equal(res.status, 400);
|
|
|
|
for (const mode of ['train', 'bus', 'ferry', 'taxi', 'drive', 'other']) {
|
|
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: mode, transport_mode: mode });
|
|
assert.equal(ok.status, 201, `expected ${mode} to be accepted`);
|
|
}
|
|
});
|
|
|
|
test('transport_mode: PATCH accepts a mode, null clears it, rejected when entry is not transport', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
const entry = (await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x' })).body.entry;
|
|
|
|
const patched = await agent.patch(`/api/entries/${entry.id}`).send({ transport_mode: 'train' });
|
|
assert.equal(patched.status, 200);
|
|
assert.equal(patched.body.entry.transport_mode, 'train');
|
|
|
|
const cleared = await agent.patch(`/api/entries/${entry.id}`).send({ transport_mode: null });
|
|
assert.equal(cleared.status, 200);
|
|
assert.equal(cleared.body.entry.transport_mode, null);
|
|
|
|
const note = (await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'n' })).body.entry;
|
|
const rejected = await agent.patch(`/api/entries/${note.id}`).send({ transport_mode: 'taxi' });
|
|
assert.equal(rejected.status, 400);
|
|
});
|
|
|
|
test('route stops include transport_mode (entry value, or null for airport stops)', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({
|
|
date: '2026-08-01', type: 'transport', title: 'Bus to X', transport_mode: 'bus',
|
|
location_name: 'Somewhere', lat: 10, lng: 20,
|
|
});
|
|
await agent.post(base).send({
|
|
date: '2026-08-02', type: 'flight', title: 'CNX-BKK',
|
|
segments: [
|
|
{ from: { code: 'CNX', lat: 18.77, lng: 98.96 }, to: { code: 'BKK', lat: 13.68, lng: 100.75 } },
|
|
],
|
|
});
|
|
|
|
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
|
const stops = res.body.stops;
|
|
|
|
const transportStop = stops.find((s) => s.type === 'transport');
|
|
assert.ok(transportStop);
|
|
assert.equal(transportStop.transport_mode, 'bus');
|
|
|
|
const airportStops = stops.filter((s) => s.kind === 'airport');
|
|
assert.ok(airportStops.length > 0);
|
|
assert.ok(airportStops.every((s) => s.transport_mode === null));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data migrations (legacy types -> new types)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('data migrations: hotel/travel/immigration convert to stay/transport/activity', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const db = app.locals.db;
|
|
|
|
const insertLegacy = db.prepare(
|
|
`INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode)
|
|
VALUES (?, ?, ?, ?, '', 0, 'equal')`
|
|
);
|
|
insertLegacy.run(trip.id, '2026-08-01', 'hotel', 'Old Hotel');
|
|
insertLegacy.run(trip.id, '2026-08-02', 'travel', 'Old Travel');
|
|
insertLegacy.run(trip.id, '2026-08-03', 'immigration', 'Border crossing');
|
|
|
|
applyDataMigrations(db);
|
|
|
|
const rows = db.prepare('SELECT type, title FROM entries WHERE trip_id = ? ORDER BY date').all(trip.id);
|
|
assert.deepEqual(rows, [
|
|
{ type: 'stay', title: 'Old Hotel' },
|
|
{ type: 'transport', title: 'Old Travel' },
|
|
{ type: 'activity', title: '🛂 Border crossing' },
|
|
]);
|
|
|
|
// Idempotent: no legacy rows remain, so re-running is a no-op.
|
|
applyDataMigrations(db);
|
|
const rowsAgain = db.prepare('SELECT type, title FROM entries WHERE trip_id = ? ORDER BY date').all(trip.id);
|
|
assert.deepEqual(rowsAgain, rows);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-transport between stays
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('auto-transport: creates a bridging entry between two consecutive stays', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({
|
|
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice',
|
|
});
|
|
const berlin = (await agent.post(base).send({
|
|
date: '2026-08-04', type: 'stay', title: 'Berlin',
|
|
})).body.entry;
|
|
|
|
const list = (await agent.get(base)).body.entries;
|
|
const transports = list.filter((e) => e.type === 'transport');
|
|
assert.equal(transports.length, 1);
|
|
assert.equal(transports[0].title, 'Venice → Berlin');
|
|
assert.equal(transports[0].date, berlin.date);
|
|
assert.equal(transports[0].sort_order, 0);
|
|
|
|
const route = await agent.get(`/api/trips/${trip.id}/route`);
|
|
assert.equal(route.body.summary.transports, 1);
|
|
});
|
|
|
|
test('auto-transport: title uses the first comma-segment of location_name', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({
|
|
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice, Italy',
|
|
});
|
|
await agent.post(base).send({
|
|
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin, Germany',
|
|
});
|
|
|
|
const list = (await agent.get(base)).body.entries;
|
|
const auto = list.find((e) => e.type === 'transport');
|
|
assert.equal(auto.title, 'Venice → Berlin');
|
|
});
|
|
|
|
test('auto-transport: skipped when a flight already covers the window', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({ date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice' });
|
|
await agent.post(base).send({ date: '2026-08-04', type: 'flight', title: 'VCE-TXL' });
|
|
await agent.post(base).send({ date: '2026-08-04', type: 'stay', title: 'Berlin' });
|
|
|
|
const list = (await agent.get(base)).body.entries;
|
|
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
|
|
});
|
|
|
|
test('auto-transport: overlapping stays (inverted window) are skipped', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({ date: '2026-08-01', end_date: '2026-08-10', type: 'stay', title: 'Region' });
|
|
await agent.post(base).send({ date: '2026-08-05', type: 'stay', title: 'City inside' });
|
|
|
|
const list = (await agent.get(base)).body.entries;
|
|
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
|
|
});
|
|
|
|
test('auto-transport: a stay inserted between two existing stays bridges gaps not already covered', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({
|
|
date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'V', location_name: 'Venice',
|
|
});
|
|
await agent.post(base).send({
|
|
date: '2026-08-08', end_date: '2026-08-10', type: 'stay', title: 'B', location_name: 'Berlin',
|
|
});
|
|
|
|
let list = (await agent.get(base)).body.entries;
|
|
assert.equal(list.filter((e) => e.type === 'transport').length, 1);
|
|
assert.ok(list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin' && e.date === '2026-08-08'));
|
|
|
|
// Prague, inserted in between: bridges Venice->Prague (gap not covered);
|
|
// Prague->Berlin is skipped because the existing Venice->Berlin transport
|
|
// (dated 2026-08-08) already falls inside that window.
|
|
await agent.post(base).send({
|
|
date: '2026-08-05', type: 'stay', title: 'P', location_name: 'Prague',
|
|
});
|
|
|
|
list = (await agent.get(base)).body.entries;
|
|
const transports = list.filter((e) => e.type === 'transport');
|
|
assert.equal(transports.length, 2);
|
|
assert.ok(transports.some((e) => e.title === 'Venice → Prague' && e.date === '2026-08-05'));
|
|
assert.ok(!transports.some((e) => e.title === 'Prague → Berlin'));
|
|
});
|
|
|
|
test('auto-transport: deleting it is not resurrected by an unrelated later stay', async () => {
|
|
const { agent } = await createAccount();
|
|
const trip = await makeTrip(agent);
|
|
const base = `/api/trips/${trip.id}/entries`;
|
|
|
|
await agent.post(base).send({ date: '2026-08-01', end_date: '2026-08-03', type: 'stay', title: 'Venice', location_name: 'Venice' });
|
|
await agent.post(base).send({ date: '2026-08-04', type: 'stay', title: 'Berlin', location_name: 'Berlin' });
|
|
|
|
let list = (await agent.get(base)).body.entries;
|
|
const auto = list.find((e) => e.type === 'transport' && e.title === 'Venice → Berlin');
|
|
assert.ok(auto);
|
|
assert.equal((await agent.delete(`/api/entries/${auto.id}`)).status, 204);
|
|
|
|
// Unrelated stay earlier in time -- touches a different gap entirely, and
|
|
// auto-transport only fires on stay creation (never as a background pass).
|
|
await agent.post(base).send({ date: '2026-07-01', type: 'stay', title: 'Bangkok', location_name: 'Bangkok' });
|
|
|
|
list = (await agent.get(base)).body.entries;
|
|
assert.ok(!list.some((e) => e.type === 'transport' && e.title === 'Venice → Berlin'));
|
|
});
|