Files
TREK/server/tests/unit/nest/trek-places.client.test.ts
T
Maurice ed18c37903 fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an
independent check against the code is fixed here, with a test for each.

Data and credentials
- A document sync connection edited to a new address no longer carries the
  stored token along: a blank form under another origin is refused, the same
  rule the probe route already had. Dawarich and AirTrail testConnection send
  the stored key only to the host it was stored for.
- The key rotation script now covers document_connections.secrets and
  trip_document_links.webhook_secret, which it had skipped.
- Turning a booked night into a pause in the road trip popup asks first and
  says that the booking and its expense go with it. Deleting a place says the
  same when a night is booked there, on the desktop and on the phone.
- A moved version marker over unchanged bytes no longer replaces the TREK file
  and its attachments; a real revision carries the attachments over.

Planner
- A drop in the day plan names the row it lands ahead of in the full day, so a
  hidden hotel stop no longer shifts it one slot early; the store sorts before
  it splices. Via re-anchoring, the booked-night guard and the sidebar's day
  membership read the stored day, the index space the server uses.
- A hotel stop placed mid-day re-pins the road trip vias of that day, and the
  booking session receives the new stop order too. The Public API and the iCal
  feed list a booked hotel once. A plugin can no longer write a stay against
  another trip's day.

Road trip and map
- The route overview paces its requests to the public routers and retries a
  rate limit, and it says which legs it could not route instead of quietly
  reporting a shorter trip. A click on a via handle no longer drops a second
  via; handles hide when zooming out. Read-only members cannot edit the driving
  limits. The phone shows a way back when no day is picked and prints the night
  drive departure in the chosen clock format.

Integrations and search
- Google transit keeps conventional trains when the train chip is on.
  TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and
  accepts the whole boolean family. A caller-supplied description no longer
  lands in the instance-wide enrichment cache. Nominatim searches carry a
  timeout. An OSM place id is checked for shape before it reaches Overpass.
- Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off
  knows what the Atlas already shows, and a host change with a new key clears
  the old instance's state.
- Document sync: a webhook address pasted by hand works on the token alone,
  deleting a connection unregisters its subscriptions, Nextcloud under a
  sub-path connects, Synology answers a name collision before uploading and
  verifies a move into the bin, Paperless titles are capped at 128 characters,
  a DNS failure reads as unreachable rather than as a policy verdict, videos
  from the store are accepted like uploads, and remote_missing has a way out.
  Conflict buttons only for the owner, a refused choice is reported, a refused
  credential gets a Reconnect button, disconnecting asks first on the desktop
  as it does on the phone, and the folder picker no longer spins forever.

Release
- The server holds the release notice back from a bundle that cannot draw it,
  so the previous shell served from the service worker cache after an update
  no longer shows bare keys and no longer lets a click use the notice up.
- Import into an existing list gets the same body limit as a new list. Chat
  images say when the limit is reached and the new controls are translated.
  The transit backend switch reports a failed save. The Helm chart carries the
  Amap credentials in its Secret; the shipped templates document the Places
  API and Amap variables and the Overpass default. The wiki follows.
2026-09-20 13:54:31 +02:00

632 lines
25 KiB
TypeScript

