mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fc631d36b6
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
798 lines
30 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|