diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index bf864d38f..ec444b9cc 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -134,6 +134,12 @@ export function deriveManaged(raw: RawEnv) { export function deriveMaps(raw: RawEnv) { return { placesApiBase: raw.PLACES_API_BASE || undefined, + /** + * Base URL of the TREK Places API. Unset means the public instance; an + * operator who runs their own copy of the index points at it here, and one + * who wants nothing to leave their network points at their own machine. + */ + trekPlacesUrl: raw.TREK_PLACES_URL || undefined, placesApiKey: raw.PLACES_API_KEY || undefined, /** Public pk.* token shipped with a managed instance; reaches the browser by design. */ mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined, diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index c2f8b0afd..3bf655347 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -143,6 +143,7 @@ export const envSchema = z.object({ ADMIN_PASSWORD: anyString, TREK_MANAGED: boolStr, PLACES_API_BASE: url, + TREK_PLACES_URL: url, PLACES_API_KEY: anyString, MAPBOX_ACCESS_TOKEN: anyString, CARTO_API_KEY: anyString, diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 59cdab5ca..be077da16 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -15,6 +15,13 @@ import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys' import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; import { DatabaseService } from '../database/database.service'; import { nominatimFetch, type GeoLane } from '../geo/nominatim.client'; +import { + trekPlacesSearch, + trekPlacesById, + trekPlacesNearby, + toPlaceRecord, + POI_CATEGORY_TO_TREK, +} from './trek-places.client'; import { UA, SEARCH_TEXT_FIELD_MASK, @@ -539,6 +546,19 @@ export class MapsService { return row?.value === 'false'; } + /** + * Fail-OPEN, unlike the three Google switches: the index is the path we want + * people on, and an install that upgrades without visiting the admin panel + * should land on it rather than stay on Nominatim, whose usage policy forbids + * what TREK was doing with it. + */ + trekPlacesEnabled(): boolean { + const row = this.database.get<{ value: string }>( + "SELECT value FROM app_settings WHERE key = 'trek_places_enabled'", + ); + return row?.value !== 'false'; + } + autocompleteDisabled(): boolean { return this.isSettingDisabled('places_autocomplete_enabled'); } @@ -585,8 +605,70 @@ export class MapsService { return this.resolveGoogleMapsUrl(url) as Promise; } - // OSM-only POI search by category within a viewport bbox (never calls Google). - pois(category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string) { + // POI search by category within a viewport. Never calls Google. + // + // The index answers first: it holds the same places, indexed in an R-Tree, and + // the public Overpass mirrors this used to depend on are regularly overloaded. + // A miss or a failure drops through to Overpass exactly as before. + async pois( + category: string, + bbox: { south: number; west: number; north: number; east: number }, + lang?: string, + ): Promise { + const terms = POI_CATEGORY_TO_TREK[category]; + if (this.trekPlacesEnabled() && terms) { + try { + const lat = (bbox.south + bbox.north) / 2; + const lng = (bbox.west + bbox.east) / 2; + // Half the diagonal, so the circle covers the viewport corners rather + // than leaving the edges of the map empty. + const radius = Math.min( + 20000, + Math.max( + 300, + Math.round( + (Math.hypot( + (bbox.north - bbox.south) * 111_320, + (bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180), + ) / 2), + ), + ), + ); + const found = await trekPlacesNearby(lat, lng, { + radius, + limit: 50, + category: terms.join(','), + }); + if (found.length > 0) { + // Same shape the Overpass path produces, so the client and the map + // renderer need no branch. `source` stays 'openstreetmap' because it + // is a typed contract the client switches on, and the places are the + // same places; where they came from is an implementation detail of + // this method, not something the pill should render differently. + return { + pois: found.map(p => ({ + osm_id: `gers:${p.gers}`, + name: p.name, + lat: p.lat, + lng: p.lng, + category, + poi_type: p.category ?? category, + address: p.address?.freeform ?? null, + website: p.contact?.website ?? null, + phone: p.contact?.phone ?? null, + opening_hours: null, + cuisine: null, + source: 'openstreetmap' as const, + })), + source: 'openstreetmap' as const, + truncated: found.length >= 50, + clamped: false, + }; + } + } catch (err: unknown) { + console.warn('TREK Places nearby failed, falling back:', (err as Error).message); + } + } return this.searchOverpassPois(category, bbox, lang); } @@ -974,6 +1056,18 @@ export class MapsService { * want a single image (fetchWikimediaPhoto, and through it getPlacePhoto) take * the first entry and get exactly the file they got before this existed. */ + /** + * Anything photographed near a coordinate. The bottom rung of the picture + * ladder, and the only one with no claim on the subject at all. + * + * 60 metres rather than the 300 it used to be. Three hundred metres in a city + * centre is a whole block: the town hall, the church and the underground + * entrance are all inside it, and every one of them outranks the doner shop + * we were actually asked about. Sixty is roughly "the same building and its + * neighbours", which is the widest a picture can be taken and still plausibly + * show the place. It does not fix the real problem, which is that nobody + * photographed the shop, but it stops the wrong answer from being confident. + */ async fetchCommonsCandidates(lat: number, lng: number, limit = 5): Promise { const params = new URLSearchParams({ action: 'query', @@ -981,7 +1075,7 @@ export class MapsService { generator: 'geosearch', ggsprimary: 'all', ggsnamespace: '6', - ggsradius: '300', + ggsradius: '60', ggscoord: `${lat}|${lng}`, // Deliberately more than the caller asked for. Around anything worth // visiting the first few hits are survey tiles, passers-by and the @@ -1463,6 +1557,30 @@ export class MapsService { ): Promise<{ places: Record[]; source: string }> { const { key: apiKey, source: keySource } = this.resolveMapsKey(userId); + // The TREK index answers first, whether or not a Google key exists. It is + // the only source here that may be stored, works offline as a country + // package, and costs the caller nothing; a key buys ratings and photos on + // top of it, not a better search. + // + // It never throws upward. A search that used to work must keep working when + // the service is slow or down, so a failure drops through to exactly what + // this method did before. + if (this.trekPlacesEnabled()) { + try { + const found = await trekPlacesSearch(query, { + lat: locationBias?.lat, + lng: locationBias?.lng, + limit: 10, + }); + if (found.length > 0) { + return { places: found.map(toPlaceRecord), source: 'trek-places' }; + } + } catch (err: unknown) { + // Logged, not surfaced: the fallback below is a working answer. + console.warn('TREK Places search failed, falling back:', (err as Error).message); + } + } + if (!apiKey) { const places = await this.searchNominatim(query, lang); return { places, source: 'openstreetmap' }; @@ -1534,6 +1652,37 @@ export class MapsService { ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { const { key: apiKey, source: keySource } = this.resolveMapsKey(userId); + // This is the path that mattered most. Nominatim's usage policy names + // autocomplete as unacceptable use in its own words, regardless of rate, + // and the whole TREK fleet shares one User-Agent there: one abusive install + // could get every instance blocked at once. The index removes that. + // + // Same contract as search: never throws upward, falls through to what this + // method did before. + if (this.trekPlacesEnabled()) { + try { + const centre = locationBias + ? { + lat: (locationBias.low.lat + locationBias.high.lat) / 2, + lng: (locationBias.low.lng + locationBias.high.lng) / 2, + } + : undefined; + const found = await trekPlacesSearch(input, { lat: centre?.lat, lng: centre?.lng, limit: 8 }); + if (found.length > 0) { + return { + suggestions: found.map(p => ({ + placeId: `gers:${p.gers}`, + mainText: p.name, + secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '), + })), + source: 'trek-places', + }; + } + } catch (err: unknown) { + console.warn('TREK Places autocomplete failed, falling back:', (err as Error).message); + } + } + if (!apiKey) { return this.autocompleteNominatim(input, lang); } @@ -1620,6 +1769,47 @@ export class MapsService { lang?: string, sessionToken?: string, ): Promise<{ place: Record | null }> { + // A place picked out of the TREK index. Checked BEFORE the generic + // colon branch below, which would otherwise read "gers" as an OSM type + // and ask Overpass for an element that does not exist. + if (placeId.startsWith('gers:')) { + const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null); + if (!found) return { place: null }; + + // What the index does not have, the free sources still do. The index + // carries no opening hours yet and never carries cuisine, wheelchair + // access or a menu link; OpenStreetMap has all of them for the same + // building. Without this the hours a user saw while adding the place + // disappeared from its card afterwards, which reads like data loss. + // + // Matched by name and coordinate, with the same two gates + // resolveOsmIdentity applies everywhere: within range, and sharing a + // substantial word of the name. A confident description of the building + // next door is worse than none. + const osm = await this.resolveOsmIdentity(found.name, found.lat, found.lng, { + lang, + maxDistanceM: 150, + }).catch(() => null); + + const record = toPlaceRecord(found); + if (!osm) return { place: record }; + + const osmDetails = buildOsmDetails(osm.tags, '', ''); + return { + place: { + // Index first: its name, coordinate and contact details are the ones + // the user picked. OSM only fills what is still missing. + ...osmDetails, + ...record, + opening_hours: osmDetails.opening_hours ?? null, + website: record.website ?? osmDetails.website ?? null, + phone: record.phone ?? osmDetails.phone ?? null, + osm_id: placeId, + source: 'trek-places', + }, + }; + } + // OSM details: placeId is "node:123456" or "way:123456" etc. if (placeId.includes(':')) { const [osmType, osmId] = placeId.split(':'); diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts new file mode 100644 index 000000000..d9aa21b0e --- /dev/null +++ b/server/src/nest/maps/trek-places.client.ts @@ -0,0 +1,229 @@ +/** + * Client for the TREK Places API. + * + * The index behind it is Overture Places, built monthly and served from a + * server the project runs. It replaces the two things TREK used to do badly: + * searching through Nominatim, which its own usage policy forbids for + * autocomplete, and paying Google for a text search whose terms then forbid + * storing what comes back. + * + * Everything here is read-only, unauthenticated and cacheable. There is no key + * to configure and no quota to exhaust, so the failure modes are the network + * and the service being down, both of which fall back to what TREK did before. + */ +import { readEnv } from '../../app-config'; + +/** The public instance. An operator may point at their own copy instead. */ +export const DEFAULT_TREK_PLACES_URL = 'https://places.liketrek.com'; + +/** + * Short on purpose. This sits in front of a keystroke, and a slow answer is + * worse than no answer: the caller falls back to the old path rather than + * leaving somebody watching a spinner. + */ +const TIMEOUT_MS = 3500; + +/** A search answer is a few kB. A megabyte means something is wrong upstream. */ +const MAX_BYTES = 1_000_000; + +export interface TrekPlace { + gers: string; + name: string; + lat: number; + lng: number; + category: string | null; + categoryPath: string | null; + confidence: number | null; + address: { + freeform: string | null; + locality: string | null; + postcode: string | null; + region: string | null; + country: string | null; + }; + contact: { + website: string | null; + phone: string | null; + email: string | null; + socials: string[] | null; + }; + brand: { name: string | null; wikidata: string | null } | null; + source: string; + hours: unknown | null; + score?: number; +} + +export interface TrekPlacesSearchResponse { + query: string; + results: TrekPlace[]; +} + +/** + * One opaque string per instance, so the service can rate-limit per caller + * instead of per IP. Derived from the instance URL rather than generated and + * stored: it must be stable across restarts, and it must not be anything that + * identifies a person. It is never resolved back to anything on the far side. + */ +function instanceToken(): string { + const url = readEnv().app.appUrl || 'unconfigured'; + let hash = 0; + for (let i = 0; i < url.length; i++) { + hash = ((hash << 5) - hash + url.charCodeAt(i)) | 0; + } + return `trek-${(hash >>> 0).toString(36)}`; +} + +export function trekPlacesBaseUrl(): string { + const configured = (readEnv().maps.trekPlacesUrl || '').trim(); + return (configured || DEFAULT_TREK_PLACES_URL).replace(/\/+$/, ''); +} + +async function getJson(path: string, params: Record): Promise { + const url = new URL(trekPlacesBaseUrl() + path); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { + Accept: 'application/json', + 'X-TREK-Instance': instanceToken(), + }, + }); + if (!res.ok) { + const err = new Error(`TREK Places API ${res.status}`) as Error & { status: number }; + err.status = res.status; + throw err; + } + // Read with a cap rather than res.json(): a hostile or broken upstream + // should not be able to hand us an unbounded body to buffer. + const text = await readCapped(res, MAX_BYTES); + return JSON.parse(text) as T; + } finally { + clearTimeout(timer); + } +} + +async function readCapped(res: Response, max: number): Promise { + const reader = res.body?.getReader(); + if (!reader) return res.text(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > max) { + await reader.cancel(); + throw new Error('TREK Places API response too large'); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export async function trekPlacesSearch( + query: string, + opts: { lat?: number; lng?: number; limit?: number } = {}, +): Promise { + const body = await getJson('/v1/search', { + q: query, + lat: opts.lat, + lng: opts.lng, + limit: opts.limit ?? 10, + }); + return Array.isArray(body.results) ? body.results : []; +} + +/** + * The planner's category pills, mapped onto Overture categories. + * + * Deliberately a hand-written map rather than a guess at runtime: the two + * taxonomies do not line up, and a pill that quietly returns the wrong kind of + * place is worse than one that returns none. Each entry is a substring matched + * against `category` and `category_path`, so `eat_and_drink>cafe` answers the + * cafe pill without listing every leaf. + */ +export const POI_CATEGORY_TO_TREK: Record = { + restaurant: ['restaurant', 'casual_eatery', 'fast_food'], + cafe: ['cafe', 'coffee_shop'], + bar: ['bar', 'pub', 'nightclub', 'brewery'], + hotel: ['hotel', 'lodging', 'hostel', 'motel', 'bed_and_breakfast'], + sights: ['historic_site', 'landmark_and_historical_building', 'monument', 'castle', 'tourist_attraction'], + museum: ['museum', 'art_gallery', 'performing_arts', 'theater'], + nature: ['park', 'garden', 'beach', 'nature_preserve', 'mountain'], + activity: ['amusement_park', 'zoo', 'aquarium', 'water_park', 'theme_park'], + shopping: ['shopping', 'shopping_center', 'department_store', 'market'], + supermarket: ['grocery_store', 'food_and_beverage_store', 'convenience_store', 'supermarket'], +}; + +export interface TrekNearbyPlace extends TrekPlace { + distanceMetres: number; +} + +/** + * Places around a coordinate, nearest first. Served from an R-Tree, so it + * answers in milliseconds where the Overpass mirrors this used to ask are + * regularly overloaded and answer in seconds or not at all. + */ +export async function trekPlacesNearby( + lat: number, + lng: number, + opts: { radius?: number; limit?: number; category?: string } = {}, +): Promise { + const body = await getJson<{ results: TrekNearbyPlace[] }>('/v1/nearby', { + lat, + lng, + radius: Math.round(opts.radius ?? 1500), + limit: opts.limit ?? 50, + category: opts.category, + }); + return Array.isArray(body.results) ? body.results : []; +} + +export async function trekPlacesById(gers: string): Promise { + try { + const body = await getJson<{ place: TrekPlace }>(`/v1/place/${encodeURIComponent(gers)}`, {}); + return body.place ?? null; + } catch (err) { + if ((err as { status?: number }).status === 404) return null; + throw err; + } +} + +/** + * Map a TREK place onto the record shape the client already reads. + * + * Deliberately the same shape Nominatim and Google produce, so nothing on the + * client had to learn a third format. `osm_id` carries the GERS id prefixed + * with `gers:`, because that field is where the client already looks for "the + * provider's id for this place", and the prefix keeps it from being mistaken + * for an OSM reference by code that parses it. + */ +export function toPlaceRecord(p: TrekPlace): Record { + const address = [p.address?.freeform, p.address?.postcode, p.address?.locality] + .filter(Boolean) + .join(', '); + return { + google_place_id: null, + google_ftid: null, + osm_id: `gers:${p.gers}`, + name: p.name, + address: address || p.address?.locality || '', + lat: Number.isFinite(p.lat) ? p.lat : null, + lng: Number.isFinite(p.lng) ? p.lng : null, + // Ratings exist nowhere in open data, and saying so with null is honest. + rating: null, + website: p.contact?.website ?? null, + phone: p.contact?.phone ?? null, + email: p.contact?.email ?? null, + category: p.category ?? null, + brand: p.brand?.name ?? null, + wikidata: p.brand?.wikidata ?? null, + source: 'trek-places', + }; +} diff --git a/server/tests/unit/nest/trek-places.client.test.ts b/server/tests/unit/nest/trek-places.client.test.ts new file mode 100644 index 000000000..dae9d6b31 --- /dev/null +++ b/server/tests/unit/nest/trek-places.client.test.ts @@ -0,0 +1,189 @@ +/** + * The TREK Places client: what it sends, what it refuses, and how it maps an + * answer onto the record shape the rest of TREK already reads. + * + * fetch is stubbed rather than hit: the point is the contract, and a unit test + * that depends on a live service tells you about the service. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../../src/app-config', () => ({ + readEnv: () => ({ maps: { trekPlacesUrl: '' }, app: { appUrl: 'https://trip.example.org' } }), +})); + +import { + DEFAULT_TREK_PLACES_URL, + POI_CATEGORY_TO_TREK, + toPlaceRecord, + trekPlacesBaseUrl, + trekPlacesById, + trekPlacesNearby, + trekPlacesSearch, + type TrekPlace, +} from '../../../src/nest/maps/trek-places.client'; + +const PLACE: TrekPlace = { + gers: 'abc-123', + name: "L'Osteria", + lat: 54.0879, + lng: 12.1408, + category: 'restaurant', + categoryPath: 'eat_and_drink>restaurant', + confidence: 1, + address: { freeform: 'Steinstrasse 9', locality: 'Rostock', postcode: '18055', region: 'MV', country: 'DE' }, + contact: { website: 'https://losteria.net/', phone: '+49381', email: 'r@losteria.de', socials: null }, + brand: { name: "L'Osteria", wikidata: 'Q123' }, + source: 'overture', + hours: null, +}; + +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; + })); +} + +beforeEach(() => { calls = []; }); +afterEach(() => vi.unstubAllGlobals()); + +describe('trekPlacesBaseUrl', () => { + it('falls back to the public instance when nothing is configured', () => { + expect(trekPlacesBaseUrl()).toBe(DEFAULT_TREK_PLACES_URL); + }); +}); + +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; + 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('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('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('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'); + }); +}); + +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('reports no rating rather than a made-up one', () => { + // Ratings exist in no open dataset. Null says so; 0 would be a claim. + expect(toPlaceRecord(PLACE).rating).toBeNull(); + }); + + it('keeps a place on the null island rather than dropping its coordinate', () => { + const r = toPlaceRecord({ ...PLACE, lat: 0, lng: 0 }); + expect(r.lat).toBe(0); + expect(r.lng).toBe(0); + }); + + it('leaves the coordinate null when it is not a number', () => { + const r = toPlaceRecord({ ...PLACE, lat: Number.NaN, lng: Number.NaN }); + expect(r.lat).toBeNull(); + }); + + it('builds an address from whatever parts exist', () => { + const r = toPlaceRecord({ + ...PLACE, + address: { freeform: null, locality: 'Rostock', postcode: null, region: null, country: 'DE' }, + }); + expect(r.address).toBe('Rostock'); + }); +}); + +describe('POI_CATEGORY_TO_TREK', () => { + it('covers every pill the planner offers', () => { + // The keys are the contract with the client's POI_CATEGORIES. A missing one + // silently drops that pill back to Overpass, which is the slow path. + expect(Object.keys(POI_CATEGORY_TO_TREK).sort()).toEqual([ + 'activity', 'bar', 'cafe', 'hotel', 'museum', + 'nature', '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); + } + }); +});