diff --git a/server/scripts/import-boundaries.mjs b/server/scripts/import-boundaries.mjs index 8e52ebfdd..c78bbe956 100644 --- a/server/scripts/import-boundaries.mjs +++ b/server/scripts/import-boundaries.mjs @@ -101,6 +101,7 @@ export const PUBLIC_FILES = [ // The maps domain's outbound clients. MapsModule exports them so enrichment // asks Google and Wikimedia directly instead of through a MapsService facade. 'maps/providers/google-places.provider.ts', + 'maps/providers/osm.client.ts', ]; function walk(dir, files = []) { diff --git a/server/src/nest/maps/maps.module.ts b/server/src/nest/maps/maps.module.ts index eb763cfcd..5df215e5e 100644 --- a/server/src/nest/maps/maps.module.ts +++ b/server/src/nest/maps/maps.module.ts @@ -4,6 +4,7 @@ import { MapsController } from './maps.controller'; import { MapsService } from './maps.service'; import { MapsMcp } from './maps.mcp'; import { GooglePlacesClient } from './providers/google-places.provider'; +import { OsmClient } from './providers/osm.client'; import { PlacePhotosModule } from '../place-photos/place-photos.module'; import { StorageModule } from '../storage/storage.module'; import { GoogleQuotaModule } from '../google-quota/google-quota.module'; @@ -29,7 +30,7 @@ import { Places } from '../../db/entities/Places.entity'; @Module({ imports: [PlacePhotosModule, StorageModule, GoogleQuotaModule, MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places])], controllers: [MapsController], - providers: [MapsService, MapsMcp, GooglePlacesClient], - exports: [MapsService, GooglePlacesClient], + providers: [MapsService, MapsMcp, GooglePlacesClient, OsmClient], + exports: [MapsService, GooglePlacesClient, OsmClient], }) export class MapsModule {} diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 67860308e..0a72f308c 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -24,6 +24,7 @@ import { Places } from '../../db/entities/Places.entity'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider'; import { GooglePlacesClient } from './providers/google-places.provider'; +import { OsmClient, POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client'; import { AMAP_SHORT_HOSTS, AmapPlacesProvider, @@ -35,7 +36,7 @@ import { // ── Photo cache (disk-backed) ──────────────────────────────────────────────── import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; import { GoogleQuotaService } from '../google-quota/google-quota.service'; -import { nominatimFetch, type GeoLane } from '../geo/nominatim.client'; +import type { GeoLane } from '../geo/nominatim.client'; import { trekPlacesSearch, indexHitsOnly, @@ -55,68 +56,22 @@ import { isGooglePlaceId, googleFtidFromMapsUrl, OSM_PLACE_ID, - CATEGORY_OSM_FILTERS, parsePoiCategories, - clampPoiBbox, - resolveOverpassEndpoints, - resolveOverpassTimeoutMs, - OVERPASS_QUERY_TIMEOUT_S, stripWikiMarkup, parseWikipediaTag, toWikiLang, - haversineMetres, - namesOverlap, mergeSearchResults, - readChargingInfo, - type OverpassPoi, } from './maps.helpers'; import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, - nearbyOverpassQuery, nearestFirst, - overpassNearbyRecords, } from './maps-nearby.helpers'; // ── Interfaces ─────────────────────────────────────────────────────────────── -function nominatimCategory(item: { class?: string; type?: string }): string | null { - if (!item.class || !item.type || item.type === 'yes') return null; - return item.class === 'shop' ? `shop_${item.type}` : item.type; -} - -interface NominatimResult { - osm_type: string; - osm_id: string; - /** The main OSM tag, key and value: `class` "tourism" with `type` "hotel". */ - class?: string; - type?: string; - name?: string; - display_name?: string; - lat: string; - lon: string; - extratags?: Record | null; -} - -/** - * The keys that say which encyclopaedia entry, Wikidata item and Commons - * category describe a place. Everything the enrichment column shows beyond - * coordinates hangs off one of these. - * - * Bare keys only. OSM also carries `brand:wikidata` / `brand:wikipedia`, and - * following those means a branch of a chain gets the chain's article and the - * chain's logo — for "L'Osteria Rostock" you would confidently describe - * L'Osteria the company. That is the exact failure the tag-only rule was - * written to avoid, so do not "improve" this by falling back to brand:*. - */ -const WIKI_IDENTITY_TAGS = ['wikipedia', 'wikidata', 'wikimedia_commons'] as const; - -export interface WikiIdentity { - wikipedia: string | null; - wikidata: string | null; - wikimedia_commons: string | null; -} +export { readBrandIdentity, readWikiIdentity, type WikiIdentity } from './providers/wiki-identity'; interface WikidataSnak { mainsnak?: { datavalue?: { value?: string } }; @@ -205,44 +160,6 @@ function normalizeCategoryName(raw: string): string | null { return value.replace(/^(category|kategorie|categorie|categoría|categoria)\s*:/i, '').trim() || null; } -/** - * The chain a place belongs to, when it belongs to one. - * - * Read separately from `readWikiIdentity` and never mixed into it. Following - * `brand:wikidata` as if it described the place is how "L'Osteria Rostock" - * ends up illustrated with the company logo and described as a franchise - * operator — which is why the picture ladder never sees these. For a - * description they are still worth something: a branch of a chain has no - * article of its own and never will, and "L'Osteria is a German restaurant - * chain serving pizza and pasta" beats an empty column, as long as the reader - * is told that is what they are looking at. - */ -export function readBrandIdentity(extratags: Record | null | undefined): { - wikidata: string | null; - wikipedia: string | null; -} { - const read = (key: string): string | null => { - const value = extratags?.[key]; - return typeof value === 'string' && value.trim() ? value.trim() : null; - }; - return { wikidata: read('brand:wikidata'), wikipedia: read('brand:wikipedia') }; -} - -/** Picks the three identity tags out of a Nominatim `extratags` blob. */ -export function readWikiIdentity(extratags: Record | null | undefined): WikiIdentity { - const out: WikiIdentity = { wikipedia: null, wikidata: null, wikimedia_commons: null }; - if (!extratags) return out; - for (const tag of WIKI_IDENTITY_TAGS) { - const value = extratags[tag]; - if (typeof value === 'string' && value.trim()) out[tag] = value.trim(); - } - return out; -} - -interface OverpassElement { - tags?: Record; -} - interface WikiCommonsPage { pageid?: number; title?: string; @@ -424,17 +341,6 @@ async function flattenBrandLogo(bytes: Buffer): Promise { // day at the provider rather than a slow answer worth waiting for. const IDENTITY_TIMEOUT_MS = 2500; -// The explicit search asks Nominatim alongside the index, and the pair costs -// the slower one. The index gives up after 3.5 s and the browser after 8 s, so a -// Nominatim that accepts the connection and then sits on it (the public service -// under load, a self-hosted one that hung) has to give up in between: without a -// deadline of its own the request rides undici's 300 s default, and the index's -// answer is thrown away with the request that timed out waiting for it. The -// deadline starts after the throttle wait, so it measures the answer alone: -// generous for a slow one, still inside the browser's budget with the wait -// added on. -const SEARCH_TIMEOUT_MS = 6000; - const MAX_CONCURRENT_PHOTO_FETCHES = 5; let photoFetchActive = 0; const photoFetchQueue: Array<() => void> = []; @@ -471,49 +377,6 @@ export async function withPhotoFetchSlot(fn: () => Promise): Promise { } } -// ── Overpass POI search state ──────────────────────────────────────────────── - -interface OverpassPoiElement { - type: string; - id: number; - lat?: number; - lon?: number; - center?: { lat: number; lon: number }; - tags?: Record; -} - -interface PoiSearchResult { - pois: OverpassPoi[]; - /** - * Which index answered. Not decoration: this branch calls naming the sources - * a licence obligation rather than a courtesy, and Overture carries more than - * OpenStreetMap under more than one licence, so stamping its rows as OSM is a - * wrong attribution rather than a rounding of one. - */ - source: 'openstreetmap' | 'trek-places'; - truncated: boolean; - // True when the requested viewport was too large and got shrunk to a centred - // window before querying — the results then cover the middle of the view only. - clamped: boolean; -} - -// Frozen at module load, same timing as the legacy service ("frozen on purpose", -// see src/app-config/README.md). -const OVERPASS_MIRRORS = resolveOverpassEndpoints(); -const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs(); -// Short-lived cache so panning back over / re-toggling the same area doesn't -// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox. -const POI_CACHE = new Map(); -const POI_CACHE_TTL_MS = 5 * 60 * 1000; -// Cap the number of cached areas so panning across the globe can't grow the map -// without bound (entries are evicted oldest-first once the cap is reached). -const POI_CACHE_MAX = 500; -/** - * Hard ceiling on one POI answer, however many categories it carries. A corridor search - * asks for four kinds at once; without a ceiling a dense city box would return a payload - * nobody reads and every mirror pays for. - */ -const POI_RESULT_CAP = 240; // Places near a point (#976): cached longer than the POI boxes, because the // likely caller is an import asking the same photo location again, and Google // bills every one of those. @@ -521,68 +384,6 @@ const NEARBY_CACHE = new Map { - const body = `data=${encodeURIComponent(query)}`; - const controllers: AbortController[] = []; - - const attempt = async (url: string): Promise => { - const ctrl = new AbortController(); - controllers.push(ctrl); - const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS); - try { - const res = await fetch(url, { - method: 'POST', - headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - signal: ctrl.signal, - }); - if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`); - const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string }; - // Overpass signals an internal timeout / runtime error via `remark` while - // still answering HTTP 200 — often fast, with an empty or partial element - // set. Treat that as a failed attempt so a healthy mirror wins the race - // instead of this fast-but-empty answer, and so the all-mirrors-failed path - // still surfaces a real error to the client instead of a silent "no places". - if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`); - if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`); - return data.elements; - } finally { - clearTimeout(timer); - } - }; - - try { - // Promise.any resolves with the first mirror to return valid JSON, and only - // rejects (AggregateError) once every mirror has failed. - return await Promise.any(OVERPASS_MIRRORS.map(attempt)); - } catch (err) { - // Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM - // body, …) so an operator can tell blocked egress / a firewall from a transiently - // overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309). - const reasons = - err instanceof AggregateError - ? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ') - : err instanceof Error - ? err.message - : String(err); - console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`); - throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 }); - } finally { - // Cancel the slower/losing requests — we already have (or have given up on) a result. - controllers.forEach((c) => { - try { - c.abort(); - } catch { - /* noop */ - } - }); - } -} - type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } }; /** @@ -650,6 +451,7 @@ export class MapsService { @InjectRepository(Places) private readonly placesRepo: PlacesRepository, private readonly googleQuota: GoogleQuotaService, private readonly googlePlaces: GooglePlacesClient, + private readonly osm: OsmClient, ) {} /** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the @@ -950,7 +752,7 @@ export class MapsService { console.warn('TREK Places nearby failed, falling back:', (err as Error).message); } } - return this.searchOverpassPois(category, bbox, lang, limit); + return this.osm.searchOverpassPois(category, bbox, lang, limit); } // ── API key retrieval ────────────────────────────────────────────────────── @@ -1123,357 +925,10 @@ export class MapsService { console.warn('TREK Places geocode failed, falling back:', (err as Error).message); } } - const hit = (await this.searchNominatim(query, undefined, 'background'))[0]; + const hit = (await this.osm.searchNominatim(query, undefined, 'background'))[0]; return hit?.lat != null && hit?.lng != null ? { lat: hit.lat, lng: hit.lng } : null; } - // ── Nominatim search ─────────────────────────────────────────────────────── - - /** - * `lane` defaults to interactive because most callers are a keystroke. - * - * Bulk callers must pass 'background': booking-import geocodes every venue and - * every uncoordinated endpoint of an import in one request loop, which is up - * to thirty sequential calls. On the interactive lane those thirty take the - * next slot each time, so somebody typing in the place search waits behind the - * whole import. Yielding does not make the import faster, it stops it from - * being the only thing the process will do for half a minute. - */ - async searchNominatim( - query: string, - lang?: string, - lane: GeoLane = 'interactive', - bias?: { lat: number; lng: number }, - ) { - const params = new URLSearchParams({ - q: query, - format: 'json', - addressdetails: '1', - // Free, same request: this is where a place's wikidata/wikipedia/commons - // tags live. Without them the enrichment column can only fall back to - // "photos taken within 300m", which around a city centre is passers-by - // and the neighbouring building. - extratags: '1', - limit: '10', - 'accept-language': toApiLang(lang), - }); - // Prefer the area the caller is looking at, never restrict to it - // (bounded=0). Without this, "Hase-dera" returns the temple of that name in - // Nara rather than the one in Kamakura the user is standing next to; with - // bounded=1 a search for somewhere genuinely far away would return nothing. - if (bias) { - const d = 0.5; - params.set('viewbox', [bias.lng - d, bias.lat - d, bias.lng + d, bias.lat + d].join(',')); - params.set('bounded', '0'); - } - // Through the shared client: one throttle for the whole process. - const response = await nominatimFetch('search', params, { lane, timeoutMs: SEARCH_TIMEOUT_MS }); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error( - `Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`, - ); - } - const data = (await response.json()) as NominatimResult[]; - return data.map((item) => { - // Number.isFinite, not `|| null`: a place on the equator or prime - // meridian has a legitimate 0 coordinate. - const lat = Number.parseFloat(item.lat); - const lng = Number.parseFloat(item.lon); - return { - google_place_id: null, - google_ftid: null, - osm_id: `${item.osm_type}:${item.osm_id}`, - name: item.name || item.display_name?.split(',')[0] || '', - address: item.display_name || '', - lat: Number.isFinite(lat) ? lat : null, - lng: Number.isFinite(lng) ? lng : null, - rating: null, - website: null, - phone: null, - // What the place is, so the add form can preselect a category (#2282). A shop's - // value is the ware ("bakery"), so the key goes first and says it is a shop. - category: nominatimCategory(item), - source: 'openstreetmap', - ...readWikiIdentity(item.extratags), - }; - }); - } - - /** - * Finds the OpenStreetMap record for a place we only know by name and - * coordinate, and hands back its tags. - * - * This is what gives a Google place a free identity. Google's payload has no - * `wikidata`, no `wikipedia` and no `wikimedia_commons` — it never had — so - * without this the entire free half of the enrichment column is unreachable - * for anyone who configured a Google key, which is the opposite of how this - * feature is meant to work. OSM knows these places perfectly well; nobody was - * asking it. - * - * Two gates keep it from describing the wrong building, because a confident - * description of somewhere else is worse than none: - * - the match has to be within `maxDistanceM` of where we are looking, and - * - it has to share a substantial word with the name we are looking for. - * Among what survives, Nominatim's own `importance` decides — that is what - * separates the Brandenburg Gate from the underground station named after it. - */ - async resolveOsmIdentity( - name: string, - lat: number, - lng: number, - opts: { lang?: string; maxDistanceM?: number; signal?: AbortSignal } = {}, - ): Promise<{ tags: Record; osmUrl: string | null; matchedName: string } | null> { - const query = (name || '').trim(); - if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null; - const maxDistanceM = opts.maxDistanceM ?? 2000; - - // ~2km box around the point, so Nominatim ranks locally instead of handing - // back the most famous place on earth with this name. - const d = 0.02; - const params = new URLSearchParams({ - q: query, - format: 'jsonv2', - extratags: '1', - limit: '5', - bounded: '1', - viewbox: `${lng - d},${lat - d},${lng + d},${lat + d}`, - 'accept-language': toApiLang(opts.lang), - }); - - try { - // The caller's deadline is handed in rather than built here, so the - // throttle wait cannot eat it before the request starts. - const res = await nominatimFetch('search', params, { - signal: opts.signal, - timeoutMs: IDENTITY_TIMEOUT_MS, - }); - if (!res.ok) return null; - // Nominatim answers rate limiting in plain text, not JSON. - const data = (await res.json()) as (NominatimResult & { importance?: number })[]; - if (!Array.isArray(data)) return null; - - const best = data - .map((item) => ({ - item, - lat: Number.parseFloat(item.lat), - lng: Number.parseFloat(item.lon), - })) - .filter(({ item, lat: hitLat, lng: hitLng }) => { - if (!Number.isFinite(hitLat) || !Number.isFinite(hitLng)) return false; - if (haversineMetres(lat, lng, hitLat, hitLng) > maxDistanceM) return false; - const label = item.name || item.display_name?.split(',')[0] || ''; - return namesOverlap(query, label); - }) - .sort((a, b) => { - const byImportance = (b.item.importance ?? 0) - (a.item.importance ?? 0); - if (byImportance !== 0) return byImportance; - return ( - haversineMetres(lat, lng, a.lat, a.lng) - haversineMetres(lat, lng, b.lat, b.lng) - ); - })[0]; - - if (!best) return null; - return { - tags: best.item.extratags ?? {}, - osmUrl: - best.item.osm_type && best.item.osm_id - ? `https://www.openstreetmap.org/${best.item.osm_type}/${best.item.osm_id}` - : null, - matchedName: best.item.name || best.item.display_name?.split(',')[0] || query, - }; - } catch { - return null; - } - } - - // ── Nominatim lookup (by OSM ID) ─────────────────────────────────────────── - - async lookupNominatim( - osmType: string, - osmId: string, - lang?: string, - ): Promise<{ - name: string; - address: string; - lat: number | null; - lng: number | null; - extratags: Record | null; - } | null> { - const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R - const params = new URLSearchParams({ - osm_ids: `${typePrefix}${osmId}`, - format: 'json', - // Overpass is the richer source but it is also the one that times out; - // whatever Nominatim already knows costs nothing extra here. - extratags: '1', - 'accept-language': toApiLang(lang), - }); - try { - const res = await nominatimFetch('lookup', params); - if (!res.ok) return null; - const data = (await res.json()) as NominatimResult[]; - const item = data[0]; - if (!item) return null; - const lat = Number.parseFloat(item.lat); - const lng = Number.parseFloat(item.lon); - return { - name: item.name || item.display_name?.split(',')[0] || '', - address: item.display_name || '', - lat: Number.isFinite(lat) ? lat : null, - lng: Number.isFinite(lng) ? lng : null, - extratags: item.extratags ?? null, - }; - } catch { - return null; - } - } - - // ── Overpass API (OSM details) ───────────────────────────────────────────── - - async fetchOverpassDetails(osmType: string, osmId: string): Promise { - const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; - const oType = typeMap[osmType]; - if (!oType) return null; - // The id is the one thing written into the query, and the query is a - // language. An OSM element id is a number and nothing else; anything with - // more in it is a statement of its own, sent under TREK's shared user agent. - if (!/^\d+$/.test(osmId)) return null; - const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`; - try { - const res = await fetch('https://overpass-api.de/api/interpreter', { - method: 'POST', - headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(query)}`, - // The query asks Overpass for 5 s; a public instance that is overloaded - // takes the connection and then sits on it. - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) return null; - const data = (await res.json()) as { elements?: OverpassElement[] }; - return data.elements?.[0] || null; - } catch { - return null; - } - } - - // ── Overpass POI search (by category within a viewport bbox) ─────────────── - // Powers the "explore places on the map" pill. OSM-ONLY by design — this never - // calls Google, even when a Google key is configured. - - async searchOverpassPois( - category: string, - bbox: { south: number; west: number; north: number; east: number }, - lang?: string, - limit = 60, - ): Promise { - // One or several categories in one query. Each OSM selector belongs to exactly one - // category, so a hit can still be labelled with the category it answered. - const categories = parsePoiCategories(category); - if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 }); - const categoryOfFilter = new Map(); - for (const key of categories) { - const own = CATEGORY_OSM_FILTERS[key]; - if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 }); - for (const f of own) categoryOfFilter.set(f, key); - } - const filters = [...categoryOfFilter.keys()]; - // Each category gets its own share of the cap, or a mixed search would spend the - // whole budget on whichever kind happens to be densest. - const cap = Math.min(limit * categories.length, POI_RESULT_CAP); - - // Clamp an oversized viewport to a centred window so the query stays cheap and - // returns fast at any zoom, instead of timing out / 502-ing on a huge area. - const searchWindow = clampPoiBbox(bbox); - const { south, west, north, east } = searchWindow.bbox; - const clamped = searchWindow.clamped; - - // OSM `name:*` tags are keyed by language subtag: prefer the user's language - // (the same localization the search/autocomplete path asks the geocoder for) - // over the native `name`. `int_name` is OSM's international/romanized name — a - // sensible fallback before the native one. Part of the cache key so a cached - // area isn't served with another language's titles. - const osmLang = toApiLang(lang).split('-')[0].toLowerCase(); - - // Serve repeat pans/toggles of the same area straight from the cache. - const cacheKey = `${[...categories].sort((a, b) => a.localeCompare(b)).join('+')}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${cap}`; - const cached = POI_CACHE.get(cacheKey); - if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value; - if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching - - // Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng). - const box = `(${south},${west},${north},${east})`; - const selectors = filters - .map((f) => { - const [k, v] = f.split('='); - return ` nwr["${k}"="${v}"]${box};`; - }) - .join('\n'); - // `out center tags ` returns ways/relations with a computed center and caps - // the result count in one round-trip. - const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`; - - const elements = await overpassFetch(query); - - const pois: OverpassPoi[] = []; - for (const el of elements) { - const tags = el.tags || {}; - // `operator` comes last but matters for the road categories: petrol stations, - // charging points and service areas are routinely mapped with an operator and no - // name, and dropping those would empty the road trip corridor over long stretches. - const name = - tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null; - if (!name) continue; // unnamed POIs aren't useful to add to a plan - // A shut-down place is not somewhere to plan a visit (#1341). OSM usually - // re-tags one with a `disused:`/`abandoned:` prefix, and those never match - // the selectors above — but plenty keep their original tag and gain a marker - // instead, and those do come back. `opening_hours=closed`/`off` is the same - // statement in the hours field. - if (tags.disused === 'yes' || tags.abandoned === 'yes') continue; - if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue; - const lat = el.lat ?? el.center?.lat; - const lng = el.lon ?? el.center?.lon; - if (lat == null || lng == null) continue; - const matched = - filters.find((f) => { - const [k, v] = f.split('='); - return tags[k] === v; - }) || filters[0]; - const addr = - [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']] - .filter(Boolean) - .join(' ') || null; - pois.push({ - osm_id: `${el.type}:${el.id}`, - name, - lat, - lng, - category: categoryOfFilter.get(matched) ?? categories[0], - poi_type: matched, - address: addr, - website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']), - phone: tags.phone || tags['contact:phone'] || null, - opening_hours: tags.opening_hours || null, - cuisine: tags.cuisine || null, - brand: tags.brand || tags.operator || null, - // Only the plain Q-id form is passed on; anything else would be a lookup we - // would have to guess at. - brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null, - // Only where it means something. Every POI carries `capacity` and `fee` for its - // own reasons — a restaurant's capacity is seats — so reading them as charging - // data anywhere else would be wrong on most of the map. - charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null, - source: 'openstreetmap', - }); - } - const truncated = pois.length > cap; - const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped }; - // FIFO eviction: a Map preserves insertion order, so the first key is the oldest. - if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string); - POI_CACHE.set(cacheKey, { at: Date.now(), value }); - return value; - } - // ── Wikimedia Commons photo lookup ───────────────────────────────────────── async fetchWikimediaPhoto( @@ -2026,7 +1481,7 @@ export class MapsService { console.warn('TREK Places search failed, falling back:', (err as Error).message); return [] as TrekPlace[]; }), - this.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => { + this.osm.searchNominatim(query, lang, 'interactive', locationBias).catch((err: unknown) => { console.warn('OpenStreetMap search failed, index only:', (err as Error).message); return [] as Record[]; }), @@ -2059,7 +1514,7 @@ export class MapsService { // first call carried a viewbox with bounded=0, which orders results // without changing which ones exist, so a second call can only return the // same empty list a throttle-interval later. - const places = osmAnswer ?? (await this.searchNominatim(query, lang)); + const places = osmAnswer ?? (await this.osm.searchNominatim(query, lang)); return { places, source: 'openstreetmap' }; } @@ -2124,8 +1579,7 @@ export class MapsService { } const osmLang = lang.split('-')[0].toLowerCase(); - const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit)); - return { places: overpassNearbyRecords(elements, origin, osmLang, limit), source: 'openstreetmap' }; + return { places: await this.osm.nearby(origin, radius, limit, osmLang), source: 'openstreetmap' }; } // ── Autocomplete (Google or Nominatim fallback) ──────────────────────────── @@ -2236,7 +1690,7 @@ export class MapsService { } if (!apiKey) { - return this.autocompleteNominatim(input, lang); + return this.osm.autocompleteNominatim(input, lang); } const google = this.googlePlaces.provider({ key: apiKey, source: keySource, userId }); @@ -2244,30 +1698,6 @@ export class MapsService { return { suggestions, source: 'google' }; } - private async autocompleteNominatim( - input: string, - lang?: string, - ): Promise { - try { - const places = await this.searchNominatim(input, lang); - const suggestions = places - .filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '') - .slice(0, 5) - .map((p) => { - const parts = (p.address || '').split(',').map((s) => s.trim()); - return { - placeId: p.osm_id, - mainText: p.name || parts[0] || '', - secondaryText: parts.slice(1).join(', '), - }; - }); - return { suggestions, source: 'nominatim' }; - } catch (err) { - console.error('Nominatim autocomplete failed:', err); - return { suggestions: [], source: 'nominatim' }; - } - } - // ── Place details (Google or OSM) ────────────────────────────────────────── async getPlaceDetails( @@ -2296,7 +1726,7 @@ export class MapsService { // 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, { + const osm = await this.osm.resolveOsmIdentity(found.name, found.lat, found.lng, { lang, maxDistanceM: 150, }).catch(() => null); @@ -2364,8 +1794,8 @@ export class MapsService { // buildOsmDetails never yields name/address/coordinates — Nominatim is // always the source for those (Overpass contributes the tag-derived rest). const [element, nominatim] = await Promise.all([ - this.fetchOverpassDetails(osmType, osmId), - this.lookupNominatim(osmType, osmId, lang), + this.osm.fetchOverpassDetails(osmType, osmId), + this.osm.lookupNominatim(osmType, osmId, lang), ]); // Overpass has the fuller tag set and wins where both answer, but it is // also the one that goes down — overpass-api.de is regularly overloaded. @@ -2658,22 +2088,7 @@ export class MapsService { } } - const params = new URLSearchParams({ - lat, - lon: lng, - format: 'json', - addressdetails: '1', - zoom: opts?.locality ? '10' : '18', - 'accept-language': toApiLang(lang), - }); - const response = await nominatimFetch('reverse', params, opts); - if (!response.ok) return { name: null, address: null }; - const data = (await response.json()) as { name?: string; display_name?: string; address?: Record }; - const addr = data.address || {}; - const name = opts?.locality - ? addr.city || addr.town || addr.village || addr.municipality || data.name || null - : data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; - return { name, address: data.display_name || null }; + return this.osm.reverse(lat, lng, lang, opts); } // ── Resolve Google Maps URL ──────────────────────────────────────────────── @@ -2783,14 +2198,7 @@ export class MapsService { // Reverse geocode to get address. A non-ok answer (Nominatim 5xx/429) must // not fail the whole resolution — the coordinates are already extracted, so // fall back to the URL-derived name and a null address. - const nominatimRes = await nominatimFetch( - 'reverse', - new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }), - { timeoutMs: 8000 }, - ); - const nominatim: { display_name?: string; name?: string; address?: Record } = nominatimRes.ok - ? await nominatimRes.json() - : {}; + const nominatim = await this.osm.reverseRaw(lat, lng); const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; const address = nominatim.display_name || null; diff --git a/server/src/nest/maps/providers/osm.client.ts b/server/src/nest/maps/providers/osm.client.ts new file mode 100644 index 000000000..340b142ff --- /dev/null +++ b/server/src/nest/maps/providers/osm.client.ts @@ -0,0 +1,599 @@ +/** + * OpenStreetMap: Nominatim for names and coordinates, Overpass for tags and + * points of interest. + * + * The keyless floor every install falls back to, and deliberately NOT a + * PlacesProvider (see places-provider.ts): it has no credential, and its + * details path is a Nominatim/Overpass merge rather than one lookup. MapsService + * decides when OpenStreetMap is asked; this client only knows how to ask it, + * through the shared Nominatim client (one throttle for the whole process) and + * the Overpass mirrors the operator configured. + * + * The POI cache and the mirror list are module state on purpose, frozen at load + * the way they were in the service ("frozen on purpose", see + * src/app-config/README.md), so every instance shares one cache. + * + * Never imports MapsService: providers sit below the orchestrator + * (lint:boundaries holds that). + */ +import { Injectable } from '@nestjs/common'; +import { normalizePlaceWebsite } from '@trek/shared'; +import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client'; +import { + UA, + toApiLang, + CATEGORY_OSM_FILTERS, + parsePoiCategories, + clampPoiBbox, + resolveOverpassEndpoints, + resolveOverpassTimeoutMs, + OVERPASS_QUERY_TIMEOUT_S, + haversineMetres, + namesOverlap, + readChargingInfo, + type OverpassPoi, +} from '../maps.helpers'; +import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers'; +import { readWikiIdentity } from './wiki-identity'; + +function nominatimCategory(item: { class?: string; type?: string }): string | null { + if (!item.class || !item.type || item.type === 'yes') return null; + return item.class === 'shop' ? `shop_${item.type}` : item.type; +} + +interface NominatimResult { + osm_type: string; + osm_id: string; + /** The main OSM tag, key and value: `class` "tourism" with `type` "hotel". */ + class?: string; + type?: string; + name?: string; + display_name?: string; + lat: string; + lon: string; + extratags?: Record | null; +} + +export interface OverpassElement { + tags?: Record; +} + +interface OverpassPoiElement { + type: string; + id: number; + lat?: number; + lon?: number; + center?: { lat: number; lon: number }; + tags?: Record; +} + +export interface PoiSearchResult { + pois: OverpassPoi[]; + /** + * Which index answered. Not decoration: this branch calls naming the sources + * a licence obligation rather than a courtesy, and Overture carries more than + * OpenStreetMap under more than one licence, so stamping its rows as OSM is a + * wrong attribution rather than a rounding of one. + */ + source: 'openstreetmap' | 'trek-places'; + truncated: boolean; + // True when the requested viewport was too large and got shrunk to a centred + // window before querying — the results then cover the middle of the view only. + clamped: boolean; +} + +/** A Nominatim reverse answer as it came, for the callers that pick their own fields. */ +export interface NominatimReverse { + display_name?: string; + name?: string; + address?: Record; +} + +// Tighter than the wiki calls, because this one sits at the FRONT of a chain: +// identity, then sitelinks, then the extract. Nominatim answers a bounded +// search in 0.2-0.7s in practice, so anything past a couple of seconds is a bad +// day at the provider rather than a slow answer worth waiting for. +const IDENTITY_TIMEOUT_MS = 2500; + +// The explicit search asks Nominatim alongside the index, and the pair costs +// the slower one. The index gives up after 3.5 s and the browser after 8 s, so a +// Nominatim that accepts the connection and then sits on it (the public service +// under load, a self-hosted one that hung) has to give up in between: without a +// deadline of its own the request rides undici's 300 s default, and the index's +// answer is thrown away with the request that timed out waiting for it. The +// deadline starts after the throttle wait, so it measures the answer alone: +// generous for a slow one, still inside the browser's budget with the wait +// added on. +const SEARCH_TIMEOUT_MS = 6000; + +// Frozen at module load, same timing as the legacy service ("frozen on purpose", +// see src/app-config/README.md). +const OVERPASS_MIRRORS = resolveOverpassEndpoints(); +const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs(); +// Short-lived cache so panning back over / re-toggling the same area doesn't +// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox. +const POI_CACHE = new Map(); +const POI_CACHE_TTL_MS = 5 * 60 * 1000; +// Cap the number of cached areas so panning across the globe can't grow the map +// without bound (entries are evicted oldest-first once the cap is reached). +const POI_CACHE_MAX = 500; +/** + * Hard ceiling on one POI answer, however many categories it carries. A corridor search + * asks for four kinds at once; without a ceiling a dense city box would return a payload + * nobody reads and every mirror pays for. + */ +export const POI_RESULT_CAP = 240; + +// POST the query to all mirrors at once and return the first one that answers with +// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than +// trying one-by-one) keeps latency at the fastest reachable mirror instead of the +// sum of every dead mirror's timeout. +async function overpassFetch(query: string): Promise { + const body = `data=${encodeURIComponent(query)}`; + const controllers: AbortController[] = []; + + const attempt = async (url: string): Promise => { + const ctrl = new AbortController(); + controllers.push(ctrl); + const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: ctrl.signal, + }); + if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`); + const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string }; + // Overpass signals an internal timeout / runtime error via `remark` while + // still answering HTTP 200 — often fast, with an empty or partial element + // set. Treat that as a failed attempt so a healthy mirror wins the race + // instead of this fast-but-empty answer, and so the all-mirrors-failed path + // still surfaces a real error to the client instead of a silent "no places". + if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`); + if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`); + return data.elements; + } finally { + clearTimeout(timer); + } + }; + + try { + // Promise.any resolves with the first mirror to return valid JSON, and only + // rejects (AggregateError) once every mirror has failed. + return await Promise.any(OVERPASS_MIRRORS.map(attempt)); + } catch (err) { + // Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM + // body, …) so an operator can tell blocked egress / a firewall from a transiently + // overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309). + const reasons = + err instanceof AggregateError + ? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ') + : err instanceof Error + ? err.message + : String(err); + console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`); + throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 }); + } finally { + // Cancel the slower/losing requests — we already have (or have given up on) a result. + controllers.forEach((c) => { + try { + c.abort(); + } catch { + /* noop */ + } + }); + } +} + +@Injectable() +export class OsmClient { + // ── Nominatim search ─────────────────────────────────────────────────────── + + /** + * `lane` defaults to interactive because most callers are a keystroke. + * + * Bulk callers must pass 'background': booking-import geocodes every venue and + * every uncoordinated endpoint of an import in one request loop, which is up + * to thirty sequential calls. On the interactive lane those thirty take the + * next slot each time, so somebody typing in the place search waits behind the + * whole import. Yielding does not make the import faster, it stops it from + * being the only thing the process will do for half a minute. + */ + async searchNominatim(query: string, lang?: string, lane: GeoLane = 'interactive', bias?: { lat: number; lng: number }) { + const params = new URLSearchParams({ + q: query, + format: 'json', + addressdetails: '1', + // Free, same request: this is where a place's wikidata/wikipedia/commons + // tags live. Without them the enrichment column can only fall back to + // "photos taken within 300m", which around a city centre is passers-by + // and the neighbouring building. + extratags: '1', + limit: '10', + 'accept-language': toApiLang(lang), + }); + // Prefer the area the caller is looking at, never restrict to it + // (bounded=0). Without this, "Hase-dera" returns the temple of that name in + // Nara rather than the one in Kamakura the user is standing next to; with + // bounded=1 a search for somewhere genuinely far away would return nothing. + if (bias) { + const d = 0.5; + params.set('viewbox', [bias.lng - d, bias.lat - d, bias.lng + d, bias.lat + d].join(',')); + params.set('bounded', '0'); + } + // Through the shared client: one throttle for the whole process. + const response = await nominatimFetch('search', params, { lane, timeoutMs: SEARCH_TIMEOUT_MS }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error( + `Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`, + ); + } + const data = (await response.json()) as NominatimResult[]; + return data.map((item) => { + // Number.isFinite, not `|| null`: a place on the equator or prime + // meridian has a legitimate 0 coordinate. + const lat = Number.parseFloat(item.lat); + const lng = Number.parseFloat(item.lon); + return { + google_place_id: null, + google_ftid: null, + osm_id: `${item.osm_type}:${item.osm_id}`, + name: item.name || item.display_name?.split(',')[0] || '', + address: item.display_name || '', + lat: Number.isFinite(lat) ? lat : null, + lng: Number.isFinite(lng) ? lng : null, + rating: null, + website: null, + phone: null, + // What the place is, so the add form can preselect a category (#2282). A shop's + // value is the ware ("bakery"), so the key goes first and says it is a shop. + category: nominatimCategory(item), + source: 'openstreetmap', + ...readWikiIdentity(item.extratags), + }; + }); + } + + /** Suggestions out of a Nominatim search, for an install with neither the index nor a key. */ + async autocompleteNominatim(input: string, lang?: string): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> { + try { + const places = await this.searchNominatim(input, lang); + const suggestions = places + .filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '') + .slice(0, 5) + .map((p) => { + const parts = (p.address || '').split(',').map((s) => s.trim()); + return { + placeId: p.osm_id, + mainText: p.name || parts[0] || '', + secondaryText: parts.slice(1).join(', '), + }; + }); + return { suggestions, source: 'nominatim' }; + } catch (err) { + console.error('Nominatim autocomplete failed:', err); + return { suggestions: [], source: 'nominatim' }; + } + } + + /** + * Finds the OpenStreetMap record for a place we only know by name and + * coordinate, and hands back its tags. + * + * This is what gives a Google place a free identity. Google's payload has no + * `wikidata`, no `wikipedia` and no `wikimedia_commons` — it never had — so + * without this the entire free half of the enrichment column is unreachable + * for anyone who configured a Google key, which is the opposite of how this + * feature is meant to work. OSM knows these places perfectly well; nobody was + * asking it. + * + * Two gates keep it from describing the wrong building, because a confident + * description of somewhere else is worse than none: + * - the match has to be within `maxDistanceM` of where we are looking, and + * - it has to share a substantial word with the name we are looking for. + * Among what survives, Nominatim's own `importance` decides — that is what + * separates the Brandenburg Gate from the underground station named after it. + */ + async resolveOsmIdentity( + name: string, + lat: number, + lng: number, + opts: { lang?: string; maxDistanceM?: number; signal?: AbortSignal } = {}, + ): Promise<{ tags: Record; osmUrl: string | null; matchedName: string } | null> { + const query = (name || '').trim(); + if (!query || !Number.isFinite(lat) || !Number.isFinite(lng)) return null; + const maxDistanceM = opts.maxDistanceM ?? 2000; + + // ~2km box around the point, so Nominatim ranks locally instead of handing + // back the most famous place on earth with this name. + const d = 0.02; + const params = new URLSearchParams({ + q: query, + format: 'jsonv2', + extratags: '1', + limit: '5', + bounded: '1', + viewbox: `${lng - d},${lat - d},${lng + d},${lat + d}`, + 'accept-language': toApiLang(opts.lang), + }); + + try { + // The caller's deadline is handed in rather than built here, so the + // throttle wait cannot eat it before the request starts. + const res = await nominatimFetch('search', params, { + signal: opts.signal, + timeoutMs: IDENTITY_TIMEOUT_MS, + }); + if (!res.ok) return null; + // Nominatim answers rate limiting in plain text, not JSON. + const data = (await res.json()) as (NominatimResult & { importance?: number })[]; + if (!Array.isArray(data)) return null; + + const best = data + .map((item) => ({ + item, + lat: Number.parseFloat(item.lat), + lng: Number.parseFloat(item.lon), + })) + .filter(({ item, lat: hitLat, lng: hitLng }) => { + if (!Number.isFinite(hitLat) || !Number.isFinite(hitLng)) return false; + if (haversineMetres(lat, lng, hitLat, hitLng) > maxDistanceM) return false; + const label = item.name || item.display_name?.split(',')[0] || ''; + return namesOverlap(query, label); + }) + .sort((a, b) => { + const byImportance = (b.item.importance ?? 0) - (a.item.importance ?? 0); + if (byImportance !== 0) return byImportance; + return haversineMetres(lat, lng, a.lat, a.lng) - haversineMetres(lat, lng, b.lat, b.lng); + })[0]; + + if (!best) return null; + return { + tags: best.item.extratags ?? {}, + osmUrl: + best.item.osm_type && best.item.osm_id + ? `https://www.openstreetmap.org/${best.item.osm_type}/${best.item.osm_id}` + : null, + matchedName: best.item.name || best.item.display_name?.split(',')[0] || query, + }; + } catch { + return null; + } + } + + // ── Nominatim lookup (by OSM ID) ─────────────────────────────────────────── + + async lookupNominatim( + osmType: string, + osmId: string, + lang?: string, + ): Promise<{ + name: string; + address: string; + lat: number | null; + lng: number | null; + extratags: Record | null; + } | null> { + const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R + const params = new URLSearchParams({ + osm_ids: `${typePrefix}${osmId}`, + format: 'json', + // Overpass is the richer source but it is also the one that times out; + // whatever Nominatim already knows costs nothing extra here. + extratags: '1', + 'accept-language': toApiLang(lang), + }); + try { + const res = await nominatimFetch('lookup', params); + if (!res.ok) return null; + const data = (await res.json()) as NominatimResult[]; + const item = data[0]; + if (!item) return null; + const lat = Number.parseFloat(item.lat); + const lng = Number.parseFloat(item.lon); + return { + name: item.name || item.display_name?.split(',')[0] || '', + address: item.display_name || '', + lat: Number.isFinite(lat) ? lat : null, + lng: Number.isFinite(lng) ? lng : null, + extratags: item.extratags ?? null, + }; + } catch { + return null; + } + } + + // ── Nominatim reverse ────────────────────────────────────────────────────── + + /** The name and address Nominatim gives a point, or nulls when it answers with an error. */ + async reverse( + lat: string, + lng: string, + lang?: string, + opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean }, + ): Promise<{ name: string | null; address: string | null }> { + const params = new URLSearchParams({ + lat, + lon: lng, + format: 'json', + addressdetails: '1', + zoom: opts?.locality ? '10' : '18', + 'accept-language': toApiLang(lang), + }); + const response = await nominatimFetch('reverse', params, opts); + if (!response.ok) return { name: null, address: null }; + const data = (await response.json()) as NominatimReverse; + const addr = data.address || {}; + const name = opts?.locality + ? addr.city || addr.town || addr.village || addr.municipality || data.name || null + : data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; + return { name, address: data.display_name || null }; + } + + /** + * The raw reverse answer for a point a pasted link carried, in Nominatim's + * default language. A non-ok answer (5xx, 429) is an empty object: the + * coordinates are already known, so the caller still has something to return. + */ + async reverseRaw(lat: number, lng: number): Promise { + const res = await nominatimFetch( + 'reverse', + new URLSearchParams({ lat: String(lat), lon: String(lng), format: 'json', addressdetails: '1' }), + { timeoutMs: 8000 }, + ); + return res.ok ? ((await res.json()) as NominatimReverse) : {}; + } + + // ── Overpass API (OSM details) ───────────────────────────────────────────── + + async fetchOverpassDetails(osmType: string, osmId: string): Promise { + const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; + const oType = typeMap[osmType]; + if (!oType) return null; + // The id is the one thing written into the query, and the query is a + // language. An OSM element id is a number and nothing else; anything with + // more in it is a statement of its own, sent under TREK's shared user agent. + if (!/^\d+$/.test(osmId)) return null; + const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`; + try { + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(query)}`, + // The query asks Overpass for 5 s; a public instance that is overloaded + // takes the connection and then sits on it. + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return null; + const data = (await res.json()) as { elements?: OverpassElement[] }; + return data.elements?.[0] || null; + } catch { + return null; + } + } + + /** Places of any kind around a point, from the Overpass mirrors, nearest first (#976). */ + async nearby(origin: { lat: number; lng: number }, radius: number, limit: number, osmLang: string): Promise[]> { + const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit)); + return overpassNearbyRecords(elements, origin, osmLang, limit); + } + + // ── Overpass POI search (by category within a viewport bbox) ─────────────── + // Powers the "explore places on the map" pill. OSM-ONLY by design — this never + // calls Google, even when a Google key is configured. + + async searchOverpassPois( + category: string, + bbox: { south: number; west: number; north: number; east: number }, + lang?: string, + limit = 60, + ): Promise { + // One or several categories in one query. Each OSM selector belongs to exactly one + // category, so a hit can still be labelled with the category it answered. + const categories = parsePoiCategories(category); + if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 }); + const categoryOfFilter = new Map(); + for (const key of categories) { + const own = CATEGORY_OSM_FILTERS[key]; + if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 }); + for (const f of own) categoryOfFilter.set(f, key); + } + const filters = [...categoryOfFilter.keys()]; + // Each category gets its own share of the cap, or a mixed search would spend the + // whole budget on whichever kind happens to be densest. + const cap = Math.min(limit * categories.length, POI_RESULT_CAP); + + // Clamp an oversized viewport to a centred window so the query stays cheap and + // returns fast at any zoom, instead of timing out / 502-ing on a huge area. + const searchWindow = clampPoiBbox(bbox); + const { south, west, north, east } = searchWindow.bbox; + const clamped = searchWindow.clamped; + + // OSM `name:*` tags are keyed by language subtag: prefer the user's language + // (the same localization the search/autocomplete path asks the geocoder for) + // over the native `name`. `int_name` is OSM's international/romanized name — a + // sensible fallback before the native one. Part of the cache key so a cached + // area isn't served with another language's titles. + const osmLang = toApiLang(lang).split('-')[0].toLowerCase(); + + // Serve repeat pans/toggles of the same area straight from the cache. + const cacheKey = `${[...categories].sort((a, b) => a.localeCompare(b)).join('+')}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${cap}`; + const cached = POI_CACHE.get(cacheKey); + if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value; + if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching + + // Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng). + const box = `(${south},${west},${north},${east})`; + const selectors = filters + .map((f) => { + const [k, v] = f.split('='); + return ` nwr["${k}"="${v}"]${box};`; + }) + .join('\n'); + // `out center tags ` returns ways/relations with a computed center and caps + // the result count in one round-trip. + const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`; + + const elements = await overpassFetch(query); + + const pois: OverpassPoi[] = []; + for (const el of elements) { + const tags = el.tags || {}; + // `operator` comes last but matters for the road categories: petrol stations, + // charging points and service areas are routinely mapped with an operator and no + // name, and dropping those would empty the road trip corridor over long stretches. + const name = + tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null; + if (!name) continue; // unnamed POIs aren't useful to add to a plan + // A shut-down place is not somewhere to plan a visit (#1341). OSM usually + // re-tags one with a `disused:`/`abandoned:` prefix, and those never match + // the selectors above — but plenty keep their original tag and gain a marker + // instead, and those do come back. `opening_hours=closed`/`off` is the same + // statement in the hours field. + if (tags.disused === 'yes' || tags.abandoned === 'yes') continue; + if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue; + const lat = el.lat ?? el.center?.lat; + const lng = el.lon ?? el.center?.lon; + if (lat == null || lng == null) continue; + const matched = + filters.find((f) => { + const [k, v] = f.split('='); + return tags[k] === v; + }) || filters[0]; + const addr = + [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']] + .filter(Boolean) + .join(' ') || null; + pois.push({ + osm_id: `${el.type}:${el.id}`, + name, + lat, + lng, + category: categoryOfFilter.get(matched) ?? categories[0], + poi_type: matched, + address: addr, + website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']), + phone: tags.phone || tags['contact:phone'] || null, + opening_hours: tags.opening_hours || null, + cuisine: tags.cuisine || null, + brand: tags.brand || tags.operator || null, + // Only the plain Q-id form is passed on; anything else would be a lookup we + // would have to guess at. + brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null, + // Only where it means something. Every POI carries `capacity` and `fee` for its + // own reasons — a restaurant's capacity is seats — so reading them as charging + // data anywhere else would be wrong on most of the map. + charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null, + source: 'openstreetmap', + }); + } + const truncated = pois.length > cap; + const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped }; + // FIFO eviction: a Map preserves insertion order, so the first key is the oldest. + if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string); + POI_CACHE.set(cacheKey, { at: Date.now(), value }); + return value; + } +} diff --git a/server/src/nest/maps/providers/wiki-identity.ts b/server/src/nest/maps/providers/wiki-identity.ts new file mode 100644 index 000000000..5553ca6fd --- /dev/null +++ b/server/src/nest/maps/providers/wiki-identity.ts @@ -0,0 +1,61 @@ +/** + * Which encyclopaedia entry, Wikidata item and Commons category describe a + * place, read off its OpenStreetMap tags. + * + * Shared by the OSM client (a Nominatim hit carries the tags in `extratags`) + * and the enrichment column (which follows them to Wikimedia), so it lives + * apart from both. + */ + +/** + * The keys that say which encyclopaedia entry, Wikidata item and Commons + * category describe a place. Everything the enrichment column shows beyond + * coordinates hangs off one of these. + * + * Bare keys only. OSM also carries `brand:wikidata` / `brand:wikipedia`, and + * following those means a branch of a chain gets the chain's article and the + * chain's logo — for "L'Osteria Rostock" you would confidently describe + * L'Osteria the company. That is the exact failure the tag-only rule was + * written to avoid, so do not "improve" this by falling back to brand:*. + */ +const WIKI_IDENTITY_TAGS = ['wikipedia', 'wikidata', 'wikimedia_commons'] as const; + +export interface WikiIdentity { + wikipedia: string | null; + wikidata: string | null; + wikimedia_commons: string | null; +} + +/** + * The chain a place belongs to, when it belongs to one. + * + * Read separately from `readWikiIdentity` and never mixed into it. Following + * `brand:wikidata` as if it described the place is how "L'Osteria Rostock" + * ends up illustrated with the company logo and described as a franchise + * operator — which is why the picture ladder never sees these. For a + * description they are still worth something: a branch of a chain has no + * article of its own and never will, and "L'Osteria is a German restaurant + * chain serving pizza and pasta" beats an empty column, as long as the reader + * is told that is what they are looking at. + */ +export function readBrandIdentity(extratags: Record | null | undefined): { + wikidata: string | null; + wikipedia: string | null; +} { + const read = (key: string): string | null => { + const value = extratags?.[key]; + return typeof value === 'string' && value.trim() ? value.trim() : null; + }; + return { wikidata: read('brand:wikidata'), wikipedia: read('brand:wikipedia') }; +} + +/** Picks the three identity tags out of a Nominatim `extratags` blob. */ +export function readWikiIdentity(extratags: Record | null | undefined): WikiIdentity { + const out: WikiIdentity = { wikipedia: null, wikidata: null, wikimedia_commons: null }; + if (!extratags) return out; + for (const tag of WIKI_IDENTITY_TAGS) { + const value = extratags[tag]; + if (typeof value === 'string' && value.trim()) out[tag] = value.trim(); + } + return out; +} diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index 0d47c376d..31309d21b 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -28,6 +28,7 @@ import { import { buildOsmDetails, isGooglePlaceId, parseWikipediaTag, rankCommonsCandidates, toWikiLang } from '../maps/maps.helpers'; import { trekPlacesById } from '../maps/trek-places.client'; import { GooglePlacesClient } from '../maps/providers/google-places.provider'; +import { OsmClient } from '../maps/providers/osm.client'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; import { readAppSetting } from '../common/app-settings.registry'; @@ -325,6 +326,7 @@ export class PlaceEnrichmentService { private readonly maps: MapsService, private readonly photoCache: PlacePhotoCacheService, private readonly googlePlaces: GooglePlacesClient, + private readonly osm: OsmClient, ) {} /** @@ -442,7 +444,7 @@ export class PlaceEnrichmentService { }; if (carried.wikipedia || carried.wikidata || carried.wikimedia_commons) return carried; - const resolved = await this.maps.resolveOsmIdentity(req.name, req.lat, req.lng, { lang: req.lang }); + const resolved = await this.osm.resolveOsmIdentity(req.name, req.lat, req.lng, { lang: req.lang }); if (!resolved) return carried; const brand = readBrandIdentity(resolved.tags); return { diff --git a/server/tests/e2e/place-enrichment.e2e.test.ts b/server/tests/e2e/place-enrichment.e2e.test.ts index 7ba00366f..3b210b17e 100644 --- a/server/tests/e2e/place-enrichment.e2e.test.ts +++ b/server/tests/e2e/place-enrichment.e2e.test.ts @@ -32,6 +32,7 @@ vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: ( import { PlaceEnrichmentModule } from '../../src/nest/place-enrichment/place-enrichment.module'; import { candidateKey } from '../../src/nest/place-enrichment/place-enrichment.service'; import { MapsService } from '../../src/nest/maps/maps.service'; +import { OsmClient } from '../../src/nest/maps/providers/osm.client'; import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; import { RateLimitService } from '../../src/nest/common/rate-limit.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -49,6 +50,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => let server: Server; let app: Awaited>; let maps: MapsService; + let osm: OsmClient; let orm: TestOrm; async function build() { @@ -70,6 +72,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => server = app.getHttpServer(); maps = app.get(MapsService); + osm = app.get(OsmClient); const photoCache = app.get(PlacePhotoCacheService); vi.spyOn(photoCache, 'get').mockReturnValue(null); vi.spyOn(photoCache, 'put').mockImplementation(async (key: string, _b: Buffer, attribution: string | null) => ({ @@ -97,7 +100,7 @@ describe('Place enrichment e2e (real auth guard + real validation pipe)', () => // Every provider the service can reach has to be stubbed here, not just the // ones a given case cares about: anything left open goes out over the real // network from CI, which is both slow and rude to the provider. - vi.spyOn(maps, 'resolveOsmIdentity').mockClear().mockResolvedValue(null); + vi.spyOn(osm, 'resolveOsmIdentity').mockClear().mockResolvedValue(null); vi.spyOn(maps, 'fetchWikidataSitelinks').mockClear().mockResolvedValue({}); vi.spyOn(maps, 'fetchWikiExtractFor').mockClear().mockResolvedValue(null); vi.spyOn(maps, 'fetchWikidataCandidates').mockClear().mockResolvedValue({ candidates: [], commonsCategory: null }); diff --git a/server/tests/helpers/maps-service.ts b/server/tests/helpers/maps-service.ts index 5cb3621b4..ba460a756 100644 --- a/server/tests/helpers/maps-service.ts +++ b/server/tests/helpers/maps-service.ts @@ -1,5 +1,6 @@ import { MapsService } from '../../src/nest/maps/maps.service'; import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider'; +import { OsmClient } from '../../src/nest/maps/providers/osm.client'; import type { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository'; import type { UsersRepository } from '../../src/db/repositories/Users.repository'; @@ -12,6 +13,7 @@ import { noGoogleQuota } from './google-quota'; export interface MapsParts { svc: MapsService; google: GooglePlacesClient; + osm: OsmClient; } /** @@ -29,8 +31,9 @@ export function buildMapsParts( googleQuota: GoogleQuotaService = noGoogleQuota, ): MapsParts { const google = new GooglePlacesClient(googleQuota); - const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google); - return { svc, google }; + const osm = new OsmClient(); + const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google, osm); + return { svc, google, osm }; } export function buildMapsService( diff --git a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts index 1e76cdbeb..3bc0cb307 100644 --- a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts +++ b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts @@ -50,6 +50,7 @@ import { findRow } from '../../helpers/factories/rows'; import { makeTag } from '../../helpers/factories/places'; import { Tags } from '../../../src/db/entities/Tags.entity'; import { MapsService } from '../../../src/nest/maps/maps.service'; +import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { getWeather, getDetailedWeather } from '../../../src/nest/weather/weather.impl'; // The geo tools live on the DI-discovered maps.mcp.ts since the maps fold; the @@ -67,7 +68,7 @@ vi.spyOn(MapsService.prototype, 'getPlaceDetailsExpanded').mockResolvedValue({ } as never); // Overpass is stubbed one level below the facade so MapsService.pois() itself // still runs, the way the geo tools reach it. -vi.spyOn(MapsService.prototype, 'searchOverpassPois').mockResolvedValue({ +vi.spyOn(OsmClient.prototype, 'searchOverpassPois').mockResolvedValue({ pois: [{ osm_id: 'node:1', name: 'Chez Nous', lat: 48.86, lng: 2.34, category: 'restaurant' }], source: 'openstreetmap', truncated: false, @@ -458,7 +459,7 @@ describe('Tool: search_pois', () => { // back as an empty POI list. it('returns the POIs of a category inside the bbox', async () => { const { user } = createUser(testDb); - vi.mocked(MapsService.prototype.searchOverpassPois).mockClear(); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear(); trekNearbyMock.mockClear(); await withHarness(user.id, async (h) => { @@ -472,7 +473,7 @@ describe('Tool: search_pois', () => { expect(data.source).toBe('openstreetmap'); expect(trekNearbyMock).toHaveBeenCalled(); // The caller's per-category budget rides along on the fallback too. - expect(MapsService.prototype.searchOverpassPois).toHaveBeenCalledWith('restaurant', BBOX, 'fr', 60); + expect(OsmClient.prototype.searchOverpassPois).toHaveBeenCalledWith('restaurant', BBOX, 'fr', 60); }); }); @@ -481,7 +482,7 @@ describe('Tool: search_pois', () => { // places; where they were read is this method's business, not the pill's. it('answers from the TREK Places index and leaves Overpass alone', async () => { const { user } = createUser(testDb); - vi.mocked(MapsService.prototype.searchOverpassPois).mockClear(); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear(); trekNearbyMock.mockClear(); trekNearbyMock.mockResolvedValueOnce([ { @@ -514,7 +515,7 @@ describe('Tool: search_pois', () => { expect(data.source).toBe('trek-places'); expect(data.pois[0].source).toBe('trek-places'); expect(data.clamped).toBe(false); - expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled(); + expect(OsmClient.prototype.searchOverpassPois).not.toHaveBeenCalled(); // The bbox becomes a centre plus half its diagonal, and the category // becomes the Overture terms it maps to. @@ -554,12 +555,12 @@ describe('Tool: search_pois', () => { it('passes no language through when none is given', async () => { const { user } = createUser(testDb); - vi.mocked(MapsService.prototype.searchOverpassPois).mockClear(); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear(); trekNearbyMock.mockClear(); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'search_pois', arguments: { category: 'museum', bbox: BBOX } }); - expect(MapsService.prototype.searchOverpassPois).toHaveBeenCalledWith('museum', BBOX, undefined, 60); + expect(OsmClient.prototype.searchOverpassPois).toHaveBeenCalledWith('museum', BBOX, undefined, 60); }); }); @@ -567,7 +568,7 @@ describe('Tool: search_pois', () => { // mapping never reaches Overpass in the first place. it('refuses a category that has no OSM mapping', async () => { const { user } = createUser(testDb); - vi.mocked(MapsService.prototype.searchOverpassPois).mockClear(); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -575,13 +576,13 @@ describe('Tool: search_pois', () => { arguments: { category: 'dentist', bbox: BBOX }, }); expect(result.isError).toBe(true); - expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled(); + expect(OsmClient.prototype.searchOverpassPois).not.toHaveBeenCalled(); }); }); it('refuses a bbox edge outside the coordinate range', async () => { const { user } = createUser(testDb); - vi.mocked(MapsService.prototype.searchOverpassPois).mockClear(); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockClear(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ @@ -589,13 +590,13 @@ describe('Tool: search_pois', () => { arguments: { category: 'cafe', bbox: { ...BBOX, north: 118 } }, }); expect(result.isError).toBe(true); - expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled(); + expect(OsmClient.prototype.searchOverpassPois).not.toHaveBeenCalled(); }); }); it('answers isError when every Overpass mirror is unreachable', async () => { const { user } = createUser(testDb); - vi.mocked(MapsService.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed')); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed')); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'search_pois', arguments: { category: 'bar', bbox: BBOX } }); @@ -611,7 +612,7 @@ describe('Tool: search_pois', () => { const { user } = createUser(testDb); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); trekNearbyMock.mockRejectedValueOnce(new Error('index down')); - vi.mocked(MapsService.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed')); + vi.mocked(OsmClient.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed')); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'search_pois', arguments: { category: 'bar', bbox: BBOX } }); diff --git a/server/tests/unit/nest/maps.amap.test.ts b/server/tests/unit/nest/maps.amap.test.ts index e9b944958..dff0faec5 100644 --- a/server/tests/unit/nest/maps.amap.test.ts +++ b/server/tests/unit/nest/maps.amap.test.ts @@ -62,6 +62,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( import { MapsService } from '../../../src/nest/maps/maps.service'; import { buildMapsService } from '../../helpers/maps-service'; +import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { trekPlacesSearch } from '../../../src/nest/maps/trek-places.client'; import { AmapPlacesProvider, @@ -948,7 +949,7 @@ describe('MapsService: Amap first inside China when picked outright (#1636)', () vi.mocked(trekPlacesSearch).mockClear(); }); function osmAnswers(places: Record[]) { - const spy = vi.spyOn(svc, 'searchNominatim').mockResolvedValue(places as never); + const spy = vi.spyOn(OsmClient.prototype, 'searchNominatim').mockResolvedValue(places as never); spies.push(spy); return spy; } diff --git a/server/tests/unit/nest/maps.gers-details.test.ts b/server/tests/unit/nest/maps.gers-details.test.ts index c24cf7629..77e64beed 100644 --- a/server/tests/unit/nest/maps.gers-details.test.ts +++ b/server/tests/unit/nest/maps.gers-details.test.ts @@ -18,7 +18,8 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; -import { buildMapsService } from '../../helpers/maps-service'; +import { buildMapsParts } from '../../helpers/maps-service'; +import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -45,9 +46,13 @@ const PLACE = { hours: null as { osm: string } | null, }; +/** The OSM client each service was built with, so a case can assert on what it was asked. */ +const osmOf = new WeakMap(); + function make(osmTags: Record | null) { - const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); - vi.spyOn(svc, 'resolveOsmIdentity').mockResolvedValue( + const { svc, osm } = buildMapsParts({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + osmOf.set(svc, osm); + vi.spyOn(osm, 'resolveOsmIdentity').mockResolvedValue( osmTags ? { tags: osmTags, osmUrl: 'https://www.openstreetmap.org/node/1', matchedName: "L'Osteria" } : null, ); return svc; @@ -111,8 +116,8 @@ describe('MapsService.getPlaceDetails for a gers: id', () => { // only adds what OSM knows about the same building. Letting its failure // through would turn a working answer into an error for the one user whose // details request happened to land while Overpass was unreachable. - const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); - vi.spyOn(svc, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down')); + const { svc, osm } = buildMapsParts({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + vi.spyOn(osm, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down')); mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } }); const out = await svc.getPlaceDetails(1, 'gers:abc-123'); @@ -151,6 +156,6 @@ describe('MapsService.getPlaceDetails for a gers: id', () => { await expect(svc.getPlaceDetails(1, 'gers:abc-123')).resolves.toEqual({ place: null }); expect(mockById).not.toHaveBeenCalled(); - expect(svc.resolveOsmIdentity).not.toHaveBeenCalled(); + expect(osmOf.get(svc)!.resolveOsmIdentity).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/nest/maps.google-provider.test.ts b/server/tests/unit/nest/maps.google-provider.test.ts index b207e5c8a..9a0bc81dc 100644 --- a/server/tests/unit/nest/maps.google-provider.test.ts +++ b/server/tests/unit/nest/maps.google-provider.test.ts @@ -27,7 +27,7 @@ describe('GooglePlacesProvider', () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const { client: c } = client(); - expect(await c.provider({ key: 'k', source: 'env', userId: 1 }).reverse()).toBeNull(); + expect(await c.provider({ key: 'k', source: 'instance', userId: 1 }).reverse()).toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/maps.identity.test.ts b/server/tests/unit/nest/maps.identity.test.ts index 24a0bae63..e5a0a7e25 100644 --- a/server/tests/unit/nest/maps.identity.test.ts +++ b/server/tests/unit/nest/maps.identity.test.ts @@ -19,11 +19,13 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ SsrfBlockedError: class extends Error {}, })); +import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { buildMapsService } from '../../helpers/maps-service'; -import { toWikiLang, haversineMetres, namesOverlap } from '../../../src/nest/maps/maps.helpers'; import { noGoogleQuota } from '../../helpers/google-quota'; +import { toWikiLang, haversineMetres, namesOverlap } from '../../../src/nest/maps/maps.helpers'; const svcOf = () => buildMapsService({} as never, {} as never, {} as never, {} as never, {} as never, noGoogleQuota); +const osmOf = () => new OsmClient(); // The Brandenburg Gate and the underground station named after it, 250m apart. const GATE = { lat: 52.5163, lng: 13.3777 }; @@ -120,7 +122,7 @@ describe('resolveOsmIdentity', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }); vi.stubGlobal('fetch', fetchMock); - await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng, { lang: 'de' }); + await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng, { lang: 'de' }); const url = decodeURIComponent(String(fetchMock.mock.calls[0][0])); // Without bounded+viewbox, Nominatim answers with the most famous place on @@ -133,7 +135,7 @@ describe('resolveOsmIdentity', () => { it('MAPS-166: hands back the tags of the best local match', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [hit()] })); - const out = await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng); + const out = await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng); expect(out?.tags.wikidata).toBe('Q82425'); expect(out?.osmUrl).toBe('https://www.openstreetmap.org/way/518071791'); @@ -152,7 +154,7 @@ describe('resolveOsmIdentity', () => { }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [station, hit()] })); - const out = await svcOf().resolveOsmIdentity('Brandenburger Tor', 52.5166, 13.3809); + const out = await osmOf().resolveOsmIdentity('Brandenburger Tor', 52.5166, 13.3809); expect(out?.tags.wikidata).toBe('Q82425'); }); @@ -161,7 +163,7 @@ describe('resolveOsmIdentity', () => { const faraway = hit({ lat: '48.8584', lon: '2.2945' }); // Paris vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [faraway] })); - expect(await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull(); + expect(await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull(); }); it('MAPS-169: refuses a match that shares no word with the name', async () => { @@ -170,24 +172,24 @@ describe('resolveOsmIdentity', () => { const neighbour = hit({ name: 'Hotel Adlon', display_name: 'Hotel Adlon, Berlin' }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [neighbour] })); - expect(await svcOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull(); + expect(await osmOf().resolveOsmIdentity('Brandenburger Tor', GATE.lat, GATE.lng)).toBeNull(); }); it('MAPS-170: survives an empty name, a bad response, a throw and a non-array body', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - expect(await svcOf().resolveOsmIdentity('', GATE.lat, GATE.lng)).toBeNull(); + expect(await osmOf().resolveOsmIdentity('', GATE.lat, GATE.lng)).toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => [] })); - expect(await svcOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull(); + expect(await osmOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull(); // Nominatim answers rate limiting in plain text, not JSON. vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => 'Bandwidth limit exceeded' })); - expect(await svcOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull(); + expect(await osmOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull(); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); - expect(await svcOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull(); + expect(await osmOf().resolveOsmIdentity('X Tor', GATE.lat, GATE.lng)).toBeNull(); }); }); diff --git a/server/tests/unit/nest/maps.pois.test.ts b/server/tests/unit/nest/maps.pois.test.ts index 985f22d14..b866ef010 100644 --- a/server/tests/unit/nest/maps.pois.test.ts +++ b/server/tests/unit/nest/maps.pois.test.ts @@ -26,7 +26,8 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; -import { buildMapsService } from '../../helpers/maps-service'; +import { buildMapsParts } from '../../helpers/maps-service'; +import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -117,13 +118,18 @@ afterEach(() => { function make(enabled = true) { if (enabled) delete process.env.TREK_PLACES_ENABLED; else process.env.TREK_PLACES_ENABLED = 'false'; - return buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + const { svc, osm } = buildMapsParts({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + osmOf.set(svc, osm); + return svc; } +/** The OSM client each service was built with: Overpass is asked through it. */ +const osmOf = new WeakMap(); + // Overpass is the one network call this file must never make; stubbing it is // also what turns "dropped through" into something a case can assert. function stubOverpass(svc: MapsService) { - return vi.spyOn(svc, 'searchOverpassPois').mockResolvedValue(OVERPASS_ANSWER); + return vi.spyOn(osmOf.get(svc)!, 'searchOverpassPois').mockResolvedValue(OVERPASS_ANSWER); } function rows(n: number) { diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index e3ae5fcfd..245e7389e 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -173,6 +173,7 @@ const photoCacheStub = { } as unknown as PlacePhotoCacheService; import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service'; +import { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { buildMapsParts, buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -231,7 +232,7 @@ const placesStub = { // through a repository stub that flows into the SAME mockDbGet/mockDbRun/ // mockInstanceGet/mockProviderGet functions, so they keep firing exactly as // they did for the legacy module. -const { svc, google } = buildMapsParts(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); +const { svc, google, osm } = buildMapsParts(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); /** * Switch the TREK Places index off for one case. @@ -882,7 +883,7 @@ describe('searchNominatim (fetch stubbed)', () => { ], }), ); - const results = await svc.searchNominatim('Paris'); + const results = await osm.searchNominatim('Paris'); expect(results).toHaveLength(1); expect((results[0] as any).address).toBe('Paris, France'); expect((results[0] as any).source).toBe('openstreetmap'); @@ -890,7 +891,7 @@ describe('searchNominatim (fetch stubbed)', () => { it('MAPS-030: throws on fetch failure', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); - await expect(svc.searchNominatim('fail')).rejects.toThrow(); + await expect(osm.searchNominatim('fail')).rejects.toThrow(); }); it('MAPS-030b: throws when nominatim response is not ok', async () => { @@ -903,7 +904,7 @@ describe('searchNominatim (fetch stubbed)', () => { text: async () => '', }), ); - await expect(svc.searchNominatim('fail')).rejects.toThrow('Nominatim API error'); + await expect(osm.searchNominatim('fail')).rejects.toThrow('Nominatim API error'); }); it('MAPS-030c: falls back to display_name split when name is absent', async () => { @@ -914,7 +915,7 @@ describe('searchNominatim (fetch stubbed)', () => { json: async () => [{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' }], }), ); - const results = await svc.searchNominatim('London'); + const results = await osm.searchNominatim('London'); expect((results[0] as any).name).toBe('London'); }); @@ -929,7 +930,7 @@ describe('searchNominatim (fetch stubbed)', () => { ], }), ); - const results = await svc.searchNominatim('null island'); + const results = await osm.searchNominatim('null island'); expect((results[0] as any).lat).toBe(0); expect((results[0] as any).lng).toBe(0); expect((results[1] as any).lat).toBeNull(); @@ -949,7 +950,7 @@ describe('searchNominatim (fetch stubbed)', () => { ], }), ); - const results = await svc.searchNominatim('x'); + const results = await osm.searchNominatim('x'); expect(results.map((r: any) => r.category)).toEqual(['hotel', 'shop_bakery', null, null]); }); }); @@ -965,13 +966,13 @@ describe('fetchOverpassDetails (fetch stubbed)', () => { json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }), }), ); - const result = await svc.fetchOverpassDetails('way', '12345'); + const result = await osm.fetchOverpassDetails('way', '12345'); expect(result).toBeDefined(); expect((result as any).tags.name).toBe('Eiffel Tower'); }); it('MAPS-032: returns null for unknown osmType', async () => { - const result = await svc.fetchOverpassDetails('unknown', '12345'); + const result = await osm.fetchOverpassDetails('unknown', '12345'); expect(result).toBeNull(); }); @@ -983,20 +984,20 @@ describe('fetchOverpassDetails (fetch stubbed)', () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); for (const bad of ['1);nwr["amenity"](-90,-180,90,180', '12345;out geom', '', ' 1', '1e3', '-5']) { - expect(await svc.fetchOverpassDetails('node', bad), bad).toBeNull(); + expect(await osm.fetchOverpassDetails('node', bad), bad).toBeNull(); } expect(fetchMock).not.toHaveBeenCalled(); }); it('MAPS-033: returns null when fetch throws', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); - const result = await svc.fetchOverpassDetails('node', '99999'); + const result = await osm.fetchOverpassDetails('node', '99999'); expect(result).toBeNull(); }); it('MAPS-034: returns null when response is not ok', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); - const result = await svc.fetchOverpassDetails('node', '99999'); + const result = await osm.fetchOverpassDetails('node', '99999'); expect(result).toBeNull(); }); @@ -1008,7 +1009,7 @@ describe('fetchOverpassDetails (fetch stubbed)', () => { json: async () => ({ elements: [] }), }), ); - const result = await svc.fetchOverpassDetails('node', '1'); + const result = await osm.fetchOverpassDetails('node', '1'); expect(result).toBeNull(); }); }); @@ -1038,19 +1039,19 @@ describe('searchOverpassPois localized names (#1655)', () => { it('prefers name: for the user language over the native name', async () => { stubOverpass(tags); - const { pois } = await svc.searchOverpassPois('sights', bbox(1), 'en-US'); + const { pois } = await osm.searchOverpassPois('sights', bbox(1), 'en-US'); expect(pois[0].name).toBe('Elephant and Obelisk'); }); it('localizes to a non-English language too', async () => { stubOverpass(tags); - const { pois } = await svc.searchOverpassPois('sights', bbox(2), 'de-DE'); + const { pois } = await osm.searchOverpassPois('sights', bbox(2), 'de-DE'); expect(pois[0].name).toBe('Minerva-Obelisk'); }); it('falls back to int_name when the language tag is absent', async () => { stubOverpass({ name: tags.name, int_name: tags.int_name, tourism: 'attraction' }); - const { pois } = await svc.searchOverpassPois('sights', bbox(3), 'fr-FR'); + const { pois } = await osm.searchOverpassPois('sights', bbox(3), 'fr-FR'); expect(pois[0].name).toBe('Elephant Obelisk'); }); @@ -1069,13 +1070,13 @@ describe('searchOverpassPois localized names (#1655)', () => { }), }), ); - const { pois } = await svc.searchOverpassPois('sights', bbox(5), 'en-US'); + const { pois } = await osm.searchOverpassPois('sights', bbox(5), 'en-US'); expect(pois.map((p: any) => p.name)).toEqual(['Open For Business']); }); it('falls back to the native name when no localized tag exists', async () => { stubOverpass({ name: tags.name, tourism: 'attraction' }); - const { pois } = await svc.searchOverpassPois('sights', bbox(4), 'fr-FR'); + const { pois } = await osm.searchOverpassPois('sights', bbox(4), 'fr-FR'); expect(pois[0].name).toBe('Obelisco della Minerva'); }); }); @@ -2680,11 +2681,11 @@ describe('searchOverpassPois multi-category', () => { vi.stubGlobal('fetch', fetchMock); // One category first, to learn what a single box costs (the service races mirrors). - await svc.searchOverpassPois('fuel', bbox(9), 'en-US'); + await osm.searchOverpassPois('fuel', bbox(9), 'en-US'); const single = fetchMock.mock.calls.length; fetchMock.mockClear(); - const { pois } = await svc.searchOverpassPois('fuel,charging,rest_area', bbox(1), 'en-US'); + const { pois } = await osm.searchOverpassPois('fuel,charging,rest_area', bbox(1), 'en-US'); // Three categories cost exactly what one does — the whole point of the batching. expect(fetchMock.mock.calls.length).toBe(single); @@ -2704,7 +2705,7 @@ describe('searchOverpassPois multi-category', () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - await expect(svc.searchOverpassPois('fuel,unicorns', bbox(2))).rejects.toMatchObject({ status: 400 }); + await expect(osm.searchOverpassPois('fuel,unicorns', bbox(2))).rejects.toMatchObject({ status: 400 }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -2715,9 +2716,9 @@ describe('searchOverpassPois multi-category', () => { }); vi.stubGlobal('fetch', fetchMock); - await svc.searchOverpassPois('fuel,campsite', bbox(3), 'en-US'); + await osm.searchOverpassPois('fuel,campsite', bbox(3), 'en-US'); const asked = fetchMock.mock.calls.length; - await svc.searchOverpassPois('campsite,fuel', bbox(3), 'en-US'); + await osm.searchOverpassPois('campsite,fuel', bbox(3), 'en-US'); // Same set, written the other way round: the second ask costs nothing. expect(fetchMock.mock.calls.length).toBe(asked); @@ -2732,7 +2733,7 @@ describe('searchOverpassPois all-endpoints-down', () => { it('MAPS-102: surfaces a 502 with a clear message when every Overpass endpoint fails', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))); - await expect(svc.searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({ + await expect(osm.searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({ status: 502, message: 'Could not reach any Overpass endpoint', }); @@ -2742,7 +2743,7 @@ describe('searchOverpassPois all-endpoints-down', () => { it('MAPS-103: logs each endpoint failure so an operator can diagnose blocked egress', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'))); - await expect(svc.searchOverpassPois('bar', bbox)).rejects.toThrow(); + await expect(osm.searchOverpassPois('bar', bbox)).rejects.toThrow(); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all')); expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED')); errSpy.mockRestore(); @@ -2815,7 +2816,7 @@ describe('controller-facing wrappers delegate to the folded methods', () => { getPlacePhoto: vi.spyOn(MapsService.prototype, 'getPlacePhoto').mockResolvedValue({ photoUrl: null, attribution: null }), reverseGeocode: vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: null, address: null }), resolveGoogleMapsUrl: vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({ lat: 1, lng: 2, name: null, address: null, google_ftid: null }), - searchOverpassPois: vi.spyOn(MapsService.prototype, 'searchOverpassPois').mockResolvedValue({ pois: [], source: 'openstreetmap', truncated: false, clamped: false }), + searchOverpassPois: vi.spyOn(OsmClient.prototype, 'searchOverpassPois').mockResolvedValue({ pois: [], source: 'openstreetmap', truncated: false, clamped: false }), }; try { const circleBias = { lat: 1, lng: 2, radius: 5 }; @@ -3646,7 +3647,7 @@ describe('websites from the map sources (#2483)', () => { }), }), ); - const { pois } = await svc.searchOverpassPois('sights', { south: 48.0, west: -3.6, north: 48.1, east: -3.4 }, 'fr-FR'); + const { pois } = await osm.searchOverpassPois('sights', { south: 48.0, west: -3.6, north: 48.1, east: -3.4 }, 'fr-FR'); expect(pois.map((p) => p.website)).toEqual([ 'https://fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët', 'https://www.example.fr', diff --git a/server/tests/unit/nest/place-enrichment.brand.test.ts b/server/tests/unit/nest/place-enrichment.brand.test.ts index 59cd82bf0..992f6c6a3 100644 --- a/server/tests/unit/nest/place-enrichment.brand.test.ts +++ b/server/tests/unit/nest/place-enrichment.brand.test.ts @@ -33,10 +33,11 @@ import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place import { readBrandIdentity } from '../../../src/nest/maps/maps.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; +import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; /** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ type Seams = { [K in keyof T]: T[K] }; -type EnrichmentSeams = Seams & Seams; +type EnrichmentSeams = Seams & Seams & Seams; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -89,7 +90,7 @@ function cacheRepoStub(): PlaceDetailsCacheRepository { return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository; } -const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient); +const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient, maps as unknown as OsmClient); /** A place whose own identity is empty but that belongs to a chain. */ const branchOfAChain = (over: Partial> = {}) => diff --git a/server/tests/unit/nest/place-enrichment.description.test.ts b/server/tests/unit/nest/place-enrichment.description.test.ts index 471d073fd..771e38b90 100644 --- a/server/tests/unit/nest/place-enrichment.description.test.ts +++ b/server/tests/unit/nest/place-enrichment.description.test.ts @@ -37,10 +37,11 @@ vi.mock('../../../src/nest/maps/trek-places.client', () => ({ import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; +import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; /** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ type Seams = { [K in keyof T]: T[K] }; -type EnrichmentSeams = Seams & Seams; +type EnrichmentSeams = Seams & Seams & Seams; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -88,7 +89,7 @@ function cacheRepoStub(): PlaceDetailsCacheRepository { return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository; } -const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient); +const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient, maps as unknown as OsmClient); beforeEach(() => { mockGetValue.mockReset(); diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 4df49a9ca..18024eb12 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -52,10 +52,11 @@ import { } from '../../../src/nest/place-enrichment/place-enrichment.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; +import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; /** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ type Seams = { [K in keyof T]: T[K] }; -type EnrichmentSeams = Seams & Seams; +type EnrichmentSeams = Seams & Seams & Seams; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -108,7 +109,7 @@ function cacheStub(over: Record = {}) { } function make(maps: EnrichmentSeams, cache: PlacePhotoCacheService) { - return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cache, maps as unknown as GooglePlacesClient); + return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cache, maps as unknown as GooglePlacesClient, maps as unknown as OsmClient); } let candidateSeq = 0; diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts index 395d4dbaf..5463c2cf5 100644 --- a/server/tests/unit/nest/places.directions.test.ts +++ b/server/tests/unit/nest/places.directions.test.ts @@ -35,6 +35,7 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { PlacesService } from '../../../src/nest/places/places.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; +import type { OsmClient } from '../../../src/nest/maps/providers/osm.client'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; @@ -61,7 +62,7 @@ const hit = (name: string, lat: number, lng: number) => ({ lat, lng, rating: null, website: null, phone: null, source: 'openstreetmap' as const, }); -async function svc(searchNominatim: MapsService['searchNominatim']): Promise { +async function svc(searchNominatim: OsmClient['searchNominatim']): Promise { return new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), @@ -112,7 +113,7 @@ const geocoder = () => vi.fn(async (query: string) => { }; const found = known[query]; return found ? [hit(query, found[0], found[1])] : []; -}) as unknown as MapsService['searchNominatim']; +}) as unknown as OsmClient['searchNominatim']; let tripId: string; @@ -176,7 +177,7 @@ describe('PlacesService.importGoogleDirections', () => { const search = vi.fn(async (query: string) => { if (query === 'Dresden') throw new Error('Nominatim 429'); return [hit(query, 52.52, 13.405)]; - }) as unknown as MapsService['searchNominatim']; + }) as unknown as OsmClient['searchNominatim']; const result = await (await svc(search)).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'); expect('error' in result).toBe(false); @@ -233,7 +234,7 @@ describe('PlacesService.importGoogleDirections', () => { // each Nominatim call taking the next slot on a 1.1 s process-wide throttle, // so on the interactive lane one pasted link made everybody else's place // search queue behind it for half a minute. An index hit costs no slot. - const nominatim = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as MapsService['searchNominatim']; + const nominatim = vi.fn(async () => [{ lat: 52.52, lng: 13.405 }]) as unknown as OsmClient['searchNominatim']; const geocode = vi.fn(async () => ({ lat: 52.52, lng: 13.405 })); const service = await svc(nominatim); (service as unknown as { maps: Partial }).maps.geocodeQuery = @@ -270,7 +271,7 @@ describe('PlacesService.importGoogleDirections', () => { }); it('PLACES-DIR-010: a route where only one stop can be placed is not half an import', async () => { - const search = vi.fn(async () => []) as unknown as MapsService['searchNominatim']; + const search = vi.fn(async () => []) as unknown as OsmClient['searchNominatim']; const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else'; const result = await (await svc(search)).importGoogleDirections(tripId, url); expect(result).toEqual({ error: 'None of the stops in that link could be placed on the map.', status: 400 });