Files
TREK/server/tests/unit/nest/trek-places.client.test.ts
T
Maurice fc631d36b6 fix(maps): category pills ask the index by Overture path, near misses are left out
The index matches a term anywhere inside a place's category text, so the
bare names behind the pills caught whatever was spelled like them: "park"
brought car parks and an amusement park, "garden" furniture stores and beer
gardens, "bar" barbers, "pub" publishers and public squares, "market"
marketing agencies. In Vienna four of sixty "Nature & parks" pins were
parks. Every pill now names its Overture categories by full path, a place
counts only when its path is that path or lies under it, and a page of
near misses only falls through to Overpass. Terms Overture no longer has
(nightclub, nature_preserve, shopping_center ...) got their current names.

Closes #2663
2026-10-18 13:21:52 +02:00

798 lines
30 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 {
DEFAULT_TREK_PLACES_URL,
POI_CATEGORY_TO_TREK,
indexRowTerm,
toPlaceRecord,
trekPlacesArea,
trekPlacesBaseUrl,
trekPlacesById,
trekPlacesNearby,
trekPlacesSearch,
resetTrekPlacesBreaker,
serviceLang,
type TrekPlace,
} from '../../../src/nest/maps/trek-places.client';
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}`;
},
}));
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('the reader language (#2592)', () => {
it('sends TREK codes as the tags the service reads, and nothing it would refuse', () => {
expect(serviceLang('de')).toBe('de');
// Brazilian Portuguese and Greek have codes of their own in TREK.
expect(serviceLang('br')).toBe('pt-BR');
expect(serviceLang('gr')).toBe('el');
// Passed as it is: the service maps a region onto the script OSM keys by.
expect(serviceLang('zh-TW')).toBe('zh-TW');
expect(serviceLang(undefined)).toBeUndefined();
expect(serviceLang(' ')).toBeUndefined();
expect(serviceLang('not a locale!')).toBeUndefined();
});
it('asks search, nearby and the place lookup in it', async () => {
stubFetch({ query: 'x', results: [], place: PLACE });
await trekPlacesSearch('x', { lang: 'br' });
await trekPlacesNearby(54.1, 12.1, { lang: 'gr' });
await trekPlacesById('abc-123', { lang: 'ja' });
expect(calls.map((c) => new URL(c.url).searchParams.get('lang'))).toEqual(['pt-BR', 'el', 'ja']);
});
it('leaves the parameter off without a language, so the answer is the one it always was', async () => {
stubFetch({ query: 'x', results: [], place: PLACE, count: 0 });
await trekPlacesSearch('x');
await trekPlacesNearby(54.1, 12.1);
await trekPlacesById('abc-123');
await trekPlacesSearch('x', { lang: 'not a locale!' });
await trekPlacesArea(BOX);
expect(calls.every((c) => !new URL(c.url).searchParams.has('lang'))).toBe(true);
});
it('keeps a layer row locality only when it is a name', async () => {
const osm = (locality: unknown) => ({
id: 'osm:node/1',
osm_type: 'node',
osm_id: 1,
name: 'Kirby Cafe',
local_name: 'カービィカフェ',
lat: 35.71,
lng: 139.81,
category: null,
source: 'openstreetmap',
locality,
});
stubFetch({ query: 'x', results: [osm(' Sumida '), osm(''), osm(42), osm(null), { ...PLACE }] });
const found = await trekPlacesSearch('x', { lang: 'en', sources: 'index,osm' });
expect(found.map((h) => ('locality' in h ? h.locality : 'index row'))).toEqual([
'Sumida',
null,
null,
null,
'index row',
]);
// A row the service sent without the field, which is every row asked
// without a language, is left without it.
stubFetch({ query: 'x', results: [{ ...osm(null), locality: undefined }] });
expect((await trekPlacesSearch('x'))[0]).not.toHaveProperty('locality');
});
});
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();
});
// #2483: Overture keeps a website the way the operator typed it.
it('TREK-PLACES-2483-01: a website without a scheme gains https, one that is no website becomes null', () => {
const withSite = (website: string | null) =>
toPlaceRecord({ ...PLACE, contact: { ...PLACE.contact, website } }).website;
expect(withSite('fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët')).toBe(
'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët',
);
expect(withSite('javascript:alert(1)')).toBeNull();
expect(withSite(null)).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);
}
});
it('names every category by its full path, so no bare word matches inside another (#2663)', () => {
// A bare `park` is inside `parking`, `bar` inside `barber`, `market` inside
// `marketing_agency`. Only the top-level `shopping` stands alone, and it is
// a whole branch on purpose.
for (const [pill, terms] of Object.entries(POI_CATEGORY_TO_TREK)) {
for (const term of terms) {
if (term === 'shopping' || term === 'lodging') continue;
expect(term, pill).toContain('>');
}
}
});
});
describe('indexRowTerm (#2663)', () => {
const nature = POI_CATEGORY_TO_TREK.nature;
it('takes a place whose path is the term or lies under it', () => {
expect(indexRowTerm(nature, 'park', 'sports_and_recreation>park')).toBe('sports_and_recreation>park');
expect(indexRowTerm(nature, 'national_park', 'sports_and_recreation>park>national_park')).toBe(
'sports_and_recreation>park',
);
});
it('leaves out a place whose path only contains the word', () => {
expect(indexRowTerm(nature, 'parking', 'travel_and_transportation>parking')).toBeUndefined();
expect(indexRowTerm(nature, 'beer_garden', 'food_and_drink>alcoholic_beverage_venue>beer_garden')).toBeUndefined();
expect(
indexRowTerm(
nature,
'furniture_store',
'shopping>specialty_store>hardware_home_and_garden_store>furniture_store',
),
).toBeUndefined();
});
it('judges a place without a path by its leaf', () => {
expect(indexRowTerm(nature, 'beach', null)).toBe('geographic_entities>land_feature>beach');
expect(indexRowTerm(nature, 'beach_bar', null)).toBeUndefined();
expect(indexRowTerm(nature, null, null)).toBeUndefined();
});
it('picks the most specific term when several fit', () => {
const terms = [...POI_CATEGORY_TO_TREK.nature, ...POI_CATEGORY_TO_TREK.activity];
expect(indexRowTerm(terms, 'water_park', 'sports_and_recreation>park>water_park')).toBe(
'sports_and_recreation>park>water_park',
);
});
});