mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
1122 lines
45 KiB
TypeScript
1122 lines
45 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,
|
|
trekPlacesHighlights,
|
|
trekPlacesMiss,
|
|
trekPlacesNearby,
|
|
trekPlacesRoutes,
|
|
trekPlacesSearch,
|
|
trekPlacesWays,
|
|
resetTrekPlacesBreaker,
|
|
serviceLang,
|
|
type TrekPlace,
|
|
} from '../../../src/nest/maps/trek-places.client';
|
|
import { oneByOne } from '../../helpers/one-by-one';
|
|
|
|
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('trekPlacesHighlights', () => {
|
|
const SQUARE = {
|
|
osmType: 'way',
|
|
osmId: 4_567,
|
|
name: 'Piazza San Marco',
|
|
lat: 45.4341,
|
|
lng: 12.3388,
|
|
category: 'sehenswuerdigkeit',
|
|
osmTag: 'place=square',
|
|
wikidata: 'Q190092',
|
|
qrank: 912_345,
|
|
distanceMetres: 42,
|
|
};
|
|
|
|
it('TREK-PLACES-HL-01: asks /v1/highlights around a point in the reader language and reads its rows', async () => {
|
|
stubFetch({ highlights: [SQUARE], qrankRelease: '2026-10-01' });
|
|
const out = await trekPlacesHighlights(45.434, 12.338, { radius: 812.6, limit: 12, lang: 'de' });
|
|
const url = new URL(calls[0].url);
|
|
expect(url.pathname).toBe('/v1/highlights');
|
|
expect(url.searchParams.get('lat')).toBe('45.434');
|
|
expect(url.searchParams.get('lng')).toBe('12.338');
|
|
expect(url.searchParams.get('radius')).toBe('813');
|
|
expect(url.searchParams.get('limit')).toBe('12');
|
|
expect(url.searchParams.get('lang')).toBe('de');
|
|
expect(out).toEqual({ highlights: [SQUARE], qrankRelease: '2026-10-01' });
|
|
});
|
|
|
|
it('TREK-PLACES-HL-02: keeps every request inside the bounds the service takes, and asks nothing for no coordinate', async () => {
|
|
stubFetch({ highlights: [] });
|
|
await trekPlacesHighlights(45.4, 12.3);
|
|
await trekPlacesHighlights(45.4, 12.3, { radius: 1, limit: 0 });
|
|
await trekPlacesHighlights(45.4, 12.3, { radius: 90_000, limit: 500 });
|
|
await trekPlacesHighlights(45.4, 12.3, { radius: Number.NaN, limit: Number.POSITIVE_INFINITY });
|
|
const asked = calls.map((call) => new URL(call.url).searchParams);
|
|
expect(asked.map((params) => [params.get('radius'), params.get('limit')])).toEqual([
|
|
['1000', '30'],
|
|
['10', '1'],
|
|
['20000', '100'],
|
|
['10', '1'],
|
|
]);
|
|
expect(asked[0]?.has('lang')).toBe(false);
|
|
await expect(trekPlacesHighlights(Number.NaN, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
|
|
await expect(trekPlacesHighlights(91, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
|
|
expect(calls).toHaveLength(4);
|
|
});
|
|
|
|
it('TREK-PLACES-HL-03: drops a row of another shape on its own, and reads an old layer without ranks as none', async () => {
|
|
stubFetch({
|
|
highlights: [SQUARE, { ...SQUARE, osmId: 5, wikidata: 'L12' }, { ...SQUARE, osmId: 6, qrank: -1 }, 'row'],
|
|
qrankRelease: 20261001,
|
|
});
|
|
expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [SQUARE], qrankRelease: null });
|
|
stubFetch({ highlights: { 0: SQUARE } });
|
|
expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [], qrankRelease: null });
|
|
stubFetch(null);
|
|
expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [], qrankRelease: null });
|
|
});
|
|
|
|
it('TREK-PLACES-HL-04: throws on an error status and when the index is switched off, so the caller goes on without', async () => {
|
|
stubFetch({}, 503);
|
|
await expect(trekPlacesHighlights(45.4, 12.3)).rejects.toThrow('TREK Places API 503');
|
|
env.maps.trekPlacesEnabled = false;
|
|
await expect(trekPlacesHighlights(45.4, 12.3)).rejects.toThrow('switched off');
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it('TREK-PLACES-HL-05: a service older than the endpoint answers 404, read as no highlights', async () => {
|
|
stubFetch({}, 404);
|
|
await expect(trekPlacesHighlights(45.4, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
|
|
});
|
|
});
|
|
|
|
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, and the kinds of the search along a tour it holds', () => {
|
|
// 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. Museums and
|
|
// galleries, park, market and peak are asked along a tour (PLN-02,
|
|
// CW-05), each path checked against a live answer of the index.
|
|
expect(Object.keys(POI_CATEGORY_TO_TREK).sort()).toEqual([
|
|
'activity',
|
|
'bar',
|
|
'cafe',
|
|
'campsite',
|
|
'charging',
|
|
'fuel',
|
|
'hotel',
|
|
'market',
|
|
'museum',
|
|
'museum_gallery',
|
|
'nature',
|
|
'park',
|
|
'peak',
|
|
'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',
|
|
);
|
|
});
|
|
});
|
|
|
|
// TREK-PLACES-WAYS-01 to 06, TREK-PLACES-ROUTES-01 to 05, TREK-PLACES-MISS-01, TREK-PLACES-BREAKER-01 to 03
|
|
|
|
/** A fetch answering each call with the next of `answers`, the last one again once they run out. */
|
|
function stubFetchSequence(answers: { body: unknown; status?: number }[]) {
|
|
calls = [];
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (url: URL | string, init?: RequestInit) => {
|
|
calls.push({ url: String(url), init });
|
|
const next = answers[Math.min(calls.length, answers.length) - 1];
|
|
const status = next?.status ?? 200;
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
body: null,
|
|
text: async () => JSON.stringify(next?.body),
|
|
} as unknown as Response;
|
|
}),
|
|
);
|
|
}
|
|
|
|
const askedIds = (call: { url: string }) => new URL(call.url).searchParams.get('ids');
|
|
|
|
describe('trekPlacesWays', () => {
|
|
it('TREK-PLACES-WAYS-01: asks /v1/ways for the ids and reads the ways in Overpass form', async () => {
|
|
stubFetch({
|
|
elements: [
|
|
{ type: 'way', id: 11, tags: { 'mtb:scale': '2', highway: 'path' } },
|
|
{ type: 'way', id: 12 },
|
|
],
|
|
missing: [13],
|
|
keys: ['highway', 'mtb:scale'],
|
|
});
|
|
const answer = await trekPlacesWays([11, 12, 13]);
|
|
const url = new URL(calls[0].url);
|
|
expect(url.pathname).toBe('/v1/ways');
|
|
expect(url.searchParams.get('ids')).toBe('11,12,13');
|
|
expect((calls[0].init?.headers as Record<string, string>)['X-TREK-Instance']).toMatch(/^trek-/);
|
|
expect(calls[0].init?.signal).toBeInstanceOf(AbortSignal);
|
|
// A way without tags reads as one without tags, never as a malformed row.
|
|
expect(answer).toEqual({
|
|
complete: true,
|
|
ways: [
|
|
{ type: 'way', id: 11, tags: { 'mtb:scale': '2', highway: 'path' } },
|
|
{ type: 'way', id: 12, tags: {} },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('TREK-PLACES-WAYS-02: asks in batches of 500, one after another, each id once, and nothing for no id', async () => {
|
|
stubFetch({ elements: [] });
|
|
const ids = Array.from({ length: 1200 }, (_, index) => index + 1);
|
|
await trekPlacesWays([...ids, 1, 2, 0, -3, 1.5, Number.NaN]);
|
|
expect(calls.map((call) => askedIds(call)?.split(',').length)).toEqual([500, 500, 200]);
|
|
expect(askedIds(calls[0])?.startsWith('1,2,3,')).toBe(true);
|
|
expect(askedIds(calls[2])?.endsWith(',1200')).toBe(true);
|
|
calls = [];
|
|
await expect(trekPlacesWays([])).resolves.toEqual({ complete: true, ways: [] });
|
|
await expect(trekPlacesWays([0, -1])).resolves.toEqual({ complete: true, ways: [] });
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it('TREK-PLACES-WAYS-03: drops a row of another shape or for a way not asked, keeping the others', async () => {
|
|
stubFetch({
|
|
elements: [
|
|
{ type: 'way', id: 1, tags: { surface: 'gravel' } },
|
|
{ type: 'node', id: 2, tags: {} },
|
|
{ type: 'way', id: 3, tags: { surface: 4 } },
|
|
{ type: 'way', id: 99, tags: { surface: 'asphalt' } },
|
|
'row',
|
|
],
|
|
});
|
|
expect((await trekPlacesWays([1, 2, 3])).ways).toEqual([{ type: 'way', id: 1, tags: { surface: 'gravel' } }]);
|
|
});
|
|
|
|
it('TREK-PLACES-WAYS-04: an answer without elements fails rather than reading as ways without tags', async () => {
|
|
stubFetch({ ways: [] });
|
|
await expect(trekPlacesWays([1])).rejects.toThrow('without elements');
|
|
stubFetch(null);
|
|
await expect(trekPlacesWays([1])).rejects.toThrow('without elements');
|
|
});
|
|
|
|
it('TREK-PLACES-WAYS-05: a later batch that fails stops there and names the ids still open', async () => {
|
|
stubFetchSequence([
|
|
{ body: { elements: [{ type: 'way', id: 2, tags: { surface: 'gravel' } }] } },
|
|
{ body: { detail: 'busy' }, status: 503 },
|
|
{ body: { elements: [] } },
|
|
]);
|
|
const ids = Array.from({ length: 1400 }, (_, index) => index + 1);
|
|
const answer = await trekPlacesWays(ids);
|
|
expect(calls).toHaveLength(2);
|
|
if (answer.complete !== false) throw new Error('expected an answer that stopped part way');
|
|
expect(answer.ways).toEqual([{ type: 'way', id: 2, tags: { surface: 'gravel' } }]);
|
|
expect(answer.unanswered).toEqual(ids.slice(500));
|
|
expect(trekPlacesMiss(answer.failure)).toBe('failed');
|
|
});
|
|
|
|
it('TREK-PLACES-WAYS-06: a first batch that fails fails the call and asks no further, and the off switch asks nothing', async () => {
|
|
stubFetchSequence([{ body: {}, status: 503 }, { body: { elements: [] } }]);
|
|
const ids = Array.from({ length: 1400 }, (_, index) => index + 1);
|
|
const failure = await trekPlacesWays(ids).catch((err: unknown) => err);
|
|
expect(trekPlacesMiss(failure)).toBe('failed');
|
|
expect(calls).toHaveLength(1);
|
|
stubFetch({}, 404);
|
|
expect(trekPlacesMiss(await trekPlacesWays([1]).catch((err: unknown) => err))).toBe('not_built');
|
|
env.maps.trekPlacesEnabled = false;
|
|
calls = [];
|
|
expect(trekPlacesMiss(await trekPlacesWays([1]).catch((err: unknown) => err))).toBe('off');
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('trekPlacesRoutes', () => {
|
|
const VIEW = { south: 47.1, west: 11.2, north: 47.2, east: 11.3 };
|
|
const CAPS = { relations: 60, ways: 4000 };
|
|
const RELATION = {
|
|
type: 'relation',
|
|
id: 7,
|
|
tags: { route: 'horse', name: 'Reitweg' },
|
|
members: [
|
|
{ type: 'way', ref: 1, role: '' },
|
|
{ type: 'node', ref: 5, role: 'guidepost' },
|
|
],
|
|
};
|
|
const WAY = {
|
|
type: 'way',
|
|
id: 1,
|
|
tags: { horse: 'designated' },
|
|
nodes: [10, 11],
|
|
geometry: [
|
|
{ lat: 47.15, lon: 11.25 },
|
|
{ lat: 47.16, lon: 11.25 },
|
|
],
|
|
};
|
|
|
|
it('TREK-PLACES-ROUTES-01: asks /v1/routes for the box, the type and the caps, kept inside the service bounds', async () => {
|
|
stubFetch({ elements: [RELATION, WAY], truncated: false });
|
|
const routes = await trekPlacesRoutes(VIEW, 'horse', CAPS);
|
|
const url = new URL(calls[0].url);
|
|
expect(url.pathname).toBe('/v1/routes');
|
|
expect(url.searchParams.get('bbox')).toBe('47.1,11.2,47.2,11.3');
|
|
expect(url.searchParams.get('type')).toBe('horse');
|
|
expect(url.searchParams.get('limitRelations')).toBe('60');
|
|
expect(url.searchParams.get('limitWays')).toBe('4000');
|
|
expect(routes).toEqual({ elements: [RELATION, WAY], truncated: false });
|
|
await trekPlacesRoutes(VIEW, 'horse', { relations: 999, ways: 0 });
|
|
const clamped = new URL(calls[1].url).searchParams;
|
|
expect([clamped.get('limitRelations'), clamped.get('limitWays')]).toEqual(['200', '1']);
|
|
});
|
|
|
|
it('TREK-PLACES-ROUTES-02: drops an element of another shape on its own, and fills the tags one left off', async () => {
|
|
stubFetch({
|
|
elements: [
|
|
RELATION,
|
|
{ ...RELATION, id: 8, members: [{ type: 'area', ref: 1, role: '' }] },
|
|
{ ...WAY, id: 2, nodes: [10] },
|
|
{ ...WAY, id: 3, geometry: [{ lat: 95, lon: 11 }, WAY.geometry[1]] },
|
|
{ ...WAY, id: 4, tags: undefined },
|
|
{ type: 'node', id: 5 },
|
|
],
|
|
truncated: true,
|
|
});
|
|
const routes = await trekPlacesRoutes(VIEW, 'horse', CAPS);
|
|
expect(routes.elements.map((element) => element.id)).toEqual([7, 4]);
|
|
expect(routes.elements[1]).toMatchObject({ id: 4, tags: {} });
|
|
expect(routes.truncated).toBe(true);
|
|
});
|
|
|
|
it('TREK-PLACES-ROUTES-03: reads truncated only when the service says true', async () => {
|
|
stubFetch({ elements: [], truncated: 'yes' });
|
|
expect(await trekPlacesRoutes(VIEW, 'horse', CAPS)).toEqual({ elements: [], truncated: false });
|
|
stubFetch({ elements: [] });
|
|
expect((await trekPlacesRoutes(VIEW, 'horse', CAPS)).truncated).toBe(false);
|
|
});
|
|
|
|
it('TREK-PLACES-ROUTES-04: an answer without elements fails, and a type not built is a 404', async () => {
|
|
stubFetch({ routes: [] });
|
|
await expect(trekPlacesRoutes(VIEW, 'horse', CAPS)).rejects.toThrow('without elements');
|
|
stubFetch({ detail: 'route type horse not built' }, 404);
|
|
const failure = await trekPlacesRoutes(VIEW, 'horse', CAPS).catch((err: unknown) => err);
|
|
expect(trekPlacesMiss(failure)).toBe('not_built');
|
|
});
|
|
|
|
it('TREK-PLACES-ROUTES-05: reads an answer past the search cap, since a view of routes carries their lines', async () => {
|
|
const payload = JSON.stringify({ elements: [RELATION, WAY], truncated: false, pad: 'x'.repeat(1_500_000) });
|
|
stubStreamingFetch([encoder.encode(payload)]);
|
|
const routes = await trekPlacesRoutes(VIEW, 'horse', CAPS);
|
|
expect(routes.elements).toHaveLength(2);
|
|
expect(streamCancelled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('trekPlacesMiss', () => {
|
|
it('TREK-PLACES-MISS-01: tells a missing layer, the off switch and any other failure apart', async () => {
|
|
const notBuilt = Object.assign(new Error('TREK Places API 404'), { status: 404 });
|
|
expect(trekPlacesMiss(notBuilt)).toBe('not_built');
|
|
env.maps.trekPlacesEnabled = false;
|
|
expect(trekPlacesMiss(await trekPlacesSearch('x').catch((err: unknown) => err))).toBe('off');
|
|
expect(trekPlacesMiss(Object.assign(new Error('TREK Places API 500'), { status: 500 }))).toBe('failed');
|
|
expect(trekPlacesMiss(new Error('aborted'))).toBe('failed');
|
|
expect(trekPlacesMiss(null)).toBe('failed');
|
|
expect(trekPlacesMiss('nope')).toBe('failed');
|
|
});
|
|
});
|
|
|
|
describe('the layers breaker', () => {
|
|
const VIEW = { south: 47.1, west: 11.2, north: 47.2, east: 11.3 };
|
|
const CAPS = { relations: 60, ways: 4000 };
|
|
|
|
it('TREK-PLACES-BREAKER-01: busy layer answers never stop place search', async () => {
|
|
stubFetch({ detail: 'busy' }, 503);
|
|
await oneByOne(Array.from({ length: 4 }), () =>
|
|
expect(trekPlacesRoutes(VIEW, 'horse', CAPS)).rejects.toThrow(/503/),
|
|
);
|
|
stubFetch({ query: 'x', results: [PLACE] });
|
|
await expect(trekPlacesSearch('x')).resolves.toHaveLength(1);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it('TREK-PLACES-BREAKER-02: the layers stop dialling on their own after a few failures', async () => {
|
|
stubFetch({ detail: 'busy' }, 503);
|
|
await oneByOne(Array.from({ length: 4 }), () => expect(trekPlacesWays([1])).rejects.toThrow(/503/));
|
|
const dialled = calls.length;
|
|
await expect(trekPlacesRoutes(VIEW, 'horse', CAPS)).rejects.toThrow('unreachable, not retrying yet');
|
|
await expect(trekPlacesWays([1])).rejects.toThrow('unreachable, not retrying yet');
|
|
expect(calls).toHaveLength(dialled);
|
|
});
|
|
|
|
it('TREK-PLACES-BREAKER-03: a layer not built yet does not clear the count of search failures', async () => {
|
|
stubFetch({}, 503);
|
|
await oneByOne(Array.from({ length: 3 }), () => expect(trekPlacesSearch('x')).rejects.toThrow(/503/));
|
|
stubFetch({ detail: 'not built' }, 404);
|
|
await expect(trekPlacesWays([1])).rejects.toThrow(/404/);
|
|
stubFetch({}, 503);
|
|
await expect(trekPlacesSearch('x')).rejects.toThrow(/503/);
|
|
calls = [];
|
|
await expect(trekPlacesSearch('x')).rejects.toThrow('unreachable, not retrying yet');
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|