Files
trip-plan/tests/transport.test.js
grabowski e2c3089c25 Add scenic waypoints for drive legs (OSRM via-routing)
- 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)
2026-07-20 14:57:26 +07:00

596 lines
24 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));
});
// ---------------------------------------------------------------------------
// Scenic waypoints
// ---------------------------------------------------------------------------
test('waypoints: accepted on transport entries (POST), returned parsed in entry JSON', 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: 'Alpine drive',
waypoints: [{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' }, { lat: 46.6, lng: 10.5 }],
});
assert.equal(res.status, 201);
assert.deepEqual(res.body.entry.waypoints, [
{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' },
{ lat: 46.6, lng: 10.5 },
]);
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.waypoints, null);
});
test('waypoints: PATCH accepts an array; [] and null both clear to null', 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({
waypoints: [{ lat: 1, lng: 2 }],
});
assert.equal(patched.status, 200);
assert.deepEqual(patched.body.entry.waypoints, [{ lat: 1, lng: 2 }]);
const clearedEmpty = await agent.patch(`/api/entries/${entry.id}`).send({ waypoints: [] });
assert.equal(clearedEmpty.status, 200);
assert.equal(clearedEmpty.body.entry.waypoints, null);
await agent.patch(`/api/entries/${entry.id}`).send({ waypoints: [{ lat: 1, lng: 2 }] });
const clearedNull = await agent.patch(`/api/entries/${entry.id}`).send({ waypoints: null });
assert.equal(clearedNull.status, 200);
assert.equal(clearedNull.body.entry.waypoints, null);
});
test('waypoints: 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', waypoints: [{ lat: 1, lng: 2 }],
});
assert.equal(res.status, 400);
assert.deepEqual(res.body, { error: 'waypoints are only allowed on transport entries' });
});
test('waypoints: more than 8 rejected', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const nine = Array.from({ length: 9 }, (_, i) => ({ lat: i, lng: i }));
const res = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x', waypoints: nine });
assert.equal(res.status, 400);
assert.deepEqual(res.body, { error: 'at most 8 waypoints' });
const eight = Array.from({ length: 8 }, (_, i) => ({ lat: i, lng: i }));
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'transport', title: 'x', waypoints: eight });
assert.equal(ok.status, 201);
});
test('waypoints: bad coord rejected', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const badLat = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x', waypoints: [{ lat: 999, lng: 10 }],
});
assert.equal(badLat.status, 400);
assert.deepEqual(badLat.body, { error: 'waypoint lat/lng out of range' });
const badLng = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x', waypoints: [{ lat: 10, lng: -999 }],
});
assert.equal(badLng.status, 400);
assert.deepEqual(badLng.body, { error: 'waypoint lat/lng out of range' });
const nonArray = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x', waypoints: 'nope',
});
assert.equal(nonArray.status, 400);
assert.deepEqual(nonArray.body, { error: 'waypoints must be an array' });
});
test('waypoints: bad name rejected', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const tooLong = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x',
waypoints: [{ lat: 1, lng: 2, name: 'x'.repeat(121) }],
});
assert.equal(tooLong.status, 400);
assert.deepEqual(tooLong.body, { error: 'waypoint name must be a string of at most 120 characters' });
const notString = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x',
waypoints: [{ lat: 1, lng: 2, name: 42 }],
});
assert.equal(notString.status, 400);
// Empty name after trim is dropped rather than rejected.
const emptyName = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x',
waypoints: [{ lat: 1, lng: 2, name: ' ' }],
});
assert.equal(emptyName.status, 201);
assert.deepEqual(emptyName.body.entry.waypoints, [{ lat: 1, lng: 2 }]);
});
test('route: a ground leg bridged by a waypoint-bearing transport exposes leg.waypoints; air legs and unbridged legs have []', 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: 'activity', title: 'Start', location_name: 'A', lat: 10, lng: 10,
});
await agent.post(base).send({
date: '2026-08-02', type: 'transport', title: 'Drive', transport_mode: 'drive',
waypoints: [{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' }],
});
await agent.post(base).send({
date: '2026-08-03', type: 'activity', title: 'End', location_name: 'B', lat: 20, lng: 20,
});
await agent.post(base).send({
date: '2026-08-04', type: 'flight', title: 'BKK-CNX',
segments: [
{ from: { code: 'BKK', lat: 13.68, lng: 100.75 }, to: { code: 'CNX', lat: 18.77, lng: 98.96 } },
],
});
const res = await agent.get(`/api/trips/${trip.id}/route`);
assert.equal(res.status, 200);
const legs = res.body.legs;
assert.ok(legs.length >= 2);
const groundLeg = legs.find((l) => l.mode === 'ground');
assert.ok(groundLeg);
assert.deepEqual(groundLeg.waypoints, [{ lat: 46.5, lng: 10.45, name: 'Stelvio Pass' }]);
const airLeg = legs.find((l) => l.mode === 'air');
assert.ok(airLeg);
assert.deepEqual(airLeg.waypoints, []);
});
// ---------------------------------------------------------------------------
// 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);
assert.ok(transports[0].auto_ref);
assert.equal(transports[0].auto_ref.to, berlin.id);
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'));
});
// ---------------------------------------------------------------------------
// Regenerating auto-transports
// ---------------------------------------------------------------------------
test('auto_ref cannot be set via POST/PATCH bodies', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const base = `/api/trips/${trip.id}/entries`;
const posted = await agent.post(base).send({
date: '2026-08-01', type: 'transport', title: 'x', auto_ref: { from: 1, to: 2 },
});
assert.equal(posted.status, 201);
assert.equal(posted.body.entry.auto_ref, null);
const patched = await agent.patch(`/api/entries/${posted.body.entry.id}`).send({
auto_ref: { from: 1, to: 2 },
});
assert.equal(patched.status, 200);
assert.equal(patched.body.entry.auto_ref, null);
});
test('regenerate: non-member gets 404', async () => {
const { agent } = await createAccount();
const trip = await makeTrip(agent);
const { agent: other } = await createAccount();
const res = await other.post(`/api/trips/${trip.id}/transports/regenerate`);
assert.equal(res.status, 404);
});
test('regenerate: re-dates and re-titles the auto transport after a stay moves, preserving transport_mode', 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: 'B', location_name: 'Berlin',
})).body.entry;
let list = (await agent.get(base)).body.entries;
const auto = list.find((e) => e.type === 'transport');
assert.ok(auto);
await agent.patch(`/api/entries/${auto.id}`).send({ transport_mode: 'train' });
// Move Berlin later; still the second stay -> same (from,to) pair.
await agent.patch(`/api/entries/${berlin.id}`).send({ date: '2026-08-20' });
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
assert.equal(res.status, 200);
assert.deepEqual(res.body, { created: 0, updated: 1, deleted: 0 });
list = (await agent.get(base)).body.entries;
const updated = list.find((e) => e.type === 'transport');
assert.equal(updated.id, auto.id);
assert.equal(updated.date, '2026-08-20');
assert.equal(updated.title, 'Venice → Berlin');
assert.equal(updated.transport_mode, 'train');
});
test('regenerate: deletes an auto transport orphaned by a deleted 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: 'V', location_name: 'Venice',
});
const berlin = (await agent.post(base).send({
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
})).body.entry;
await agent.delete(`/api/entries/${berlin.id}`);
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
assert.equal(res.status, 200);
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 1 });
const list = (await agent.get(base)).body.entries;
assert.equal(list.filter((e) => e.type === 'transport').length, 0);
});
test('regenerate: recreates a missing auto transport', 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-04', type: 'stay', title: 'B', location_name: 'Berlin',
});
let list = (await agent.get(base)).body.entries;
const auto = list.find((e) => e.type === 'transport');
await agent.delete(`/api/entries/${auto.id}`);
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
assert.equal(res.status, 200);
assert.deepEqual(res.body, { created: 1, updated: 0, deleted: 0 });
list = (await agent.get(base)).body.entries;
const recreated = list.find((e) => e.type === 'transport');
assert.ok(recreated);
assert.equal(recreated.title, 'Venice → Berlin');
assert.ok(recreated.auto_ref);
});
test('regenerate: respects manual transport coverage — creates nothing and does not delete it', 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 manual = (await agent.post(base).send({
date: '2026-08-03', type: 'transport', title: 'Manual train',
})).body.entry;
await agent.post(base).send({
date: '2026-08-04', type: 'stay', title: 'B', location_name: 'Berlin',
});
// No auto transport was created at stay-creation time (manual already covers the window).
let list = (await agent.get(base)).body.entries;
assert.equal(list.filter((e) => e.type === 'transport').length, 1);
const res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
assert.equal(res.status, 200);
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 0 });
list = (await agent.get(base)).body.entries;
const transports = list.filter((e) => e.type === 'transport');
assert.equal(transports.length, 1);
assert.equal(transports[0].id, manual.id);
assert.equal(transports[0].auto_ref, null);
});
test('regenerate: overlapping stays (inverted window) produce no pair and do not crash', 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 res = await agent.post(`/api/trips/${trip.id}/transports/regenerate`);
assert.equal(res.status, 200);
assert.deepEqual(res.body, { created: 0, updated: 0, deleted: 0 });
});