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)
This commit is contained in:
+73
-1
@@ -325,7 +325,7 @@ test('entry CRUD and full-row shape', async () => {
|
||||
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'].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, '');
|
||||
@@ -607,6 +607,78 @@ test('directions requires auth', async () => {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -124,6 +124,167 @@ test('route stops include transport_mode (entry value, or null for airport stops
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user