/**
* The TREK Places client: what it sends, what it refuses, and how it maps an
* answer onto the record shape the rest of TREK already reads.
*
* fetch is stubbed rather than hit: the point is the contract, and a unit test
* that depends on a live service tells you about the service.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mutable, so the instance URL and the operator's own index can be set per test.
const { env } = vi.hoisted(() => ({
env: {
maps: { trekPlacesUrl: '', trekPlacesEnabled: true },
app: { appUrl: 'https://trip.example.org', port: 3001 },
http: { allowedOriginsRaw: '' },
},
}));
// getAppUrl is the real resolution chain, reproduced here rather than stubbed
// to a constant: the instance token depends on the whole chain, and the case
// that matters is the one where APP_URL is unset — the shipped compose file
// leaves it commented out, so that is the default install, not an edge case.
vi.mock('../../../src/app-config', () => ({
readEnv: () => env,
getAppUrl: () => {
const strip = (v: string) => v.replace(/\/+$/, '');
if (env.app.appUrl) {
try { new URL(env.app.appUrl); return strip(env.app.appUrl); } catch { /* fall through */ }
}
const first = env.http.allowedOriginsRaw.split(',')[0]?.trim();
if (first) {
try { new URL(first); return strip(first); } catch { /* fall through */ }
}
return `http://localhost:${env.app.port}`;
},
}));
import {
DEFAULT_TREK_PLACES_URL,
POI_CATEGORY_TO_TREK,
toPlaceRecord,
trekPlacesArea,
trekPlacesBaseUrl,
trekPlacesById,
trekPlacesNearby,
trekPlacesSearch,
resetTrekPlacesBreaker,
type TrekPlace,
} from '../../../src/nest/maps/trek-places.client';
const PLACE: TrekPlace = {
gers: 'abc-123',
name: "L'Osteria",
lat: 54.0879,
lng: 12.1408,
category: 'restaurant',
categoryPath: 'eat_and_drink>restaurant',
confidence: 1,
address: { freeform: 'Steinstrasse 9', locality: 'Rostock', postcode: '18055', region: 'MV', country: 'DE' },
contact: { website: 'https://losteria.net/', phone: '+49381', email: 'r@losteria.de', socials: null },
brand: { name: "L'Osteria", wikidata: 'Q123' },
source: 'overture',
hours: null,
};
const BOX = { minLat: 54, minLng: 12, maxLat: 54.2, maxLng: 12.3 };
let calls: { url: string; init?: RequestInit }[] = [];
function stubFetch(body: unknown, status = 200) {
calls = [];
vi.stubGlobal('fetch', vi.fn(async (url: URL | string, init?: RequestInit) => {
calls.push({ url: String(url), init });
return {
ok: status >= 200 && status < 300,
status,
body: null,
text: async () => JSON.stringify(body),
} as unknown as Response;
}));
}
const encoder = new TextEncoder();
let streamCancelled = false;
// The same stub with a real body stream, so the capped reader runs instead of
// res.text(). text() throws here to prove which arm was taken.
function stubStreamingFetch(chunks: (string | Uint8Array)[], status = 200) {
calls = [];
streamCancelled = false;
const queue = chunks.map((c) => (typeof c === 'string' ? encoder.encode(c) : c));
vi.stubGlobal('fetch', vi.fn(async (url: URL | string, init?: RequestInit) => {
calls.push({ url: String(url), init });
return {
ok: status >= 200 && status < 300,
status,
body: new ReadableStream<Uint8Array>({
pull(controller) {
const next = queue.shift();
if (next) controller.enqueue(next);
else controller.close();
},
cancel() { streamCancelled = true; },
}),
text: async () => { throw new Error('read the stream, not text()'); },
} as unknown as Response;
}));
}
beforeEach(() => {
calls = [];
// Process state, so it leaks between cases unless it is cleared.
resetTrekPlacesBreaker();
env.maps.trekPlacesUrl = '';
env.maps.trekPlacesEnabled = true;
env.app.appUrl = 'https://trip.example.org';
env.app.port = 3001;
env.http.allowedOriginsRaw = '';
// Nothing in this file may reach the real service: a test that forgets to
// stub gets a thrown request rather than a request.
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('unstubbed fetch'); }));
});
afterEach(() => vi.unstubAllGlobals());
describe('trekPlacesBaseUrl', () => {
it('falls back to the public instance when nothing is configured', () => {
expect(trekPlacesBaseUrl()).toBe(DEFAULT_TREK_PLACES_URL);
});
it('uses the operator own copy when one is configured', () => {
env.maps.trekPlacesUrl = 'https://places.example.net';
expect(trekPlacesBaseUrl()).toBe('https://places.example.net');
});
it('strips trailing slashes rather than doubling them into the path', () => {
env.maps.trekPlacesUrl = 'https://places.example.net//';
expect(trekPlacesBaseUrl()).toBe('https://places.example.net');
});
it('treats a blank setting as unconfigured', () => {
env.maps.trekPlacesUrl = ' ';
expect(trekPlacesBaseUrl()).toBe(DEFAULT_TREK_PLACES_URL);
});
it('sends the request to the configured host, not the public one', async () => {
env.maps.trekPlacesUrl = 'https://places.example.net/';
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
const url = new URL(calls[0].url);
expect(url.origin).toBe('https://places.example.net');
expect(url.pathname).toBe('/v1/search');
});
});
describe('trekPlacesSearch', () => {
it('sends the query, the bias and a limit', async () => {
stubFetch({ query: 'x', results: [PLACE] });
const found = await trekPlacesSearch('losteria', { lat: 54.1, lng: 12.1, limit: 5 });
expect(found).toHaveLength(1);
const url = new URL(calls[0].url);
expect(url.pathname).toBe('/v1/search');
expect(url.searchParams.get('q')).toBe('losteria');
expect(url.searchParams.get('lat')).toBe('54.1');
expect(url.searchParams.get('limit')).toBe('5');
});
it('identifies the instance without identifying a person', async () => {
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
const headers = calls[0].init?.headers as Record<string, string>;
const token = headers['X-TREK-Instance'];
// Stable, opaque, and derived from the instance URL rather than a user or
// an install id: it exists so one caller cannot exhaust everyone's limit.
expect(token).toMatch(/^trek-[a-z0-9]+$/);
expect(token).not.toContain('example.org');
});
it('names one instance the same way twice, and another instance differently', async () => {
const token = async () => {
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
return (calls[0].init?.headers as Record<string, string>)['X-TREK-Instance'];
};
const configured = await token();
// Derived from the URL, not generated: a token that changed per call would
// hand the same instance a fresh rate-limit budget on every request.
expect(await token()).toBe(configured);
env.app.appUrl = '';
const unconfigured = await token();
expect(unconfigured).toMatch(/^trek-[a-z0-9]+$/);
// Two instances the far side must be able to tell apart for its rate limit.
expect(unconfigured).not.toBe(configured);
});
it('tells two unconfigured instances apart, which is the default install', async () => {
// APP_URL is commented out in the shipped compose file, so "unset" is the
// ordinary case rather than the odd one. Reading it raw gave every such
// install the same token, and a token every caller shares is not a caller:
// one instance running a large import would rate-limit all the others,
// who would fall back to Nominatim with nothing to point at.
const token = async () => {
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
return (calls[0].init?.headers as Record<string, string>)['X-TREK-Instance'];
};
env.app.appUrl = '';
env.http.allowedOriginsRaw = 'https://a.example.net';
const viaOrigins = await token();
env.http.allowedOriginsRaw = 'https://b.example.net';
expect(await token()).not.toBe(viaOrigins);
// And with nothing at all, the port still separates two instances on one host.
env.http.allowedOriginsRaw = '';
env.app.port = 3001;
const onDefaultPort = await token();
env.app.port = 8080;
expect(await token()).not.toBe(onDefaultPort);
});
it('omits parameters that were not given rather than sending empties', async () => {
stubFetch({ query: 'x', results: [] });
await trekPlacesSearch('x');
const url = new URL(calls[0].url);
expect(url.searchParams.has('lat')).toBe(false);
expect(url.searchParams.has('lng')).toBe(false);
});
it('throws on an error status so the caller can fall back', async () => {
stubFetch({}, 503);
await expect(trekPlacesSearch('x')).rejects.toThrow(/503/);
});
it('survives a malformed results field', async () => {
stubFetch({ query: 'x', results: 'not an array' });
await expect(trekPlacesSearch('x')).resolves.toEqual([]);
});
});
describe('reading the body', () => {
// One buffer, handed out more than once: two of them are already over the cap.
const BIG = new Uint8Array(600_000);
it('assembles an answer that arrives in pieces', async () => {
const payload = JSON.stringify({ query: 'x', results: [{ ...PLACE, name: 'Café Kröpeliner' }] });
const bytes = encoder.encode(payload);
// Cut between the two bytes of the é: decoding chunk by chunk would leave a
// replacement character where the name is.
const cut = bytes.indexOf(0xc3) + 1;
stubStreamingFetch([bytes.slice(0, cut), bytes.slice(cut)]);
const found = await trekPlacesSearch('cafe');
expect(found[0].name).toBe('Café Kröpeliner');
});
it('refuses a body past the megabyte cap instead of buffering it', async () => {
// The first chunk is under the cap, the second takes it over.
stubStreamingFetch([BIG, BIG]);
await expect(trekPlacesSearch('x')).rejects.toThrow('TREK Places API response too large');
});
it('drops the connection instead of draining the rest of an oversized body', async () => {
// A third chunk nobody reads: the cap trips while the body is still running,
// which is the case that has a connection left to drop. Cancelling a stream
// that already ended is a no-op and would assert nothing.
stubStreamingFetch([BIG, BIG, BIG]);
await expect(trekPlacesSearch('x')).rejects.toThrow('TREK Places API response too large');
expect(streamCancelled).toBe(true);
});
it('gives the area download a budget that follows the rows it asked for', async () => {
// The search cap is a megabyte because a search answer is a few kB. The area
// download is the one call that is neither: it asks for thousands of rows at
// once for the offline cache, and a city-sized answer sits right on that
// megabyte. Holding it to the search budget threw, the caller could only
// report "index unavailable", and the trip went offline with nothing cached.
// A real answer, just a big one: 1200 rows of a padded name puts the body
// comfortably past the megabyte the search path would refuse.
const pad = 'x'.repeat(1000);
const rows = Array.from({ length: 1200 }, (_, i) => ({ ...PLACE, gers: `row-${i}`, name: pad }));
const payload = JSON.stringify({ count: rows.length, truncated: false, results: rows });
expect(payload.length).toBeGreaterThan(1_000_000);
stubStreamingFetch([encoder.encode(payload)]);
const area = await trekPlacesArea(BOX, 3000);
expect(area.results).toHaveLength(1200);
expect(streamCancelled).toBe(false);
});
it('still caps the area download, at the budget its own limit buys', async () => {
// Not unbounded — a hostile or broken upstream must not be able to hand the
// instance an endless body just because the path is the bulk one.
stubStreamingFetch(Array.from({ length: 8 }, () => BIG));
await expect(trekPlacesArea(BOX, 1)).rejects.toThrow('TREK Places API response too large');
expect(streamCancelled).toBe(true);
});
});
describe('the circuit breaker', () => {
const BIG_BODY = new Uint8Array(600_000);
// Every call site here sits in front of something a person is waiting for and
// falls back to the old path when the index fails — but it pays the timeout
// first, every time. On an instance that cannot reach the service at all,
// that is 3.5 s per keystroke of autocomplete, and over a hundred seconds for
// a ten-venue booking import.
it('stops dialling out after a few consecutive failures', async () => {
stubFetch({}, 503);
for (let i = 0; i < 4; i++) {
await expect(trekPlacesSearch('x')).rejects.toThrow();
}
const dialled = calls.length;
// The next call must not reach the network at all.
await expect(trekPlacesSearch('x')).rejects.toThrow('unreachable, not retrying yet');
expect(calls.length).toBe(dialled);
});
it('tries again once the cooldown is over', async () => {
vi.useFakeTimers();
try {
stubFetch({}, 503);
for (let i = 0; i < 4; i++) await expect(trekPlacesSearch('x')).rejects.toThrow();
const dialled = calls.length;
await expect(trekPlacesSearch('x')).rejects.toThrow('unreachable');
expect(calls.length).toBe(dialled);
vi.advanceTimersByTime(60_000);
stubFetch({ query: 'x', results: [PLACE] });
await expect(trekPlacesSearch('x')).resolves.toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it('an answer clears the count, so scattered failures never open it', async () => {
for (let i = 0; i < 20; i++) {
stubFetch({}, 503);
await expect(trekPlacesSearch('x')).rejects.toThrow(/503/);
stubFetch({ query: 'x', results: [] });
await expect(trekPlacesSearch('x')).resolves.toEqual([]);
}
// Still dialling: the failures never ran consecutively.
stubFetch({}, 503);
await expect(trekPlacesSearch('x')).rejects.toThrow(/503/);
});
it('a 404 is an answer, not an outage', async () => {
// "No such place" says the service is there and working. Counting it would
// open the breaker for a caller who asked about places that do not exist.
for (let i = 0; i < 10; i++) {
stubFetch({}, 404);
await expect(trekPlacesById('nope')).resolves.toBeNull();
}
stubFetch({ place: PLACE });
await expect(trekPlacesById('abc-123')).resolves.toMatchObject({ gers: 'abc-123' });
});
it('an oversized body is an answer too', async () => {
// The service replied, and replied too much. That is a bug upstream, not a
// reason to stop asking — and the caller already falls back for this one.
for (let i = 0; i < 10; i++) {
stubStreamingFetch([BIG_BODY, BIG_BODY]);
await expect(trekPlacesSearch('x')).rejects.toThrow('too large');
}
stubFetch({ query: 'x', results: [PLACE] });
await expect(trekPlacesSearch('x')).resolves.toHaveLength(1);
});
});
describe('getJson request shape', () => {
it('carries an abort signal on every call, and aborts when the answer never comes', async () => {
// The repo rule is that no outbound fetch runs without a timeout. Nothing
// asserted the signal before, so removing it would not have failed a test.
vi.useFakeTimers();
try {
calls = [];
vi.stubGlobal('fetch', vi.fn((url: URL | string, init?: RequestInit) => {
calls.push({ url: String(url), init });
// Answers only when the caller gives up.
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
}) as Promise<Response>;
}));
const pending = trekPlacesSearch('never answered');
const failed = expect(pending).rejects.toThrow('aborted');
expect(calls[0].init?.signal).toBeInstanceOf(AbortSignal);
expect(calls[0].init?.signal?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(3500);
expect(calls[0].init?.signal?.aborted).toBe(true);
await failed;
} finally {
vi.useRealTimers();
}
});
it('lets the area download run longer than a keystroke would', async () => {
// The search timeout sits in front of a keystroke. The offline prefetch is a
// background bulk download that nobody is watching, and 3.5 s is not enough
// to move thousands of rows — it would abort mid-body and cache nothing.
vi.useFakeTimers();
try {
calls = [];
vi.stubGlobal('fetch', vi.fn((url: URL | string, init?: RequestInit) => {
calls.push({ url: String(url), init });
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
}) as Promise<Response>;
}));
const pending = trekPlacesArea(BOX, 3000);
const failed = expect(pending).rejects.toThrow('aborted');
await vi.advanceTimersByTimeAsync(3500);
expect(calls[0].init?.signal?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(20_000);
expect(calls[0].init?.signal?.aborted).toBe(true);
await failed;
} finally {
vi.useRealTimers();
}
});
});
describe('trekPlacesById', () => {
it('returns null for a place that does not exist instead of throwing', async () => {
stubFetch({}, 404);
await expect(trekPlacesById('nope')).resolves.toBeNull();
});
it('lets other failures through', async () => {
stubFetch({}, 500);
await expect(trekPlacesById('x')).rejects.toThrow();
});
it('escapes the id into the path', async () => {
stubFetch({ place: PLACE });
await trekPlacesById('a/b c');
expect(calls[0].url).toContain('/v1/place/a%2Fb%20c');
});
});
describe('the switch', () => {
it('lets nothing leave once the operator turned the index off, a lookup by id included', async () => {
// TREK_PLACES_ENABLED=false is a promise about egress. The service checks
// it before each call it makes; this is the one place every request passes,
// so a caller that did not is stopped here rather than at the firewall.
env.maps.trekPlacesEnabled = false;
stubFetch({ place: PLACE });
await expect(trekPlacesById('abc-123')).rejects.toThrow('switched off');
await expect(trekPlacesSearch('osteria')).rejects.toThrow('switched off');
await expect(trekPlacesNearby(54, 12)).rejects.toThrow('switched off');
await expect(trekPlacesArea(BOX)).rejects.toThrow('switched off');
expect(calls).toEqual([]);
});
it('is not an outage: the refusal never opens the breaker', async () => {
env.maps.trekPlacesEnabled = false;
for (let i = 0; i < 6; i++) await trekPlacesById('abc-123').catch(() => null);
env.maps.trekPlacesEnabled = true;
stubFetch({ place: PLACE });
await expect(trekPlacesById('abc-123')).resolves.toEqual(PLACE);
expect(calls).toHaveLength(1);
});
});
describe('trekPlacesNearby', () => {
it('rounds the radius and passes the category list through', async () => {
stubFetch({ results: [] });
await trekPlacesNearby(54.1, 12.1, { radius: 812.7, category: 'cafe,coffee_shop' });
const url = new URL(calls[0].url);
expect(url.searchParams.get('radius')).toBe('813');
expect(url.searchParams.get('category')).toBe('cafe,coffee_shop');
});
it('asks 1500m and 50 rows when the caller says nothing', async () => {
stubFetch({ results: [] });
await trekPlacesNearby(54.1, 12.1);
const url = new URL(calls[0].url);
expect(url.pathname).toBe('/v1/nearby');
expect(url.searchParams.get('radius')).toBe('1500');
expect(url.searchParams.get('limit')).toBe('50');
expect(url.searchParams.has('category')).toBe(false);
});
it('lets the caller ask for a smaller circle and fewer rows', async () => {
stubFetch({ results: [] });
await trekPlacesNearby(54.1, 12.1, { radius: 300, limit: 5 });
const url = new URL(calls[0].url);
expect(url.searchParams.get('radius')).toBe('300');
expect(url.searchParams.get('limit')).toBe('5');
});
it('passes the distance the index measured through', async () => {
stubFetch({ results: [{ ...PLACE, distanceMetres: 12 }] });
const out = await trekPlacesNearby(54.1, 12.1);
expect(out).toHaveLength(1);
expect(out[0].distanceMetres).toBe(12);
});
it('survives a malformed results field', async () => {
stubFetch({ results: { 0: PLACE } });
await expect(trekPlacesNearby(54.1, 12.1)).resolves.toEqual([]);
});
});
describe('trekPlacesArea', () => {
it('sends the whole box and caps the rows at 2000', async () => {
stubFetch({ count: 1, truncated: false, results: [PLACE] });
await trekPlacesArea(BOX);
const url = new URL(calls[0].url);
expect(url.pathname).toBe('/v1/bbox');
expect(url.searchParams.get('minLat')).toBe('54');
expect(url.searchParams.get('minLng')).toBe('12');
expect(url.searchParams.get('maxLat')).toBe('54.2');
expect(url.searchParams.get('maxLng')).toBe('12.3');
expect(url.searchParams.get('limit')).toBe('2000');
});
it('sends the caller own cap when one is given', async () => {
stubFetch({ count: 0, truncated: false, results: [] });
await trekPlacesArea(BOX, 500);
expect(new URL(calls[0].url).searchParams.get('limit')).toBe('500');
});
it('keeps an edge on the equator instead of dropping it', async () => {
stubFetch({ count: 0, truncated: false, results: [] });
await trekPlacesArea({ ...BOX, minLat: 0 });
// 0 is a coordinate, not a missing value; dropping it would widen the box.
expect(new URL(calls[0].url).searchParams.get('minLat')).toBe('0');
});
it('passes truncation through rather than smoothing it over', async () => {
stubFetch({ count: 20000, truncated: true, results: [PLACE] });
const area = await trekPlacesArea(BOX);
// A client that believed it had the whole area would search offline and
// quietly miss places.
expect(area.truncated).toBe(true);
expect(area.count).toBe(20000);
expect(area.results).toHaveLength(1);
});
it('reads a count that arrived as a string, and a truthy truncated flag', async () => {
stubFetch({ count: '17', truncated: 1, results: [] });
const area = await trekPlacesArea(BOX);
expect(area.count).toBe(17);
expect(area.truncated).toBe(true);
});
it('turns a malformed answer into an empty one rather than passing it on', async () => {
stubFetch({ count: 'lots', truncated: undefined, results: null });
await expect(trekPlacesArea(BOX)).resolves.toEqual({ count: 0, truncated: false, results: [] });
});
it('throws on an error status so the sync can leave the area alone', async () => {
stubFetch({}, 502);
await expect(trekPlacesArea(BOX)).rejects.toThrow(/502/);
});
});
describe('toPlaceRecord', () => {
it('produces the same shape the other providers do', () => {
const r = toPlaceRecord(PLACE);
expect(r).toMatchObject({
google_place_id: null,
osm_id: 'gers:abc-123',
name: "L'Osteria",
address: 'Steinstrasse 9, 18055, Rostock',
lat: 54.0879,
website: 'https://losteria.net/',
phone: '+49381',
source: 'trek-places',
});
});
it('files the chain under brand:wikidata, never under the place own identity', () => {
// `wikidata` means "the article about this place". Putting the chain's item
// there is how a branch ends up illustrated with the company logo and
// described as the franchise operator, which is the whole reason
// readWikiIdentity refuses to follow brand:* tags.
const r = toPlaceRecord(PLACE);
expect(r['brand:wikidata']).toBe('Q123');
expect(r.wikidata).toBeUndefined();
expect(r.brand).toBe("L'Osteria");
});
it('reports no rating rather than a made-up one', () => {
// Ratings exist in no open dataset. Null says so; 0 would be a claim.
expect(toPlaceRecord(PLACE).rating).toBeNull();
});
it('keeps a place on the null island rather than dropping its coordinate', () => {
const r = toPlaceRecord({ ...PLACE, lat: 0, lng: 0 });
expect(r.lat).toBe(0);
expect(r.lng).toBe(0);
});
it('leaves the coordinate null when it is not a number', () => {
const r = toPlaceRecord({ ...PLACE, lat: Number.NaN, lng: Number.NaN });
expect(r.lat).toBeNull();
});
it('builds an address from whatever parts exist', () => {
const r = toPlaceRecord({
...PLACE,
address: { freeform: null, locality: 'Rostock', postcode: null, region: null, country: 'DE' },
});
expect(r.address).toBe('Rostock');
});
});
describe('POI_CATEGORY_TO_TREK', () => {
it('covers every pill the planner offers', () => {
// The keys are the contract with the client's POI_CATEGORIES. A missing one
// silently drops that pill back to Overpass, which is the slow path — and
// for the four a drive needs that is the worst path of all, because the
// corridor search asks for them sixteen boxes at a time.
expect(Object.keys(POI_CATEGORY_TO_TREK).sort()).toEqual([
'activity', 'bar', 'cafe', 'campsite', 'charging', 'fuel', 'hotel', 'museum',
'nature', 'rest_area', 'restaurant', 'shopping', 'sights', 'supermarket',
]);
});
it('gives every pill at least one category to match on', () => {
for (const [pill, terms] of Object.entries(POI_CATEGORY_TO_TREK)) {
expect(terms.length, pill).toBeGreaterThan(0);
}
});
});