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:
2026-07-20 14:57:26 +07:00
parent 9369c82e56
commit e2c3089c25
15 changed files with 594 additions and 16 deletions
+73 -1
View File
@@ -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
// ---------------------------------------------------------------------------