Route ground legs along real roads via an OSRM directions proxy
- GET /api/directions proxies OSRM (OSRM_URL env, default public demo server) with validation, Leaflet-order geometry, 24h capped cache - Map draws straight lines first, then upgrades ground legs to road-following polylines with routed km (marked road) as directions arrive; silent fallback to great-circle on failure; air legs unchanged - README documents OSRM_URL
This commit is contained in:
@@ -109,10 +109,11 @@ npm test # API tests (node:test + supertest)
|
|||||||
| `PORT` | `3000` | HTTP port the server listens on. |
|
| `PORT` | `3000` | HTTP port the server listens on. |
|
||||||
| `DATA_DIR` | `./data` (`/app/data` in Docker) | Directory holding the SQLite database file. |
|
| `DATA_DIR` | `./data` (`/app/data` in Docker) | Directory holding the SQLite database file. |
|
||||||
| `SESSION_SECRET` | dev value (warns) | Secret used to sign session cookies. **Set this in production.** |
|
| `SESSION_SECRET` | dev value (warns) | Secret used to sign session cookies. **Set this in production.** |
|
||||||
|
| `OSRM_URL` | `https://router.project-osrm.org` | Base URL for the driving-directions proxy (`/api/directions`). The default is the public OSRM demo server — fine for light personal use. Self-hosters can point this at their own OSRM instance. |
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
|
|
||||||
All state lives in a single SQLite file under `DATA_DIR`. In Docker this is `/app/data`, bind-mounted from `./data` next to the compose file — back up that directory to back up all trips. No external database or services are required (geocoding calls OpenStreetMap's Nominatim, which needs no API key; airport lookups use a bundled offline dataset).
|
All state lives in a single SQLite file under `DATA_DIR`. In Docker this is `/app/data`, bind-mounted from `./data` next to the compose file — back up that directory to back up all trips. No external database or services are required (geocoding calls OpenStreetMap's Nominatim, which needs no API key; airport lookups use a bundled offline dataset; road directions call the public OSRM demo server, also no API key).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
+11
@@ -243,6 +243,17 @@ Computation (see Cost semantics above):
|
|||||||
|
|
||||||
Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=…` server-side with header `User-Agent: trip-plan-app/0.1 (self-hosted)` (`accept-language=en` so results come back in Latin script, not the local language). Map Nominatim's `display_name`→`name`, parse lat/lon to numbers. On upstream failure return `502 {"error":"geocoding unavailable"}`. Cache identical queries in-memory for 10 minutes.
|
Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=…` server-side with header `User-Agent: trip-plan-app/0.1 (self-hosted)` (`accept-language=en` so results come back in Latin script, not the local language). Map Nominatim's `display_name`→`name`, parse lat/lon to numbers. On upstream failure return `502 {"error":"geocoding unavailable"}`. Cache identical queries in-memory for 10 minutes.
|
||||||
|
|
||||||
|
### Directions proxy (OSRM)
|
||||||
|
|
||||||
|
`GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>` → `200 {km, geometry}` (requires auth).
|
||||||
|
|
||||||
|
- Proxies `${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{toLng},{toLat}?overview=full&geometries=geojson` server-side. `OSRM_URL` env var, default `https://router.project-osrm.org` (the public demo server — fine for light personal use; self-hosters can point it at their own OSRM). Send the same `User-Agent` header as the geocode proxy.
|
||||||
|
- Validation: `from`/`to` must each be `lat,lng` with finite numbers in range (lat [-90,90], lng [-180,180]) → else `400`. Upstream failure, non-Ok OSRM code, or no route → `502 {"error":"directions unavailable"}`.
|
||||||
|
- Response `km` = route distance / 1000 rounded to 1 decimal; `geometry` = the GeoJSON coordinates converted to `[[lat,lng], …]` (Leaflet order).
|
||||||
|
- In-memory cache: key = both coord pairs rounded to 5 decimals, TTL 24 h, cap ~500 entries (evict oldest).
|
||||||
|
|
||||||
|
**Consumer contract (map)**: the frontend requests directions for each `mode: "ground"` route leg and, on success, draws the road-following polyline instead of the straight line and shows the routed km for that leg ("via road"); on 502/failure it keeps the straight great-circle line silently. Air legs stay straight/dashed. `/route` itself (and `summary.kmDriven`) remains great-circle — the server does not call OSRM during route computation.
|
||||||
|
|
||||||
## Frontend contract notes
|
## Frontend contract notes
|
||||||
|
|
||||||
- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed.
|
- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed.
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const api = {
|
|||||||
costs: (id) => get(`/api/trips/${id}/costs`),
|
costs: (id) => get(`/api/trips/${id}/costs`),
|
||||||
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
|
regenerateTransports: (id) => post(`/api/trips/${id}/transports/regenerate`, {}),
|
||||||
},
|
},
|
||||||
|
directions: (from, to) => get(`/api/directions?from=${from.lat},${from.lng}&to=${to.lat},${to.lng}`),
|
||||||
entries: {
|
entries: {
|
||||||
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
||||||
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
|
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
|
||||||
|
|||||||
+84
-34
@@ -3,6 +3,7 @@
|
|||||||
// trip has no located entries yet. Uses the /route response from tctx.route.
|
// trip has no located entries yet. Uses the /route response from tctx.route.
|
||||||
import { el } from '../dom.js';
|
import { el } from '../dom.js';
|
||||||
import { typeInfo, entryIcon } from '../format.js';
|
import { typeInfo, entryIcon } from '../format.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
export function renderMap(tctx) {
|
export function renderMap(tctx) {
|
||||||
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
||||||
@@ -35,16 +36,38 @@ export function renderMap(tctx) {
|
|||||||
|
|
||||||
const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` });
|
const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` });
|
||||||
section.appendChild(mapDiv);
|
section.appendChild(mapDiv);
|
||||||
section.appendChild(legList(route, stops));
|
|
||||||
|
// Legs and the "Legs" list below share the same filtered (leg, fromStop,
|
||||||
|
// toStop) triples so the map's polylines/labels and the list rows stay in
|
||||||
|
// lockstep by index.
|
||||||
|
const legs = visibleLegs(route, stops);
|
||||||
|
const { wrap: legListEl, kmEls } = legList(legs);
|
||||||
|
section.appendChild(legListEl);
|
||||||
|
|
||||||
// Leaflet needs the container attached with a real size, so init on the
|
// Leaflet needs the container attached with a real size, so init on the
|
||||||
// next tick after this section is mounted into the page.
|
// next tick after this section is mounted into the page.
|
||||||
setTimeout(() => initMap(mapDiv, route, stops), 0);
|
setTimeout(() => initMap(mapDiv, stops, legs, kmEls), 0);
|
||||||
|
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initMap(mapDiv, route, stops) {
|
// Consecutive non-coincident stop pairs (skip <0.05km, the server's rule),
|
||||||
|
// paired with their matching /route leg.
|
||||||
|
function visibleLegs(route, stops) {
|
||||||
|
const legs = route.legs || [];
|
||||||
|
const result = [];
|
||||||
|
let li = 0;
|
||||||
|
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
||||||
|
const a = stops[i];
|
||||||
|
const b = stops[i + 1];
|
||||||
|
if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue;
|
||||||
|
result.push({ leg: legs[li], a, b });
|
||||||
|
li += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMap(mapDiv, stops, legs, kmEls) {
|
||||||
const L = window.L;
|
const L = window.L;
|
||||||
if (!L) {
|
if (!L) {
|
||||||
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
|
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
|
||||||
@@ -69,26 +92,20 @@ function initMap(mapDiv, route, stops) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
|
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
|
||||||
// solid teal), each with a km label at its midpoint. Legs are matched to
|
// solid teal), each with a km label at its midpoint (straight great-circle
|
||||||
// consecutive non-coincident stop pairs (skip <0.05km, the server's rule).
|
// line to start — the map is never empty while road geometry loads).
|
||||||
const legs = route.legs || [];
|
const legLayers = legs.map(({ leg, a, b }) => {
|
||||||
let li = 0;
|
|
||||||
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
|
||||||
const a = points[i];
|
|
||||||
const b = points[i + 1];
|
|
||||||
if (haversineKm(a[0], a[1], b[0], b[1]) < 0.05) continue;
|
|
||||||
const leg = legs[li];
|
|
||||||
li += 1;
|
|
||||||
const air = leg.mode === 'air';
|
const air = leg.mode === 'air';
|
||||||
L.polyline([a, b], {
|
const line = L.polyline([[a.lat, a.lng], [b.lat, b.lng]], {
|
||||||
color: air ? '#2563eb' : '#0f766e',
|
color: air ? '#2563eb' : '#0f766e',
|
||||||
weight: 3,
|
weight: 3,
|
||||||
opacity: 0.75,
|
opacity: 0.75,
|
||||||
dashArray: air ? '6 6' : null,
|
dashArray: air ? '6 6' : null,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
const mid = [(a.lat + b.lat) / 2, (a.lng + b.lng) / 2];
|
||||||
L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map);
|
const label = L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map);
|
||||||
}
|
return { leg, a, b, line, label };
|
||||||
|
});
|
||||||
|
|
||||||
if (points.length === 1) {
|
if (points.length === 1) {
|
||||||
map.setView(points[0], 10);
|
map.setView(points[0], 10);
|
||||||
@@ -96,6 +113,36 @@ function initMap(mapDiv, route, stops) {
|
|||||||
map.fitBounds(L.latLngBounds(points).pad(0.2));
|
map.fitBounds(L.latLngBounds(points).pad(0.2));
|
||||||
}
|
}
|
||||||
map.invalidateSize();
|
map.invalidateSize();
|
||||||
|
|
||||||
|
fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async road-following geometry for ground legs, per the /api/directions
|
||||||
|
// "Consumer contract (map)" in docs/API.md. Straight great-circle lines are
|
||||||
|
// already drawn, so a slow/failed fetch just leaves those in place — no
|
||||||
|
// error UI. Each leg fetches (and fails) independently.
|
||||||
|
function fetchRoadGeometry(L, map, mapDiv, legLayers, kmEls) {
|
||||||
|
legLayers.forEach(({ leg, a, b, line, label }, i) => {
|
||||||
|
if (leg.mode !== 'ground') return;
|
||||||
|
api.directions({ lat: a.lat, lng: a.lng }, { lat: b.lat, lng: b.lng })
|
||||||
|
.then((res) => {
|
||||||
|
// The trip view may have been re-rendered (refreshTrip) while this
|
||||||
|
// was in flight — the old map/section is detached from the DOM and
|
||||||
|
// its layers must not be touched.
|
||||||
|
if (!mapDiv.isConnected || !map.hasLayer(line) || !map.hasLayer(label)) return;
|
||||||
|
line.setLatLngs(res.geometry);
|
||||||
|
label.setLatLng(pathMidpoint(res.geometry) || label.getLatLng());
|
||||||
|
label.setIcon(kmLabel(L, res.km, true));
|
||||||
|
const kmEl = kmEls[i];
|
||||||
|
if (kmEl) kmEl.textContent = `${fmtKm(res.km)} km · road`;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathMidpoint(coords) {
|
||||||
|
if (!coords || !coords.length) return null;
|
||||||
|
return coords[Math.floor(coords.length / 2)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Great-circle km — mirrors the server rule for skipping zero-distance legs.
|
// Great-circle km — mirrors the server rule for skipping zero-distance legs.
|
||||||
@@ -119,10 +166,10 @@ function numberedIcon(L, n, color) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function kmLabel(L, km) {
|
function kmLabel(L, km, road) {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'km-label-wrap',
|
className: 'km-label-wrap',
|
||||||
html: `<span class="km-label">${fmtKm(km)} km</span>`,
|
html: `<span class="km-label">${fmtKm(km)} km${road ? ' · road' : ''}</span>`,
|
||||||
iconSize: [0, 0],
|
iconSize: [0, 0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,32 +196,35 @@ function stopLabel(stop) {
|
|||||||
return stop.location_name || stop.title || '?';
|
return stop.location_name || stop.title || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
function legList(route, stops) {
|
// legs: [{ leg, a, b }, …] from visibleLegs — same order/filtering the map
|
||||||
const legs = route.legs || [];
|
// polylines use. Returns the list element plus the per-row km <span>s (by
|
||||||
if (!legs.length) return el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.');
|
// the same index) so fetchRoadGeometry can update them once routed km lands.
|
||||||
|
function legList(legs) {
|
||||||
|
if (!legs.length) {
|
||||||
|
return {
|
||||||
|
wrap: el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.'),
|
||||||
|
kmEls: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
|
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
|
||||||
let li = 0;
|
const kmEls = [];
|
||||||
let shown = 0;
|
legs.forEach(({ leg, a, b }, i) => {
|
||||||
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
|
||||||
const a = stops[i];
|
|
||||||
const b = stops[i + 1];
|
|
||||||
if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue;
|
|
||||||
const leg = legs[li++];
|
|
||||||
shown += 1;
|
|
||||||
const air = leg.mode === 'air';
|
const air = leg.mode === 'air';
|
||||||
|
const kmEl = el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`);
|
||||||
|
kmEls.push(kmEl);
|
||||||
wrap.appendChild(
|
wrap.appendChild(
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'leg-row' },
|
{ class: 'leg-row' },
|
||||||
el('span', { class: 'leg-index' }, String(shown)),
|
el('span', { class: 'leg-index' }, String(i + 1)),
|
||||||
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
|
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
|
||||||
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
|
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
|
||||||
el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`),
|
kmEl,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
return wrap;
|
return { wrap, kmEls };
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtKm(n) {
|
function fmtKm(n) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import tripsRoutes from './routes/trips.js';
|
|||||||
import entriesRoutes from './routes/entries.js';
|
import entriesRoutes from './routes/entries.js';
|
||||||
import geocodeRoutes from './routes/geocode.js';
|
import geocodeRoutes from './routes/geocode.js';
|
||||||
import airportsRoutes from './routes/airports.js';
|
import airportsRoutes from './routes/airports.js';
|
||||||
|
import directionsRoutes from './routes/directions.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
|
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
|
||||||
@@ -51,6 +52,7 @@ export function createApp(options = {}) {
|
|||||||
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
||||||
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
||||||
app.use('/api/airports', requireAuth, airportsRoutes());
|
app.use('/api/airports', requireAuth, airportsRoutes());
|
||||||
|
app.use('/api/directions', requireAuth, directionsRoutes());
|
||||||
|
|
||||||
// JSON 404 for any unmatched /api route.
|
// JSON 404 for any unmatched /api route.
|
||||||
app.use('/api', (req, res) => {
|
app.use('/api', (req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const USER_AGENT = 'trip-plan-app/0.1 (self-hosted)';
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_CACHE_ENTRIES = 500;
|
||||||
|
|
||||||
|
// Parses a "lat,lng" query param into {lat, lng}, or null if malformed/out of range.
|
||||||
|
function parseLatLng(value) {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const parts = value.split(',');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const lat = Number(parts[0]);
|
||||||
|
const lng = Number(parts[1]);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||||
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n) {
|
||||||
|
return Math.round(n * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function directionsRoutes() {
|
||||||
|
const router = express.Router();
|
||||||
|
const cache = new Map(); // "lat,lng;lat,lng" (rounded) -> { at, data }
|
||||||
|
|
||||||
|
// GET /api/directions?from=lat,lng&to=lat,lng
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const from = parseLatLng(req.query.from);
|
||||||
|
const to = parseLatLng(req.query.to);
|
||||||
|
if (!from || !to) {
|
||||||
|
return res.status(400).json({ error: 'from and to must be lat,lng' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const osrmUrl = process.env.OSRM_URL || 'https://router.project-osrm.org';
|
||||||
|
const key = `${from.lat.toFixed(5)},${from.lng.toFixed(5)};${to.lat.toFixed(5)},${to.lng.toFixed(5)}`;
|
||||||
|
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||||
|
return res.status(200).json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${osrmUrl}/route/v1/driving/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson`;
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!upstream.ok) throw new Error(`upstream status ${upstream.status}`);
|
||||||
|
data = await upstream.json();
|
||||||
|
} catch {
|
||||||
|
return res.status(502).json({ error: 'directions unavailable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = data && data.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null;
|
||||||
|
if (!route) {
|
||||||
|
return res.status(502).json({ error: 'directions unavailable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
km: round1(route.distance / 1000),
|
||||||
|
geometry: route.geometry.coordinates.map(([lng, lat]) => [lat, lng]),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cache.size >= MAX_CACHE_ENTRIES) {
|
||||||
|
const oldestKey = cache.keys().next().value;
|
||||||
|
cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
cache.set(key, { at: Date.now(), data: result });
|
||||||
|
|
||||||
|
res.status(200).json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -514,6 +514,99 @@ test('geocode requires auth and a query', async () => {
|
|||||||
assert.equal(noQuery.status, 400);
|
assert.equal(noQuery.status, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Directions (mocked upstream — never hits the real OSRM)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('directions proxies and caches results (mocked fetch)', 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: 12345.6,
|
||||||
|
geometry: {
|
||||||
|
coordinates: [
|
||||||
|
[98.9853, 18.7883],
|
||||||
|
[99.0, 18.8],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const first = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(first.status, 200);
|
||||||
|
assert.equal(first.body.km, 12.3);
|
||||||
|
assert.deepEqual(first.body.geometry, [
|
||||||
|
[18.7883, 98.9853],
|
||||||
|
[18.8, 99.0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Second identical request is served from cache -> fetch not called again.
|
||||||
|
const second = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(second.status, 200);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
} finally {
|
||||||
|
global.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directions validates from/to params', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
|
||||||
|
const missing = await agent.get('/api/directions?from=18.7883,98.9853');
|
||||||
|
assert.equal(missing.status, 400);
|
||||||
|
assert.deepEqual(missing.body, { error: 'from and to must be lat,lng' });
|
||||||
|
|
||||||
|
const malformed = await agent.get('/api/directions?from=abc&to=18.8,99.0');
|
||||||
|
assert.equal(malformed.status, 400);
|
||||||
|
|
||||||
|
const outOfRange = await agent.get('/api/directions?from=999,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(outOfRange.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directions returns 502 on non-Ok OSRM code and on fetch failure', async () => {
|
||||||
|
const { agent } = await createAccount();
|
||||||
|
const original = global.fetch;
|
||||||
|
|
||||||
|
global.fetch = async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ code: 'NoRoute', routes: [] }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const noRoute = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(noRoute.status, 502);
|
||||||
|
assert.deepEqual(noRoute.body, { error: 'directions unavailable' });
|
||||||
|
} finally {
|
||||||
|
global.fetch = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.fetch = async () => {
|
||||||
|
throw new Error('network down');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await agent.get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.deepEqual(res.body, { error: 'directions unavailable' });
|
||||||
|
} finally {
|
||||||
|
global.fetch = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directions requires auth', async () => {
|
||||||
|
const noAuth = await request(app).get('/api/directions?from=18.7883,98.9853&to=18.8,99.0');
|
||||||
|
assert.equal(noAuth.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unknown /api route -> JSON 404
|
// Unknown /api route -> JSON 404
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user