diff --git a/migrate.md b/migrate.md index 5a1dd0291..924aa040e 100644 --- a/migrate.md +++ b/migrate.md @@ -32,7 +32,7 @@ Wave 4 — the coupled cluster (order matters here) Wave 5 — the heavyweights, last -10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (big but self-contained), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus +10. adminService (14 Nest + 11 MCP consumers), journeyService/atlasService/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper `MapsService`, pure helpers to `maps.helpers.ts`, the 3 geo MCP tools to the decorator-driven `maps.mcp.ts` (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export `maps.bridge.ts` serves placeEnrichment + the places registrar — transitService → placeService now unblocked), and finally the auth stack (authService at 1494 lines with 18 MCP consumers, plus oauthService/oidcService/passkeyService) — highest risk, most consumers, security-sensitive; do it when the pattern is thoroughly proven. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately outside the DI wrapper. diff --git a/server/src/mcp/tools/mapsWeather.ts b/server/src/mcp/tools/mapsWeather.ts index c2dad6917..4da4e4a7c 100644 --- a/server/src/mcp/tools/mapsWeather.ts +++ b/server/src/mcp/tools/mapsWeather.ts @@ -1,7 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { findByIata, searchAirports } from '../../services/airportService'; -import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService'; import { getWeather, getDetailedWeather } from '../../services/weatherService'; import { TOOL_ANNOTATIONS_READONLY, @@ -9,62 +8,13 @@ import { } from './_shared'; import { canRead } from '../scopes'; -export function registerMapsWeatherTools(server: McpServer, userId: number, scopes: string[] | null): void { +export function registerMapsWeatherTools(server: McpServer, _userId: number, scopes: string[] | null): void { const canGeo = canRead(scopes, 'geo'); const canWeather = canRead(scopes, 'weather'); - // --- MAPS EXTRAS --- - - if (canGeo) server.registerTool( - 'get_place_details', - { - description: 'Fetch detailed information about a place by its Google Place ID.', - inputSchema: { - placeId: z.string().describe('Google Place ID'), - lang: z.string().optional().default('en'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ placeId, lang }) => { - const details = await getPlaceDetails(userId, placeId, lang ?? 'en'); - if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true }; - return ok({ details }); - } - ); - - if (canGeo) server.registerTool( - 'reverse_geocode', - { - description: 'Get a human-readable address for given coordinates.', - inputSchema: { - lat: z.number(), - lng: z.number(), - lang: z.string().optional().default('en'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ lat, lng, lang }) => { - const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en'); - if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true }; - return ok(result); - } - ); - - if (canGeo) server.registerTool( - 'resolve_maps_url', - { - description: 'Resolve a Google Maps share URL to coordinates and place name.', - inputSchema: { - url: z.string().describe('Google Maps share URL'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ url }) => { - const result = await resolveGoogleMapsUrl(url); - if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true }; - return ok(result); - } - ); + // The geo tools (get_place_details, reverse_geocode, resolve_maps_url) moved to + // the DI-discovered src/nest/maps/maps.mcp.ts (@McpController, attached via the + // nest-mcp registry). // --- WEATHER --- diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index f8d2afac1..00cde2639 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -5,7 +5,7 @@ import { isDemoUser } from '../../services/authService'; import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService'; import { createAssignment, dayExists } from '../../nest/assignments/assignments.bridge'; import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService'; -import { searchPlaces } from '../../services/mapsService'; +import { searchPlaces } from '../../nest/maps/maps.bridge'; import { safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 049e1c3dc..ae8bdc37e 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -28,8 +28,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express. - **Phase 2 (trip sub-domains):** vacay (addon), packing, todo. - **DI-native services (legacy `src/services/*` deleted):** tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, - collab, vacay, reservations, day, permissions, audit, budget, trip — see the - migration recipe below. + collab, vacay, reservations, day, permissions, audit, budget, trip, maps — see + the migration recipe below. ## Cross-cutting Foundation pieces @@ -223,8 +223,21 @@ serves the legacy prompts registrar and budget.mcp.ts's owner/member seam were deleted with their last consumers and the unused days/budget bridge exports pruned; the controller adopted `trips.dto.ts` — all seven allow-list entries removed). +mapsService followed (the 1429-line geo core — Google Places, Nominatim, +Overpass mirror racing, Wikimedia photos, Maps-URL resolution — folded into the +wrapper `MapsService`; the pure parser/UA/POI-category helpers moved to +`maps.helpers.ts` as plain exports (files.constants/client-ip precedent — +transitService's User-Agent imports from there, not a bridge), the module-scoped +POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped +on purpose (permissions-cache precedent: the bridge instance and the DI +singleton share them); the 3 geo tools left the mixed `mcp/tools/mapsWeather.ts` +registrar for the decorator-driven `maps.mcp.ts` (the registrar file survives — +its weather + airport tools await their own migrations); BookingImportService +injects `MapsService` for its Nominatim geocoding, and a 3-export +`maps.bridge.ts` serves the legacy placeEnrichment helper and the places +registrar until the place domain migrates). Repeat these steps per -service (next up: the **mapsService → transitService → placeService** chain — +service (next up: **transitService → placeService → transitItineraryService** — or the notifications fan-in — per the dependency-honest order in `migration-graph.md`). This is a **pure relocation** — byte-identical diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts index 45b16ce3b..f51644ff0 100644 --- a/server/src/nest/booking-import/booking-import.module.ts +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -9,9 +9,10 @@ import { ReservationsModule } from '../reservations/reservations.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { BudgetModule } from '../budget/budget.module'; import { AddonsModule } from '../addons/addons.module'; +import { MapsModule } from '../maps/maps.module'; @Module({ - imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule], + imports: [LlmParseModule, ReservationsModule, PermissionsModule, BudgetModule, AddonsModule, MapsModule], controllers: [BookingImportController, FeaturesController], providers: [BookingImportService, KitineraryExtractorService, ImportJobsService], }) diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts index 13850acca..cbeba7a63 100644 --- a/server/src/nest/booking-import/booking-import.service.ts +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -7,7 +7,7 @@ import { createPlace } from '../../services/placeService'; import { BudgetService } from '../budget/budget.service'; import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; -import { searchNominatim } from '../../services/mapsService'; +import { MapsService } from '../maps/maps.service'; import { DatabaseService } from '../database/database.service'; import type { User } from '../../types'; import { KitineraryExtractorService } from './kitinerary-extractor.service'; @@ -28,6 +28,7 @@ export class BookingImportService { private readonly budget: BudgetService, private readonly addons: AddonsService, private readonly realtime: RealtimeService, + private readonly maps: MapsService, ) {} private get db() { @@ -160,7 +161,7 @@ export class BookingImportService { ].filter((q): q is string => !!q); for (const q of queries) { - const results = await searchNominatim(q); + const results = await this.maps.searchNominatim(q); const hit = results[0]; if (hit?.lat != null && hit?.lng != null) { lat = hit.lat; @@ -192,7 +193,7 @@ export class BookingImportService { for (const ep of reservationData.endpoints) { if ((ep.lat == null || ep.lng == null) && ep.name) { try { - const hit = (await searchNominatim(ep.name))[0]; + const hit = (await this.maps.searchNominatim(ep.name))[0]; if (hit?.lat != null && hit?.lng != null) { ep.lat = hit.lat; ep.lng = hit.lng; diff --git a/server/src/nest/maps/maps.bridge.ts b/server/src/nest/maps/maps.bridge.ts new file mode 100644 index 000000000..15233b367 --- /dev/null +++ b/server/src/nest/maps/maps.bridge.ts @@ -0,0 +1,35 @@ +import { db } from '../../db/database'; +import { DatabaseService } from '../database/database.service'; +import { MapsService } from './maps.service'; + +/** + * Non-Nest entry point for the maps domain — for code running OUTSIDE the Nest + * container (the legacy placeService's placeEnrichment helper and the legacy + * places MCP registrar in src/mcp/tools/places.ts; the geo MCP tools moved to + * the DI-discovered maps.mcp.ts, and transitService's User-Agent comes from the + * pure maps.helpers.ts). Exports only the legacy services/mapsService names + * still consumed outside the container, 1:1, so repointing a consumer is an + * import-path-only diff. Inside the container, inject MapsService instead. + * Delete this file when the place domain migrates. + * + * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto + * the shared better-sqlite3 singleton. + */ +const maps = new MapsService(new DatabaseService(db)); + +export function getMapsKey(userId: number) { + return maps.getMapsKey(userId); +} + +export function searchPlaces( + userId: number, + query: string, + lang?: string, + locationBias?: { lat: number; lng: number; radius?: number }, +) { + return maps.searchPlaces(userId, query, lang, locationBias); +} + +export function getPlacePhoto(userId: number, placeId: string, lat: number, lng: number, name?: string) { + return maps.getPlacePhoto(userId, placeId, lat, lng, name); +} diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts new file mode 100644 index 000000000..4dd12df07 --- /dev/null +++ b/server/src/nest/maps/maps.helpers.ts @@ -0,0 +1,355 @@ +import { readEnv, getAppUrl } from '../../app-config'; + +/** + * Pure maps/geo helpers — no DB, no Nest, no side effects beyond reading env. + * + * Moved verbatim from the legacy services/mapsService.ts when the maps domain + * went DI-native. Kept as plain functions (files.constants.ts / client-ip.ts + * precedent) because they are consumed both by MapsService and by plain-module + * code outside the container (transitService's User-Agent, the unit suites' + * parser cases). + */ + +// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that +// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited +// and throttled harder (see #1309). When the instance URL is configured we append it; +// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it. +export function buildUserAgent(instanceUrl: string | undefined): string { + const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)'; + if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`; + return base; +} +// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime. +export const UA = buildUserAgent(getAppUrl()); + +// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a +// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is +// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places, +// Nominatim) expect BCP-47, so normalise before sending — otherwise names and +// opening hours come back in the wrong language. Codes not listed here pass +// through unchanged (they are already valid), as do locale forms the client +// sometimes sends (e.g. 'pt-BR'). +const API_LANG_OVERRIDES: Record = { + br: 'pt-BR', + gr: 'el', + 'el-GR': 'el', +}; +export function toApiLang(lang: string | undefined, fallback = 'en'): string { + const code = (lang || '').trim(); + if (!code) return fallback; + return API_LANG_OVERRIDES[code] ?? code; +} + +const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i; + +// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param. +// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN) +// with no ftid, so this returns null for most API responses — the precise query_place_id link is +// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link +// resolved by resolveGoogleMapsUrl or a Google MyMaps list import. +export function googleFtidFromMapsUrl(url?: string | null): string | null { + if (!url) return null; + try { + const ftid = new URL(url).searchParams.get('ftid')?.trim(); + return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null; + } catch { + return null; + } +} + +// ── Overpass POI categories ────────────────────────────────────────────────── + +export interface OverpassPoi { + osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere) + name: string; + lat: number; + lng: number; + category: string; // the requested pill category key, e.g. 'restaurant' + poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant' + address: string | null; + website: string | null; + phone: string | null; + opening_hours: string | null; + cuisine: string | null; + source: 'openstreetmap'; +} + +// Each pill category → the OSM tag selectors it searches. Keys here are the +// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live +// client-side). +export const CATEGORY_OSM_FILTERS: Record = { + restaurant: ['amenity=restaurant', 'amenity=fast_food'], + cafe: ['amenity=cafe'], + bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'], + hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'], + sights: [ + 'tourism=attraction', + 'tourism=viewpoint', + 'historic=monument', + 'historic=castle', + 'historic=memorial', + 'historic=ruins', + ], + museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'], + nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'], + activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'], + shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'], + supermarket: ['shop=supermarket', 'shop=convenience'], +}; + +export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS); + +// Public Overpass mirrors, queried in PARALLEL (first valid response wins). +// Reachability and load vary a lot by network/region — the canonical instance is +// frequently overloaded (504s) and some community mirrors are unreachable from +// certain networks. Racing them means whichever mirror is fastest-reachable for +// this user answers, and an overloaded or blocked one never blocks the others. +const DEFAULT_OVERPASS_MIRRORS = [ + 'https://overpass-api.de/api/interpreter', + 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', + 'https://overpass.kumi.systems/api/interpreter', + 'https://overpass.private.coffee/api/interpreter', +]; + +// Operators behind locked-down egress — or running their own Overpass — can point TREK +// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it +// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a +// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped. +export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] { + const custom = (raw ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => { + try { + const u = new URL(s); + return u.protocol === 'http:' || u.protocol === 'https:'; + } catch { + return false; + } + }); + return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS; +} + +// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case +// wait before every mirror is given up on and a 502 is returned. Public mirrors answer +// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a +// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or +// non-numeric value falls back to the default — a 0/negative cap would abort every +// request immediately and 502 the search. +export function resolveOverpassTimeoutMs(raw?: string): number { + if (raw === undefined) return readEnv().integrations.overpassTimeoutMs; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 12000; +} + +// ── Opening hours parsing ──────────────────────────────────────────────────── + +export interface GoogleTimePoint { + day?: number; + hour?: number; + minute?: number; +} + +export interface GoogleOpeningHours { + weekdayDescriptions?: string[]; + openNow?: boolean; + periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[]; + specialDays?: { date?: { year?: number; month?: number; day?: number } }[]; +} + +// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday. +// The weekday descriptions next to them are display text in the requested language and +// cannot be parsed reliably; these can. A period without `close` is Google's way of +// saying the place never closes. +export interface OpeningTimePoint { + day: number; + hour: number; + minute: number; +} +export interface OpeningPeriod { + open: OpeningTimePoint; + close: OpeningTimePoint | null; +} + +// Places (New) speaks proto3 JSON, which leaves out fields that hold the default +// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes. +function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null { + if (!point || typeof point !== 'object') return null; + const field = (value: unknown): number | null => { + if (value === undefined || value === null) return 0; + return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null; + }; + const day = field(point.day); + const hour = field(point.hour); + const minute = field(point.minute); + if (day === null || hour === null || minute === null) return null; + if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { day, hour, minute }; +} + +export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null { + if (!Array.isArray(periods) || periods.length === 0) return null; + const result: OpeningPeriod[] = []; + for (const period of periods) { + const open = toTimePoint(period?.open); + if (!open) continue; + // A missing close means "never closes"; a close that is there but unusable makes + // the whole period a guess, so it is dropped rather than read as round-the-clock. + const close = period?.close == null ? null : toTimePoint(period.close); + if (period?.close != null && !close) continue; + result.push({ open, close }); + } + return result.length > 0 ? result : null; +} + +// Holidays and other exceptional days that the weekly periods do not describe. Google +// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can +// compare them against the place's own local date. +export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null { + if (!Array.isArray(specialDays) || specialDays.length === 0) return null; + const dates: string[] = []; + for (const entry of specialDays) { + const date = entry?.date; + if (!date) continue; + const { year, month, day } = date; + if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue; + if (month < 1 || month > 12 || day < 1 || day > 31) continue; + const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + if (!dates.includes(iso)) dates.push(iso); + } + return dates.length > 0 ? dates : null; +} + +// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses. +const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g; + +/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */ +function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] { + const day = (dayIdx + 1) % 7; + const periods: OpeningPeriod[] = []; + for (const match of timePart.matchAll(OSM_TIME_RANGE)) { + const openHour = parseInt(match[1], 10); + const openMinute = parseInt(match[2], 10); + let closeHour = parseInt(match[3], 10); + const closeMinute = parseInt(match[4], 10); + if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue; + // OSM writes the end of a day as 24:00, a clock reading Google's numbering has no + // hour 24 — that is midnight of the following day, same as any range that wraps. + let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute; + if (closeHour === 24) { + if (closeMinute !== 0) continue; + closeHour = 0; + nextDay = true; + } + periods.push({ + open: { day, hour: openHour, minute: openMinute }, + close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute }, + }); + } + return periods; +} + +export function parseOpeningHours(ohString: string): { + weekdayDescriptions: string[]; + openNow: boolean | null; + periods: OpeningPeriod[]; +} { + const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const result: string[] = LONG.map((d) => `${d}: ?`); + const periods: OpeningPeriod[] = []; + + // Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00" + for (const segment of ohString.split(';')) { + const trimmed = segment.trim(); + if (!trimmed) continue; + const match = trimmed.match( + /^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i, + ); + if (!match) continue; + const [, daysPart, timePart] = match; + const dayIndices = new Set(); + for (const range of daysPart.split(',')) { + const parts = range + .trim() + .split('-') + .map((d) => DAYS.indexOf(d.trim())); + if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) { + for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i); + dayIndices.add(parts[1]); + } else if (parts[0] >= 0) { + dayIndices.add(parts[0]); + } + } + for (const idx of dayIndices) { + result[idx] = `${LONG[idx]}: ${timePart.trim()}`; + periods.push(...osmPeriods(idx, timePart)); + } + } + + // Compute openNow + let openNow: boolean | null = null; + try { + const now = new Date(); + const jsDay = now.getDay(); + const dayIdx = jsDay === 0 ? 6 : jsDay - 1; + const todayLine = result[dayIdx]; + const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)]; + if (timeRanges.length > 0) { + const nowMins = now.getHours() * 60 + now.getMinutes(); + openNow = timeRanges.some((m) => { + const start = parseInt(m[1]) * 60 + parseInt(m[2]); + const end = parseInt(m[3]) * 60 + parseInt(m[4]); + return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end; + }); + } + } catch { + /* best effort */ + } + + return { weekdayDescriptions: result, openNow, periods }; +} + +// ── Build standardized OSM details ─────────────────────────────────────────── + +export function buildOsmDetails(tags: Record, osmType: string, osmId: string) { + let opening_hours: string[] | null = null; + let open_now: boolean | null = null; + let opening_periods: OpeningPeriod[] | null = null; + if (tags.opening_hours) { + const parsed = parseOpeningHours(tags.opening_hours); + const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?')); + if (hasData) { + opening_hours = parsed.weekdayDescriptions; + open_now = parsed.openNow; + opening_periods = parsed.periods.length > 0 ? parsed.periods : null; + } + } + return { + website: tags['contact:website'] || tags.website || null, + phone: tags['contact:phone'] || tags.phone || null, + opening_hours, + open_now, + opening_periods, + osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, + summary: tags.description || null, + source: 'openstreetmap' as const, + }; +} + +// ── Place-id classification ────────────────────────────────────────────────── + +// Ids that can never resolve against the Google Places API: coordinate pseudo-ids +// (right-click places, in both the coords: and the bare "lat,lng" spelling the +// collection views send), OSM ids the client sends when a place has no +// google_place_id, and the raw photo URLs legacy rows keep in image_url. Google +// answers those with a billable 400 INVALID_ARGUMENT, so every lookup sorts them +// out before the call and uses the OSM/Wikimedia path instead. +const NON_GOOGLE_PLACE_ID = /^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$/i; +// The subset that still has a provider behind it — Overpass for details, +// Wikimedia for photos. +export const OSM_PLACE_ID = /^(?:node|way|relation):/i; + +export function isGooglePlaceId(placeId: string): boolean { + return !NON_GOOGLE_PLACE_ID.test(placeId); +} diff --git a/server/src/nest/maps/maps.mcp.ts b/server/src/nest/maps/maps.mcp.ts new file mode 100644 index 000000000..34fdf5d95 --- /dev/null +++ b/server/src/nest/maps/maps.mcp.ts @@ -0,0 +1,64 @@ +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '@trek/nest-mcp'; +import { z } from 'zod'; +import { MapsService } from './maps.service'; + +/** + * Geo MCP tools, moved 1:1 from the legacy registrar in + * src/mcp/tools/mapsWeather.ts when the maps domain went DI-native (the weather + * and airport tools stay there until their services migrate). Same names, + * descriptions, input schemas and result shapes; the legacy registration-time + * `canRead(scopes, 'geo')` gate became the declarative `access` marker ('geo' + * is a read-only scope group — there is no geo:write). + */ +@McpController() +export class MapsMcp { + constructor(private readonly maps: MapsService) {} + + @Tool({ + name: 'get_place_details', + description: 'Fetch detailed information about a place by its Google Place ID.', + inputSchema: { + placeId: z.string().describe('Google Place ID'), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + async getPlaceDetails({ placeId, lang }: { placeId: string; lang?: string }, ctx: McpContext) { + const details = await this.maps.getPlaceDetails(ctx.userId, placeId, lang ?? 'en'); + if (!details) return errorResult('Place not found or maps service not configured.'); + return ok({ details }); + } + + @Tool({ + name: 'reverse_geocode', + description: 'Get a human-readable address for given coordinates.', + inputSchema: { + lat: z.number(), + lng: z.number(), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + async reverseGeocode({ lat, lng, lang }: { lat: number; lng: number; lang?: string }, _ctx: McpContext) { + const result = await this.maps.reverseGeocode(String(lat), String(lng), lang ?? 'en'); + if (!result) return errorResult('Reverse geocode failed or maps service not configured.'); + return ok(result); + } + + @Tool({ + name: 'resolve_maps_url', + description: 'Resolve a Google Maps share URL to coordinates and place name.', + inputSchema: { + url: z.string().describe('Google Maps share URL'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + async resolveMapsUrl({ url }: { url: string }, _ctx: McpContext) { + const result = await this.maps.resolveGoogleMapsUrl(url); + if (!result) return errorResult('Could not resolve URL or maps service not configured.'); + return ok(result); + } +} diff --git a/server/src/nest/maps/maps.module.ts b/server/src/nest/maps/maps.module.ts index 350f3d3c6..63009f7e1 100644 --- a/server/src/nest/maps/maps.module.ts +++ b/server/src/nest/maps/maps.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; import { MapsController } from './maps.controller'; import { MapsService } from './maps.service'; +import { MapsMcp } from './maps.mcp'; -/** Maps / geo domain (L3 leaf module). Registered in AppModule. */ +/** + * Maps / geo domain (L3 leaf module). Registered in AppModule. Exports + * MapsService for the in-container consumers (BookingImportModule's Nominatim + * geocoding); out-of-container code goes through maps.bridge.ts. + */ @Module({ controllers: [MapsController], - providers: [MapsService], + providers: [MapsService, MapsMcp], + exports: [MapsService], }) export class MapsModule {} diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 91c2d6686..e7cf6e9f5 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -7,26 +7,230 @@ import type { MapsReverseResult, MapsResolveUrlResult, } from '@trek/shared'; +import { readEnv, getAppUrl } from '../../app-config'; +import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard'; +import { decrypt_api_key } from '../../services/apiKeyCrypto'; +// ── Photo cache (disk-backed) ──────────────────────────────────────────────── +import * as placePhotoCache from '../../services/placePhotoCache'; import { DatabaseService } from '../database/database.service'; import { - searchPlaces, - autocompletePlaces, - getPlaceDetails, - getPlaceDetailsExpanded, - getPlacePhoto, - reverseGeocode, - resolveGoogleMapsUrl, - searchOverpassPois, -} from '../../services/mapsService'; -import { serveFilePath } from '../../services/placePhotoCache'; + UA, + toApiLang, + googleFtidFromMapsUrl, + buildOsmDetails, + normalizeOpeningPeriods, + normalizeSpecialDays, + isGooglePlaceId, + OSM_PLACE_ID, + CATEGORY_OSM_FILTERS, + resolveOverpassEndpoints, + resolveOverpassTimeoutMs, + type GoogleOpeningHours, + type OverpassPoi, +} from './maps.helpers'; + +// ── Google API call counter ─────────────────────────────────────────────────── + +let googleApiCallCount = 0; + +function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { + googleApiCallCount++; + console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); + const referer = readEnv().app.appUrl ? getAppUrl() : undefined; + return fetch(endpoint, { + ...init, + headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record) ?? {}) }, + }); +} + +// ── Interfaces ─────────────────────────────────────────────────────────────── + +interface NominatimResult { + osm_type: string; + osm_id: string; + name?: string; + display_name?: string; + lat: string; + lon: string; +} + +interface OverpassElement { + tags?: Record; +} + +interface WikiCommonsPage { + imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[]; +} + +interface GooglePlaceResult { + id: string; + displayName?: { text: string }; + formattedAddress?: string; + location?: { latitude: number; longitude: number }; + rating?: number; + websiteUri?: string; + nationalPhoneNumber?: string; + types?: string[]; + googleMapsUri?: string; +} + +interface GoogleAutocompleteSuggestion { + placePrediction?: { + placeId: string; + structuredFormat?: { + mainText?: { text: string }; + secondaryText?: { text: string }; + }; + }; +} + +interface GooglePlaceDetails extends GooglePlaceResult { + userRatingCount?: number; + regularOpeningHours?: GoogleOpeningHours; + editorialSummary?: { text: string }; + reviews?: { + authorAttribution?: { displayName?: string; photoUri?: string }; + rating?: number; + text?: { text?: string }; + relativePublishTimeDescription?: string; + }[]; + photos?: { name: string; authorAttributions?: { displayName?: string }[] }[]; +} + +// ── Concurrency limiter for outbound photo fetches ─────────────────────────── +// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds +// of places cannot monopolise the event loop or trigger external API rate limits. +// Module-scoped ON PURPOSE (permissions-cache precedent): the bridge instance and +// the DI singleton must share one limiter, one POI cache and one call counter. +const MAX_CONCURRENT_PHOTO_FETCHES = 5; +let photoFetchActive = 0; +const photoFetchQueue: Array<() => void> = []; + +function acquirePhotoFetchSlot(): Promise { + if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) { + photoFetchActive++; + return Promise.resolve(); + } + return new Promise((resolve) => photoFetchQueue.push(resolve)); +} + +function releasePhotoFetchSlot(): void { + const next = photoFetchQueue.shift(); + if (next) { + next(); + } else { + photoFetchActive--; + } +} + +// ── 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[]; + source: 'openstreetmap'; + 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(); +// Largest viewport side we send to Overpass. A country/continent-sized bbox makes +// Overpass scan millions of elements and time out; clamping to a centred window +// keeps the query cheap so the explore pill returns fast at ANY zoom level. +const MAX_BBOX_SPAN_DEG = 0.5; + +// 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; + +// 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 */ + } + }); + } +} type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } }; /** - * Thin Nest wrapper around the existing maps service. All geocoding, the - * provider fan-out (Nominatim/Overpass/Google) and — importantly — the SSRF - * guard live in mapsService and are reused unchanged, so behaviour and the - * outbound-URL protection are identical. + * /api/maps domain service — geocoding, the provider fan-out + * (Nominatim/Overpass/Google), the place-details/photo caches and the SSRF + * guard on every outbound URL. DI-native since the maps fold: the legacy + * services/mapsService.ts functions live here as methods over the injected + * DatabaseService (byte-identical SQL and behaviour). Out-of-container + * consumers go through maps.bridge.ts; pure helpers live in maps.helpers.ts. * * The per-endpoint kill-switches are settings reads the legacy route does * inline; they're encapsulated here as `*Disabled()` helpers over the same @@ -56,40 +260,948 @@ export class MapsService { return this.isSettingDisabled('places_photos_enabled'); } + // ── Controller-facing surface (unchanged signatures) ─────────────────────── + search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise { - return searchPlaces(userId, query, lang, locationBias) as Promise; + return this.searchPlaces(userId, query, lang, locationBias) as Promise; } autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise { - return autocompletePlaces(userId, input, lang, locationBias) as Promise; + return this.autocompletePlaces(userId, input, lang, locationBias) as Promise; } details(userId: number, placeId: string, lang?: string): Promise { - return getPlaceDetails(userId, placeId, lang) as Promise; + return this.getPlaceDetails(userId, placeId, lang) as Promise; } detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise { - return getPlaceDetailsExpanded(userId, placeId, lang, refresh) as Promise; + return this.getPlaceDetailsExpanded(userId, placeId, lang, refresh) as Promise; } photo(userId: number, placeId: string, lat: number, lng: number, name?: string): Promise { - return getPlacePhoto(userId, placeId, lat, lng, name) as Promise; + return this.getPlacePhoto(userId, placeId, lat, lng, name) as Promise; } photoBytesPath(placeId: string): string | null { - return serveFilePath(placeId); + return placePhotoCache.serveFilePath(placeId); } reverse(lat: string, lng: string, lang?: string): Promise { - return reverseGeocode(lat, lng, lang) as Promise; + return this.reverseGeocode(lat, lng, lang) as Promise; } resolveUrl(url: string): Promise { - return resolveGoogleMapsUrl(url) as Promise; + return this.resolveGoogleMapsUrl(url) as Promise; } // OSM-only POI search by category within a viewport bbox (never calls Google). pois(category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string) { - return searchOverpassPois(category, bbox, lang); + return this.searchOverpassPois(category, bbox, lang); + } + + // ── API key retrieval ────────────────────────────────────────────────────── + + getMapsKey(userId: number): string | null { + const user = this.database.get<{ maps_api_key: string | null }>( + 'SELECT maps_api_key FROM users WHERE id = ?', + userId, + ); + const user_key = decrypt_api_key(user?.maps_api_key); + if (user_key) return user_key; + const admin = this.database.get<{ maps_api_key: string }>( + "SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1", + ); + return decrypt_api_key(admin?.maps_api_key) || null; + } + + // ── Nominatim search ─────────────────────────────────────────────────────── + + async searchNominatim(query: string, lang?: string) { + const params = new URLSearchParams({ + q: query, + format: 'json', + addressdetails: '1', + limit: '10', + 'accept-language': toApiLang(lang), + }); + const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { + headers: { 'User-Agent': UA }, + }); + 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) => ({ + 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: parseFloat(item.lat) || null, + lng: parseFloat(item.lon) || null, + rating: null, + website: null, + phone: null, + source: 'openstreetmap', + })); + } + + // ── Nominatim lookup (by OSM ID) ─────────────────────────────────────────── + + async lookupNominatim( + osmType: string, + osmId: string, + lang?: string, + ): Promise<{ + name: string; + address: string; + lat: number | null; + lng: number | null; + } | null> { + const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R + const params = new URLSearchParams({ + osm_ids: `${typePrefix}${osmId}`, + format: 'json', + 'accept-language': toApiLang(lang), + }); + try { + const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, { + headers: { 'User-Agent': UA }, + }); + if (!res.ok) return null; + const data = (await res.json()) as NominatimResult[]; + const item = data[0]; + if (!item) return null; + return { + name: item.name || item.display_name?.split(',')[0] || '', + address: item.display_name || '', + lat: parseFloat(item.lat) || null, + lng: parseFloat(item.lon) || 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; + 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)}`, + }); + 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 { + const filters = CATEGORY_OSM_FILTERS[category]; + if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 }); + + // 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. + let { south, west, north, east } = bbox; + let clamped = false; + if (north - south > MAX_BBOX_SPAN_DEG) { + const c = (north + south) / 2; + south = c - MAX_BBOX_SPAN_DEG / 2; + north = c + MAX_BBOX_SPAN_DEG / 2; + clamped = true; + } + if (east - west > MAX_BBOX_SPAN_DEG) { + const c = (east + west) / 2; + west = c - MAX_BBOX_SPAN_DEG / 2; + east = c + MAX_BBOX_SPAN_DEG / 2; + clamped = true; + } + + // 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 = `${category}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`; + 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:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`; + + const elements = await overpassFetch(query); + + const pois: OverpassPoi[] = []; + for (const el of elements) { + const tags = el.tags || {}; + const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null; + if (!name) continue; // unnamed POIs aren't useful to add to a plan + 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, + poi_type: matched, + address: addr, + website: tags.website || tags['contact:website'] || null, + phone: tags.phone || tags['contact:phone'] || null, + opening_hours: tags.opening_hours || null, + cuisine: tags.cuisine || null, + source: 'openstreetmap', + }); + } + const truncated = pois.length > limit; + const value: PoiSearchResult = { pois: pois.slice(0, limit), 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( + lat: number, + lng: number, + name?: string, + ): Promise<{ photoUrl: string; attribution: string | null } | null> { + // Strategy 1: Search Wikipedia for the place name -> get the article image + if (name) { + try { + const searchParams = new URLSearchParams({ + action: 'query', + format: 'json', + titles: name, + prop: 'pageimages', + piprop: 'thumbnail', + pithumbsize: '400', + pilimit: '1', + redirects: '1', + }); + const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); + if (res.ok) { + const data = (await res.json()) as { query?: { pages?: Record } }; + const pages = data.query?.pages; + if (pages) { + for (const page of Object.values(pages)) { + if (page.thumbnail?.source) { + return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' }; + } + } + } + } + } catch { + /* fall through to geosearch */ + } + } + + // Strategy 2: Wikimedia Commons geosearch by coordinates + const params = new URLSearchParams({ + action: 'query', + format: 'json', + generator: 'geosearch', + ggsprimary: 'all', + ggsnamespace: '6', + ggsradius: '300', + ggscoord: `${lat}|${lng}`, + ggslimit: '5', + prop: 'imageinfo', + iiprop: 'url|extmetadata|mime', + iiurlwidth: '400', + }); + try { + const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); + if (!res.ok) return null; + const data = (await res.json()) as { + query?: { pages?: Record }; + }; + const pages = data.query?.pages; + if (!pages) return null; + for (const page of Object.values(pages)) { + const info = page.imageinfo?.[0]; + // Only use actual photos (JPEG/PNG), skip SVGs and PDFs + const mime = (info as { mime?: string })?.mime || ''; + if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) { + const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null; + // iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it — + // info.url is the full-resolution original (multi-megapixel camera exports). + return { photoUrl: info.thumburl ?? info.url, attribution }; + } + } + return null; + } catch { + return null; + } + } + + // ── Search places (Google or Nominatim fallback) ─────────────────────────── + + async searchPlaces( + userId: number, + query: string, + lang?: string, + locationBias?: { lat: number; lng: number; radius?: number }, + ): Promise<{ places: Record[]; source: string }> { + const apiKey = this.getMapsKey(userId); + + if (!apiKey) { + const places = await this.searchNominatim(query, lang); + return { places, source: 'openstreetmap' }; + } + + const searchBody: Record = { textQuery: query, languageCode: toApiLang(lang) }; + // Bias results toward the caller's area when supplied — without it Google Text + // Search falls back to the API key's billing region, which skews foreign-region queries. + if (locationBias) { + searchBody.locationBias = { + circle: { + center: { latitude: locationBias.lat, longitude: locationBias.lng }, + radius: locationBias.radius ?? 50000, + }, + }; + } + + const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': + 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri', + }, + body: JSON.stringify(searchBody), + }); + + const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const places = (data.places || []).map((p: GooglePlaceResult) => ({ + google_place_id: p.id, + google_ftid: googleFtidFromMapsUrl(p.googleMapsUri), + name: p.displayName?.text || '', + address: p.formattedAddress || '', + lat: p.location?.latitude || null, + lng: p.location?.longitude || null, + rating: p.rating || null, + website: p.websiteUri || null, + phone: p.nationalPhoneNumber || null, + types: p.types || [], + source: 'google', + })); + + return { places, source: 'google' }; + } + + // ── Autocomplete (Google or Nominatim fallback) ──────────────────────────── + + async autocompletePlaces( + userId: number, + input: string, + lang?: string, + locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, + ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { + const apiKey = this.getMapsKey(userId); + + if (!apiKey) { + return this.autocompleteNominatim(input, lang); + } + + const body: Record = { + input, + languageCode: toApiLang(lang), + }; + if (locationBias) { + body.locationBias = { + rectangle: { + low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng }, + high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng }, + }, + }; + } + + const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + }, + body: JSON.stringify(body), + }); + + const data = (await response.json()) as { + suggestions?: GoogleAutocompleteSuggestion[]; + error?: { message?: string }; + }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const suggestions = (data.suggestions || []) + .filter((s) => s.placePrediction) + .slice(0, 5) + .map((s) => ({ + placeId: s.placePrediction!.placeId, + mainText: s.placePrediction!.structuredFormat?.mainText?.text || '', + secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '', + })); + + return { suggestions, source: 'google' }; + } + + private async autocompleteNominatim( + input: string, + lang?: string, + ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { + 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( + userId: number, + placeId: string, + lang?: string, + ): Promise<{ place: Record }> { + // OSM details: placeId is "node:123456" or "way:123456" etc. + if (placeId.includes(':')) { + const [osmType, osmId] = placeId.split(':'); + const element = await this.fetchOverpassDetails(osmType, osmId); + const details = buildOsmDetails(element?.tags || {}, osmType, osmId); + + // Fetch Nominatim only when Overpass lacks coordinates or address + const d = details as Record; + const needsNominatim = !d.lat || !d.lng || !d.address; + const nominatim = needsNominatim ? await this.lookupNominatim(osmType, osmId, lang) : null; + + return { + place: { + ...details, + name: (d.name as string) || nominatim?.name || element?.tags?.name || '', + address: (d.address as string) || nominatim?.address || '', + lat: d.lat ?? nominatim?.lat ?? null, + lng: d.lng ?? nominatim?.lng ?? null, + osm_id: placeId, + }, + }; + } + + // Google details + const langKey = toApiLang(lang, 'de'); + const apiKey = this.getMapsKey(userId); + if (!apiKey) { + throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); + } + + // Check DB cache first (lean mask, expanded=0) — 7-day TTL + const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000; + const cached = this.database.get<{ payload_json: string; fetched_at: number }>( + 'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0', + placeId, + langKey, + ); + if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) }; + + const response = await googleFetch( + `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, + `getPlaceDetails(${placeId})`, + { + method: 'GET', + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': + 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri', + }, + }, + ); + + const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const place = { + google_place_id: data.id, + google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), + name: data.displayName?.text || '', + address: data.formattedAddress || '', + lat: data.location?.latitude || null, + lng: data.location?.longitude || null, + rating: data.rating || null, + rating_count: data.userRatingCount || null, + website: data.websiteUri || null, + phone: data.nationalPhoneNumber || null, + opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, + open_now: data.regularOpeningHours?.openNow ?? null, + // open_now is a snapshot Google took when this payload was fetched and it is cached + // for days; the periods let the client recompute the state in the place's own + // timezone, which the localised weekday lines above cannot do. Issue #1680. + opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods), + opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays), + google_maps_url: data.googleMapsUri || null, + summary: null, + reviews: [], + source: 'google' as const, + cached_at: Date.now(), + }; + + try { + this.database.run( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)', + placeId, + langKey, + JSON.stringify(place), + Date.now(), + ); + } catch (dbErr) { + console.error('Failed to cache place details:', dbErr); + } + + return { place }; + } + + async getPlaceDetailsExpanded( + userId: number, + placeId: string, + lang?: string, + refresh = false, + ): Promise<{ place: Record | null }> { + // Reviews and the editorial summary only exist at Google, but the id does not + // have to be a Google one — the client sends whatever the place carries. OSM ids + // keep the details they do have (Overpass, via the plain lookup); coordinate + // pseudo-ids and legacy image URLs have no details source at all. Neither may be + // forwarded to Google, which bills the 400 INVALID_ARGUMENT it answers with. + if (!isGooglePlaceId(placeId)) { + return OSM_PLACE_ID.test(placeId) ? this.getPlaceDetails(userId, placeId, lang) : { place: null }; + } + + const langKey = toApiLang(lang, 'de'); + const apiKey = this.getMapsKey(userId); + if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); + + // Check DB cache for expanded result + if (!refresh) { + const cached = this.database.get<{ payload_json: string }>( + 'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1', + placeId, + langKey, + ); + if (cached) return { place: JSON.parse(cached.payload_json) }; + } + + const response = await googleFetch( + `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, + `getPlaceDetailsExpanded(${placeId})`, + { + method: 'GET', + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': + 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary', + }, + }, + ); + + const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const place = { + google_place_id: data.id, + google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), + name: data.displayName?.text || '', + address: data.formattedAddress || '', + lat: data.location?.latitude || null, + lng: data.location?.longitude || null, + rating: data.rating || null, + rating_count: data.userRatingCount || null, + website: data.websiteUri || null, + phone: data.nationalPhoneNumber || null, + opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, + open_now: data.regularOpeningHours?.openNow ?? null, + opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods), + opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays), + google_maps_url: data.googleMapsUri || null, + summary: data.editorialSummary?.text || null, + reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable[number]) => ({ + author: r.authorAttribution?.displayName || null, + rating: r.rating || null, + text: r.text?.text || null, + time: r.relativePublishTimeDescription || null, + photo: r.authorAttribution?.photoUri || null, + })), + source: 'google' as const, + cached_at: Date.now(), + }; + + try { + this.database.run( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)', + placeId, + langKey, + JSON.stringify(place), + Date.now(), + ); + } catch (dbErr) { + console.error('Failed to cache expanded place details:', dbErr); + } + + return { place }; + } + + // ── Place photo (Google or Wikimedia, disk-cached) ───────────────────────── + + async getPlacePhoto( + userId: number, + placeId: string, + lat: number, + lng: number, + name?: string, + ): Promise<{ photoUrl: string | null; attribution: string | null }> { + // Disk cache hit — serve immediately, no Google call + const diskHit = placePhotoCache.get(placeId); + if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution }; + + // "No photo for this place" is an empty result, not a missing resource: a trip + // view asks for one photo per place, so answering each miss with a 404 makes a + // normal itinerary render look like a 404 scan to fail2ban/CrowdSec and gets + // the user's IP banned. Every miss below returns photoUrl: null instead — the + // same shape the photos kill-switch already returns. + const noPhoto = { photoUrl: null, attribution: null }; + + // Recent miss — don't hammer the API + if (placePhotoCache.getErrored(placeId)) return noPhoto; + + // Deduplicate concurrent requests for the same placeId + const existing = placePhotoCache.getInFlight(placeId); + if (existing) { + const result = await existing; + if (!result) return noPhoto; + return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; + } + + // Tells the two empty outcomes apart for the negative cache below: a place that + // has no photo anywhere is worth remembering for a day, a provider that refused + // or timed out only for a few minutes. + let providerFailed = false; + + const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => { + await acquirePhotoFetchSlot(); + try { + const apiKey = this.getMapsKey(userId); + + // Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only + // (right-click) places and as a fallback when a Google place yields no photo, + // so a place added via search still gets a marker image when Google returns + // nothing. Returns null (without marking an error) so the caller decides. + const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => { + if (isNaN(lat) || isNaN(lng)) return null; + try { + const wiki = await this.fetchWikimediaPhoto(lat, lng, name); + if (!wiki) return null; + // Follow redirects manually so each hop (the image URL can 3xx to a CDN + // host) is re-validated against the SSRF guard, not just the first URL. + const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true }); + if (!imgRes.ok) { + providerFailed = true; + return null; + } + const bytes = Buffer.from(await imgRes.arrayBuffer()); + const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution); + return { filePath: cached.filePath, attribution: cached.attribution }; + } catch { + providerFailed = true; + return null; + } + }; + + // Google Places photo for a Google place_id. Returns null on any miss — no + // key, request rejected, no photos, or a failed media download — so the + // caller can fall back to Wikimedia; the misses that were Google's fault + // flag providerFailed on the way out. + const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => { + if (!apiKey) return null; + + // Fetch details to get the photo name + const detailsRes = await googleFetch( + `https://places.googleapis.com/v1/places/${placeId}`, + `getPlacePhoto/details(${placeId})`, + { + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'photos', + }, + }, + ); + const body = await detailsRes.text(); + if (!detailsRes.ok) { + console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200)); + providerFailed = true; + return null; + } + let details: GooglePlaceDetails & { error?: { message?: string } }; + try { + details = body ? JSON.parse(body) : { photos: [] }; + } catch { + providerFailed = true; + return null; + } + if (!details.photos?.length) return null; + + const photo = details.photos[0]; + const photoName = photo.name; + const attribution = photo.authorAttributions?.[0]?.displayName || null; + + // Fetch actual image bytes + const mediaRes = await googleFetch( + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`, + `getPlacePhoto/media(${placeId})`, + { headers: { 'X-Goog-Api-Key': apiKey } }, + ); + // The place does have a photo — only the download for it went wrong. + if (!mediaRes.ok) { + providerFailed = true; + return null; + } + + const bytes = Buffer.from(await mediaRes.arrayBuffer()); + if (!bytes.length) { + providerFailed = true; + return null; + } + + const cached = await placePhotoCache.put(placeId, bytes, attribution); + + // Persist stable proxy URL to database + try { + this.database.run( + "UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')", + cached.photoUrl, + placeId, + ); + } catch (dbErr) { + console.error('Failed to persist photo URL to database:', dbErr); + } + + return { filePath: cached.filePath, attribution }; + }; + + // Prefer the Google photo (higher quality); if Google yields nothing, fall + // back to the same coordinate-based Wikipedia/OSM lookup that right-click + // places use. Ids Google cannot resolve skip it entirely. + if (isGooglePlaceId(placeId)) { + const googlePhoto = await fetchGooglePhoto(); + if (googlePhoto) return googlePhoto; + } + + const fallback = await fetchWikimediaFallback(); + if (fallback) return fallback; + + placePhotoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo'); + return null; + } finally { + releasePhotoFetchSlot(); + } + })(); + + placePhotoCache.setInFlight(placeId, fetchPromise); + + const result = await fetchPromise; + if (!result) return noPhoto; + return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; + } + + // ── Reverse geocoding ────────────────────────────────────────────────────── + + async reverseGeocode( + lat: string, + lng: string, + lang?: string, + ): Promise<{ name: string | null; address: string | null }> { + const params = new URLSearchParams({ + lat, + lon: lng, + format: 'json', + addressdetails: '1', + zoom: '18', + 'accept-language': toApiLang(lang), + }); + const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { 'User-Agent': UA }, + }); + 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 = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; + return { name, address: data.display_name || null }; + } + + // ── Resolve Google Maps URL ──────────────────────────────────────────────── + + async resolveGoogleMapsUrl( + url: string, + ): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> { + let resolvedUrl = url; + + // Extract coordinates from a string (URL or page body). Google Maps encodes + // them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=. + const extractCoords = (s: string): { lat: number; lng: number } | null => { + const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); + if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) }; + const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/); + if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) }; + const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/); + if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) }; + return null; + }; + + const followRedirects = async (target: string, init?: RequestInit): Promise => { + try { + return await safeFetchFollow( + target, + { signal: AbortSignal.timeout(10000), ...init }, + { bypassInternalIpAllowed: true }, + ); + } catch (err) { + if (err instanceof SsrfBlockedError) { + throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 }); + } + throw err; + } + }; + + // Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps + // URLs that carry no inline coordinates — e.g. ?cid= links (the format + // get_place_details returns) and "Share"-button links. The redirect target + // usually carries the !3d!4d data param we can then parse. Redirects are + // followed manually so every hop is SSRF-re-checked. + const parsed = new URL(url); + const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com']; + const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname); + const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname); + if (isShort || (isGoogleMaps && !extractCoords(url))) { + resolvedUrl = (await followRedirects(url)).url || resolvedUrl; + } + + let coords = extractCoords(resolvedUrl); + + // Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the + // page body once and parse the coordinates out of the embedded map data. + if (!coords) { + try { + const pageRes = await followRedirects(resolvedUrl, { + headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, + }); + coords = extractCoords(await pageRes.text()); + } catch (err) { + if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it + // Otherwise fall through to the not-found error below. + } + } + + // Extract place name from URL path: /place/Place+Name/@... + let placeName: string | null = null; + const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/); + if (placeMatch) { + placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); + } + + if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) { + throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 }); + } + const { lat, lng } = coords; + + // Reverse geocode to get address + const nominatimRes = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, + { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }, + ); + const nominatim = (await nominatimRes.json()) as { + display_name?: string; + name?: string; + address?: Record; + }; + + const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; + const address = nominatim.display_name || null; + + return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) }; } } diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts deleted file mode 100644 index 63e46e20f..000000000 --- a/server/src/services/mapsService.ts +++ /dev/null @@ -1,1429 +0,0 @@ -import { readEnv } from '../app-config'; -import { db } from '../db/database'; -import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard'; -import { decrypt_api_key } from './apiKeyCrypto'; -import { getAppUrl } from '../app-config'; -// ── Photo cache (disk-backed) ──────────────────────────────────────────────── -import * as placePhotoCache from './placePhotoCache'; - -// ── Google API call counter ─────────────────────────────────────────────────── - -let googleApiCallCount = 0; - -function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { - googleApiCallCount++; - console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); - const referer = readEnv().app.appUrl ? getAppUrl() : undefined; - return fetch(endpoint, { - ...init, - headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record) ?? {}) }, - }); -} - -// ── Interfaces ─────────────────────────────────────────────────────────────── - -interface NominatimResult { - osm_type: string; - osm_id: string; - name?: string; - display_name?: string; - lat: string; - lon: string; -} - -interface OverpassElement { - tags?: Record; -} - -interface WikiCommonsPage { - imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[]; -} - -interface GooglePlaceResult { - id: string; - displayName?: { text: string }; - formattedAddress?: string; - location?: { latitude: number; longitude: number }; - rating?: number; - websiteUri?: string; - nationalPhoneNumber?: string; - types?: string[]; - googleMapsUri?: string; -} - -interface GoogleAutocompleteSuggestion { - placePrediction?: { - placeId: string; - structuredFormat?: { - mainText?: { text: string }; - secondaryText?: { text: string }; - }; - }; -} - -interface GoogleTimePoint { - day?: number; - hour?: number; - minute?: number; -} - -interface GoogleOpeningHours { - weekdayDescriptions?: string[]; - openNow?: boolean; - periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[]; - specialDays?: { date?: { year?: number; month?: number; day?: number } }[]; -} - -interface GooglePlaceDetails extends GooglePlaceResult { - userRatingCount?: number; - regularOpeningHours?: GoogleOpeningHours; - editorialSummary?: { text: string }; - reviews?: { - authorAttribution?: { displayName?: string; photoUri?: string }; - rating?: number; - text?: { text?: string }; - relativePublishTimeDescription?: string; - }[]; - photos?: { name: string; authorAttributions?: { displayName?: string }[] }[]; -} - -// ── Constants ──────────────────────────────────────────────────────────────── - -// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that -// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited -// and throttled harder (see #1309). When the instance URL is configured we append it; -// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it. -export function buildUserAgent(instanceUrl: string | undefined): string { - const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)'; - if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`; - return base; -} -// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime. -const UA = buildUserAgent(getAppUrl()); - -// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a -// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is -// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places, -// Nominatim) expect BCP-47, so normalise before sending — otherwise names and -// opening hours come back in the wrong language. Codes not listed here pass -// through unchanged (they are already valid), as do locale forms the client -// sometimes sends (e.g. 'pt-BR'). -const API_LANG_OVERRIDES: Record = { - br: 'pt-BR', - gr: 'el', - 'el-GR': 'el', -}; -function toApiLang(lang: string | undefined, fallback = 'en'): string { - const code = (lang || '').trim(); - if (!code) return fallback; - return API_LANG_OVERRIDES[code] ?? code; -} - -const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i; - -// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param. -// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN) -// with no ftid, so this returns null for most API responses — the precise query_place_id link is -// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link -// resolved by resolveGoogleMapsUrl or a Google MyMaps list import. -export function googleFtidFromMapsUrl(url?: string | null): string | null { - if (!url) return null; - try { - const ftid = new URL(url).searchParams.get('ftid')?.trim(); - return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null; - } catch { - return null; - } -} - -// ── Concurrency limiter for outbound photo fetches ─────────────────────────── -// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds -// of places cannot monopolise the event loop or trigger external API rate limits. -const MAX_CONCURRENT_PHOTO_FETCHES = 5; -let photoFetchActive = 0; -const photoFetchQueue: Array<() => void> = []; - -function acquirePhotoFetchSlot(): Promise { - if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) { - photoFetchActive++; - return Promise.resolve(); - } - return new Promise((resolve) => photoFetchQueue.push(resolve)); -} - -function releasePhotoFetchSlot(): void { - const next = photoFetchQueue.shift(); - if (next) { - next(); - } else { - photoFetchActive--; - } -} - -// ── API key retrieval ──────────────────────────────────────────────────────── - -export function getMapsKey(userId: number): string | null { - const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as - | { maps_api_key: string | null } - | undefined; - const user_key = decrypt_api_key(user?.maps_api_key); - if (user_key) return user_key; - const admin = db - .prepare( - "SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1", - ) - .get() as { maps_api_key: string } | undefined; - return decrypt_api_key(admin?.maps_api_key) || null; -} - -// ── Nominatim search ───────────────────────────────────────────────────────── - -export async function searchNominatim(query: string, lang?: string) { - const params = new URLSearchParams({ - q: query, - format: 'json', - addressdetails: '1', - limit: '10', - 'accept-language': toApiLang(lang), - }); - const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { - headers: { 'User-Agent': UA }, - }); - 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) => ({ - 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: parseFloat(item.lat) || null, - lng: parseFloat(item.lon) || null, - rating: null, - website: null, - phone: null, - source: 'openstreetmap', - })); -} - -// ── Nominatim lookup (by OSM ID) ──────────────────────────────────────────── - -export async function lookupNominatim( - osmType: string, - osmId: string, - lang?: string, -): Promise<{ - name: string; - address: string; - lat: number | null; - lng: number | null; -} | null> { - const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R - const params = new URLSearchParams({ - osm_ids: `${typePrefix}${osmId}`, - format: 'json', - 'accept-language': toApiLang(lang), - }); - try { - const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, { - headers: { 'User-Agent': UA }, - }); - if (!res.ok) return null; - const data = (await res.json()) as NominatimResult[]; - const item = data[0]; - if (!item) return null; - return { - name: item.name || item.display_name?.split(',')[0] || '', - address: item.display_name || '', - lat: parseFloat(item.lat) || null, - lng: parseFloat(item.lon) || null, - }; - } catch { - return null; - } -} - -// ── Overpass API (OSM details) ─────────────────────────────────────────────── - -export async function fetchOverpassDetails(osmType: string, osmId: string): Promise { - const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; - const oType = typeMap[osmType]; - if (!oType) 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)}`, - }); - 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. - -export interface OverpassPoi { - osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere) - name: string; - lat: number; - lng: number; - category: string; // the requested pill category key, e.g. 'restaurant' - poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant' - address: string | null; - website: string | null; - phone: string | null; - opening_hours: string | null; - cuisine: string | null; - source: 'openstreetmap'; -} - -// Each pill category → the OSM tag selectors it searches. Keys here are the -// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live -// client-side). -const CATEGORY_OSM_FILTERS: Record = { - restaurant: ['amenity=restaurant', 'amenity=fast_food'], - cafe: ['amenity=cafe'], - bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'], - hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'], - sights: [ - 'tourism=attraction', - 'tourism=viewpoint', - 'historic=monument', - 'historic=castle', - 'historic=memorial', - 'historic=ruins', - ], - museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'], - nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'], - activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'], - shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'], - supermarket: ['shop=supermarket', 'shop=convenience'], -}; - -export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS); - -interface OverpassPoiElement { - type: string; - id: number; - lat?: number; - lon?: number; - center?: { lat: number; lon: number }; - tags?: Record; -} - -interface PoiSearchResult { - pois: OverpassPoi[]; - source: 'openstreetmap'; - 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; -} - -// Public Overpass mirrors, queried in PARALLEL (first valid response wins). -// Reachability and load vary a lot by network/region — the canonical instance is -// frequently overloaded (504s) and some community mirrors are unreachable from -// certain networks. Racing them means whichever mirror is fastest-reachable for -// this user answers, and an overloaded or blocked one never blocks the others. -const DEFAULT_OVERPASS_MIRRORS = [ - 'https://overpass-api.de/api/interpreter', - 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', - 'https://overpass.kumi.systems/api/interpreter', - 'https://overpass.private.coffee/api/interpreter', -]; - -// Operators behind locked-down egress — or running their own Overpass — can point TREK -// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it -// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a -// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped. -export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] { - const custom = (raw ?? '') - .split(',') - .map((s) => s.trim()) - .filter((s) => { - try { - const u = new URL(s); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; - } - }); - return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS; -} -const OVERPASS_MIRRORS = resolveOverpassEndpoints(); -// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case -// wait before every mirror is given up on and a 502 is returned. Public mirrors answer -// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a -// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or -// non-numeric value falls back to the default — a 0/negative cap would abort every -// request immediately and 502 the search. -export function resolveOverpassTimeoutMs(raw?: string): number { - if (raw === undefined) return readEnv().integrations.overpassTimeoutMs; - const n = Number(raw); - return Number.isFinite(n) && n > 0 ? n : 12000; -} -const OVERPASS_TIMEOUT_MS = resolveOverpassTimeoutMs(); -// Largest viewport side we send to Overpass. A country/continent-sized bbox makes -// Overpass scan millions of elements and time out; clamping to a centred window -// keeps the query cheap so the explore pill returns fast at ANY zoom level. -const MAX_BBOX_SPAN_DEG = 0.5; - -// 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; - -// 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 */ - } - }); - } -} - -export async function searchOverpassPois( - category: string, - bbox: { south: number; west: number; north: number; east: number }, - lang?: string, - limit = 60, -): Promise { - const filters = CATEGORY_OSM_FILTERS[category]; - if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 }); - - // 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. - let { south, west, north, east } = bbox; - let clamped = false; - if (north - south > MAX_BBOX_SPAN_DEG) { - const c = (north + south) / 2; - south = c - MAX_BBOX_SPAN_DEG / 2; - north = c + MAX_BBOX_SPAN_DEG / 2; - clamped = true; - } - if (east - west > MAX_BBOX_SPAN_DEG) { - const c = (east + west) / 2; - west = c - MAX_BBOX_SPAN_DEG / 2; - east = c + MAX_BBOX_SPAN_DEG / 2; - clamped = true; - } - - // 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 = `${category}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`; - 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:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`; - - const elements = await overpassFetch(query); - - const pois: OverpassPoi[] = []; - for (const el of elements) { - const tags = el.tags || {}; - const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || null; - if (!name) continue; // unnamed POIs aren't useful to add to a plan - 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, - poi_type: matched, - address: addr, - website: tags.website || tags['contact:website'] || null, - phone: tags.phone || tags['contact:phone'] || null, - opening_hours: tags.opening_hours || null, - cuisine: tags.cuisine || null, - source: 'openstreetmap', - }); - } - const truncated = pois.length > limit; - const value: PoiSearchResult = { pois: pois.slice(0, limit), 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; -} - -// ── Opening hours parsing ──────────────────────────────────────────────────── - -// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday. -// The weekday descriptions next to them are display text in the requested language and -// cannot be parsed reliably; these can. A period without `close` is Google's way of -// saying the place never closes. -export interface OpeningTimePoint { - day: number; - hour: number; - minute: number; -} -export interface OpeningPeriod { - open: OpeningTimePoint; - close: OpeningTimePoint | null; -} - -// Places (New) speaks proto3 JSON, which leaves out fields that hold the default -// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes. -function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null { - if (!point || typeof point !== 'object') return null; - const field = (value: unknown): number | null => { - if (value === undefined || value === null) return 0; - return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null; - }; - const day = field(point.day); - const hour = field(point.hour); - const minute = field(point.minute); - if (day === null || hour === null || minute === null) return null; - if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; - return { day, hour, minute }; -} - -export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null { - if (!Array.isArray(periods) || periods.length === 0) return null; - const result: OpeningPeriod[] = []; - for (const period of periods) { - const open = toTimePoint(period?.open); - if (!open) continue; - // A missing close means "never closes"; a close that is there but unusable makes - // the whole period a guess, so it is dropped rather than read as round-the-clock. - const close = period?.close == null ? null : toTimePoint(period.close); - if (period?.close != null && !close) continue; - result.push({ open, close }); - } - return result.length > 0 ? result : null; -} - -// Holidays and other exceptional days that the weekly periods do not describe. Google -// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can -// compare them against the place's own local date. -export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null { - if (!Array.isArray(specialDays) || specialDays.length === 0) return null; - const dates: string[] = []; - for (const entry of specialDays) { - const date = entry?.date; - if (!date) continue; - const { year, month, day } = date; - if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue; - if (month < 1 || month > 12 || day < 1 || day > 31) continue; - const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - if (!dates.includes(iso)) dates.push(iso); - } - return dates.length > 0 ? dates : null; -} - -// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses. -const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g; - -/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */ -function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] { - const day = (dayIdx + 1) % 7; - const periods: OpeningPeriod[] = []; - for (const match of timePart.matchAll(OSM_TIME_RANGE)) { - const openHour = parseInt(match[1], 10); - const openMinute = parseInt(match[2], 10); - let closeHour = parseInt(match[3], 10); - const closeMinute = parseInt(match[4], 10); - if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue; - // OSM writes the end of a day as 24:00, a clock reading Google's numbering has no - // hour 24 — that is midnight of the following day, same as any range that wraps. - let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute; - if (closeHour === 24) { - if (closeMinute !== 0) continue; - closeHour = 0; - nextDay = true; - } - periods.push({ - open: { day, hour: openHour, minute: openMinute }, - close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute }, - }); - } - return periods; -} - -export function parseOpeningHours(ohString: string): { - weekdayDescriptions: string[]; - openNow: boolean | null; - periods: OpeningPeriod[]; -} { - const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; - const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; - const result: string[] = LONG.map((d) => `${d}: ?`); - const periods: OpeningPeriod[] = []; - - // Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00" - for (const segment of ohString.split(';')) { - const trimmed = segment.trim(); - if (!trimmed) continue; - const match = trimmed.match( - /^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i, - ); - if (!match) continue; - const [, daysPart, timePart] = match; - const dayIndices = new Set(); - for (const range of daysPart.split(',')) { - const parts = range - .trim() - .split('-') - .map((d) => DAYS.indexOf(d.trim())); - if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) { - for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i); - dayIndices.add(parts[1]); - } else if (parts[0] >= 0) { - dayIndices.add(parts[0]); - } - } - for (const idx of dayIndices) { - result[idx] = `${LONG[idx]}: ${timePart.trim()}`; - periods.push(...osmPeriods(idx, timePart)); - } - } - - // Compute openNow - let openNow: boolean | null = null; - try { - const now = new Date(); - const jsDay = now.getDay(); - const dayIdx = jsDay === 0 ? 6 : jsDay - 1; - const todayLine = result[dayIdx]; - const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)]; - if (timeRanges.length > 0) { - const nowMins = now.getHours() * 60 + now.getMinutes(); - openNow = timeRanges.some((m) => { - const start = parseInt(m[1]) * 60 + parseInt(m[2]); - const end = parseInt(m[3]) * 60 + parseInt(m[4]); - return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end; - }); - } - } catch { - /* best effort */ - } - - return { weekdayDescriptions: result, openNow, periods }; -} - -// ── Build standardized OSM details ─────────────────────────────────────────── - -export function buildOsmDetails(tags: Record, osmType: string, osmId: string) { - let opening_hours: string[] | null = null; - let open_now: boolean | null = null; - let opening_periods: OpeningPeriod[] | null = null; - if (tags.opening_hours) { - const parsed = parseOpeningHours(tags.opening_hours); - const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?')); - if (hasData) { - opening_hours = parsed.weekdayDescriptions; - open_now = parsed.openNow; - opening_periods = parsed.periods.length > 0 ? parsed.periods : null; - } - } - return { - website: tags['contact:website'] || tags.website || null, - phone: tags['contact:phone'] || tags.phone || null, - opening_hours, - open_now, - opening_periods, - osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, - summary: tags.description || null, - source: 'openstreetmap' as const, - }; -} - -// ── Wikimedia Commons photo lookup ─────────────────────────────────────────── - -export async function fetchWikimediaPhoto( - lat: number, - lng: number, - name?: string, -): Promise<{ photoUrl: string; attribution: string | null } | null> { - // Strategy 1: Search Wikipedia for the place name -> get the article image - if (name) { - try { - const searchParams = new URLSearchParams({ - action: 'query', - format: 'json', - titles: name, - prop: 'pageimages', - piprop: 'thumbnail', - pithumbsize: '400', - pilimit: '1', - redirects: '1', - }); - const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); - if (res.ok) { - const data = (await res.json()) as { query?: { pages?: Record } }; - const pages = data.query?.pages; - if (pages) { - for (const page of Object.values(pages)) { - if (page.thumbnail?.source) { - return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' }; - } - } - } - } - } catch { - /* fall through to geosearch */ - } - } - - // Strategy 2: Wikimedia Commons geosearch by coordinates - const params = new URLSearchParams({ - action: 'query', - format: 'json', - generator: 'geosearch', - ggsprimary: 'all', - ggsnamespace: '6', - ggsradius: '300', - ggscoord: `${lat}|${lng}`, - ggslimit: '5', - prop: 'imageinfo', - iiprop: 'url|extmetadata|mime', - iiurlwidth: '400', - }); - try { - const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); - if (!res.ok) return null; - const data = (await res.json()) as { - query?: { pages?: Record }; - }; - const pages = data.query?.pages; - if (!pages) return null; - for (const page of Object.values(pages)) { - const info = page.imageinfo?.[0]; - // Only use actual photos (JPEG/PNG), skip SVGs and PDFs - const mime = (info as { mime?: string })?.mime || ''; - if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) { - const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null; - // iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it — - // info.url is the full-resolution original (multi-megapixel camera exports). - return { photoUrl: info.thumburl ?? info.url, attribution }; - } - } - return null; - } catch { - return null; - } -} - -// ── Search places (Google or Nominatim fallback) ───────────────────────────── - -export async function searchPlaces( - userId: number, - query: string, - lang?: string, - locationBias?: { lat: number; lng: number; radius?: number }, -): Promise<{ places: Record[]; source: string }> { - const apiKey = getMapsKey(userId); - - if (!apiKey) { - const places = await searchNominatim(query, lang); - return { places, source: 'openstreetmap' }; - } - - const searchBody: Record = { textQuery: query, languageCode: toApiLang(lang) }; - // Bias results toward the caller's area when supplied — without it Google Text - // Search falls back to the API key's billing region, which skews foreign-region queries. - if (locationBias) { - searchBody.locationBias = { - circle: { - center: { latitude: locationBias.lat, longitude: locationBias.lng }, - radius: locationBias.radius ?? 50000, - }, - }; - } - - const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': - 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri', - }, - body: JSON.stringify(searchBody), - }); - - const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } }; - - if (!response.ok) { - const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; - err.status = response.status; - throw err; - } - - const places = (data.places || []).map((p: GooglePlaceResult) => ({ - google_place_id: p.id, - google_ftid: googleFtidFromMapsUrl(p.googleMapsUri), - name: p.displayName?.text || '', - address: p.formattedAddress || '', - lat: p.location?.latitude || null, - lng: p.location?.longitude || null, - rating: p.rating || null, - website: p.websiteUri || null, - phone: p.nationalPhoneNumber || null, - types: p.types || [], - source: 'google', - })); - - return { places, source: 'google' }; -} - -// ── Autocomplete (Google or Nominatim fallback) ───────────────────────────── - -export async function autocompletePlaces( - userId: number, - input: string, - lang?: string, - locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, -): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { - const apiKey = getMapsKey(userId); - - if (!apiKey) { - return autocompleteNominatim(input, lang); - } - - const body: Record = { - input, - languageCode: toApiLang(lang), - }; - if (locationBias) { - body.locationBias = { - rectangle: { - low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng }, - high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng }, - }, - }; - } - - const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - }, - body: JSON.stringify(body), - }); - - const data = (await response.json()) as { - suggestions?: GoogleAutocompleteSuggestion[]; - error?: { message?: string }; - }; - - if (!response.ok) { - const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number }; - err.status = response.status; - throw err; - } - - const suggestions = (data.suggestions || []) - .filter((s) => s.placePrediction) - .slice(0, 5) - .map((s) => ({ - placeId: s.placePrediction!.placeId, - mainText: s.placePrediction!.structuredFormat?.mainText?.text || '', - secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '', - })); - - return { suggestions, source: 'google' }; -} - -async function autocompleteNominatim( - input: string, - lang?: string, -): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> { - try { - const places = await 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) ──────────────────────────────────────────── - -// Ids that can never resolve against the Google Places API: coordinate pseudo-ids -// (right-click places, in both the coords: and the bare "lat,lng" spelling the -// collection views send), OSM ids the client sends when a place has no -// google_place_id, and the raw photo URLs legacy rows keep in image_url. Google -// answers those with a billable 400 INVALID_ARGUMENT, so every lookup sorts them -// out before the call and uses the OSM/Wikimedia path instead. -const NON_GOOGLE_PLACE_ID = /^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$/i; -// The subset that still has a provider behind it — Overpass for details, -// Wikimedia for photos. -const OSM_PLACE_ID = /^(?:node|way|relation):/i; - -export function isGooglePlaceId(placeId: string): boolean { - return !NON_GOOGLE_PLACE_ID.test(placeId); -} - -export async function getPlaceDetails( - userId: number, - placeId: string, - lang?: string, -): Promise<{ place: Record }> { - // OSM details: placeId is "node:123456" or "way:123456" etc. - if (placeId.includes(':')) { - const [osmType, osmId] = placeId.split(':'); - const element = await fetchOverpassDetails(osmType, osmId); - const details = buildOsmDetails(element?.tags || {}, osmType, osmId); - - // Fetch Nominatim only when Overpass lacks coordinates or address - const d = details as Record; - const needsNominatim = !d.lat || !d.lng || !d.address; - const nominatim = needsNominatim ? await lookupNominatim(osmType, osmId, lang) : null; - - return { - place: { - ...details, - name: (d.name as string) || nominatim?.name || element?.tags?.name || '', - address: (d.address as string) || nominatim?.address || '', - lat: d.lat ?? nominatim?.lat ?? null, - lng: d.lng ?? nominatim?.lng ?? null, - osm_id: placeId, - }, - }; - } - - // Google details - const langKey = toApiLang(lang, 'de'); - const apiKey = getMapsKey(userId); - if (!apiKey) { - throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); - } - - // Check DB cache first (lean mask, expanded=0) — 7-day TTL - const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000; - const cached = db - .prepare( - 'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0', - ) - .get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined; - if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) }; - - const response = await googleFetch( - `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, - `getPlaceDetails(${placeId})`, - { - method: 'GET', - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': - 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri', - }, - }, - ); - - const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } }; - - if (!response.ok) { - const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; - err.status = response.status; - throw err; - } - - const place = { - google_place_id: data.id, - google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), - name: data.displayName?.text || '', - address: data.formattedAddress || '', - lat: data.location?.latitude || null, - lng: data.location?.longitude || null, - rating: data.rating || null, - rating_count: data.userRatingCount || null, - website: data.websiteUri || null, - phone: data.nationalPhoneNumber || null, - opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, - open_now: data.regularOpeningHours?.openNow ?? null, - // open_now is a snapshot Google took when this payload was fetched and it is cached - // for days; the periods let the client recompute the state in the place's own - // timezone, which the localised weekday lines above cannot do. Issue #1680. - opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods), - opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays), - google_maps_url: data.googleMapsUri || null, - summary: null, - reviews: [], - source: 'google' as const, - cached_at: Date.now(), - }; - - try { - db.prepare( - 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)', - ).run(placeId, langKey, JSON.stringify(place), Date.now()); - } catch (dbErr) { - console.error('Failed to cache place details:', dbErr); - } - - return { place }; -} - -export async function getPlaceDetailsExpanded( - userId: number, - placeId: string, - lang?: string, - refresh = false, -): Promise<{ place: Record | null }> { - // Reviews and the editorial summary only exist at Google, but the id does not - // have to be a Google one — the client sends whatever the place carries. OSM ids - // keep the details they do have (Overpass, via the plain lookup); coordinate - // pseudo-ids and legacy image URLs have no details source at all. Neither may be - // forwarded to Google, which bills the 400 INVALID_ARGUMENT it answers with. - if (!isGooglePlaceId(placeId)) { - return OSM_PLACE_ID.test(placeId) ? getPlaceDetails(userId, placeId, lang) : { place: null }; - } - - const langKey = toApiLang(lang, 'de'); - const apiKey = getMapsKey(userId); - if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); - - // Check DB cache for expanded result - if (!refresh) { - const cached = db - .prepare('SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1') - .get(placeId, langKey) as { payload_json: string } | undefined; - if (cached) return { place: JSON.parse(cached.payload_json) }; - } - - const response = await googleFetch( - `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, - `getPlaceDetailsExpanded(${placeId})`, - { - method: 'GET', - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': - 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary', - }, - }, - ); - - const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } }; - - if (!response.ok) { - const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; - err.status = response.status; - throw err; - } - - const place = { - google_place_id: data.id, - google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), - name: data.displayName?.text || '', - address: data.formattedAddress || '', - lat: data.location?.latitude || null, - lng: data.location?.longitude || null, - rating: data.rating || null, - rating_count: data.userRatingCount || null, - website: data.websiteUri || null, - phone: data.nationalPhoneNumber || null, - opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, - open_now: data.regularOpeningHours?.openNow ?? null, - opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods), - opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays), - google_maps_url: data.googleMapsUri || null, - summary: data.editorialSummary?.text || null, - reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable[number]) => ({ - author: r.authorAttribution?.displayName || null, - rating: r.rating || null, - text: r.text?.text || null, - time: r.relativePublishTimeDescription || null, - photo: r.authorAttribution?.photoUri || null, - })), - source: 'google' as const, - cached_at: Date.now(), - }; - - try { - db.prepare( - 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)', - ).run(placeId, langKey, JSON.stringify(place), Date.now()); - } catch (dbErr) { - console.error('Failed to cache expanded place details:', dbErr); - } - - return { place }; -} - -// ── Place photo (Google or Wikimedia, disk-cached) ──────────────────────────── - -export async function getPlacePhoto( - userId: number, - placeId: string, - lat: number, - lng: number, - name?: string, -): Promise<{ photoUrl: string | null; attribution: string | null }> { - // Disk cache hit — serve immediately, no Google call - const diskHit = placePhotoCache.get(placeId); - if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution }; - - // "No photo for this place" is an empty result, not a missing resource: a trip - // view asks for one photo per place, so answering each miss with a 404 makes a - // normal itinerary render look like a 404 scan to fail2ban/CrowdSec and gets - // the user's IP banned. Every miss below returns photoUrl: null instead — the - // same shape the photos kill-switch already returns. - const noPhoto = { photoUrl: null, attribution: null }; - - // Recent miss — don't hammer the API - if (placePhotoCache.getErrored(placeId)) return noPhoto; - - // Deduplicate concurrent requests for the same placeId - const existing = placePhotoCache.getInFlight(placeId); - if (existing) { - const result = await existing; - if (!result) return noPhoto; - return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; - } - - // Tells the two empty outcomes apart for the negative cache below: a place that - // has no photo anywhere is worth remembering for a day, a provider that refused - // or timed out only for a few minutes. - let providerFailed = false; - - const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => { - await acquirePhotoFetchSlot(); - try { - const apiKey = getMapsKey(userId); - - // Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only - // (right-click) places and as a fallback when a Google place yields no photo, - // so a place added via search still gets a marker image when Google returns - // nothing. Returns null (without marking an error) so the caller decides. - const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => { - if (isNaN(lat) || isNaN(lng)) return null; - try { - const wiki = await fetchWikimediaPhoto(lat, lng, name); - if (!wiki) return null; - // Follow redirects manually so each hop (the image URL can 3xx to a CDN - // host) is re-validated against the SSRF guard, not just the first URL. - const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true }); - if (!imgRes.ok) { - providerFailed = true; - return null; - } - const bytes = Buffer.from(await imgRes.arrayBuffer()); - const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution); - return { filePath: cached.filePath, attribution: cached.attribution }; - } catch { - providerFailed = true; - return null; - } - }; - - // Google Places photo for a Google place_id. Returns null on any miss — no - // key, request rejected, no photos, or a failed media download — so the - // caller can fall back to Wikimedia; the misses that were Google's fault - // flag providerFailed on the way out. - const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => { - if (!apiKey) return null; - - // Fetch details to get the photo name - const detailsRes = await googleFetch( - `https://places.googleapis.com/v1/places/${placeId}`, - `getPlacePhoto/details(${placeId})`, - { - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': 'photos', - }, - }, - ); - const body = await detailsRes.text(); - if (!detailsRes.ok) { - console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200)); - providerFailed = true; - return null; - } - let details: GooglePlaceDetails & { error?: { message?: string } }; - try { - details = body ? JSON.parse(body) : { photos: [] }; - } catch { - providerFailed = true; - return null; - } - if (!details.photos?.length) return null; - - const photo = details.photos[0]; - const photoName = photo.name; - const attribution = photo.authorAttributions?.[0]?.displayName || null; - - // Fetch actual image bytes - const mediaRes = await googleFetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`, - `getPlacePhoto/media(${placeId})`, - { headers: { 'X-Goog-Api-Key': apiKey } }, - ); - // The place does have a photo — only the download for it went wrong. - if (!mediaRes.ok) { - providerFailed = true; - return null; - } - - const bytes = Buffer.from(await mediaRes.arrayBuffer()); - if (!bytes.length) { - providerFailed = true; - return null; - } - - const cached = await placePhotoCache.put(placeId, bytes, attribution); - - // Persist stable proxy URL to database - try { - db.prepare( - "UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')", - ).run(cached.photoUrl, placeId); - } catch (dbErr) { - console.error('Failed to persist photo URL to database:', dbErr); - } - - return { filePath: cached.filePath, attribution }; - }; - - // Prefer the Google photo (higher quality); if Google yields nothing, fall - // back to the same coordinate-based Wikipedia/OSM lookup that right-click - // places use. Ids Google cannot resolve skip it entirely. - if (isGooglePlaceId(placeId)) { - const googlePhoto = await fetchGooglePhoto(); - if (googlePhoto) return googlePhoto; - } - - const fallback = await fetchWikimediaFallback(); - if (fallback) return fallback; - - placePhotoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo'); - return null; - } finally { - releasePhotoFetchSlot(); - } - })(); - - placePhotoCache.setInFlight(placeId, fetchPromise); - - const result = await fetchPromise; - if (!result) return noPhoto; - return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; -} - -// ── Reverse geocoding ──────────────────────────────────────────────────────── - -export async function reverseGeocode( - lat: string, - lng: string, - lang?: string, -): Promise<{ name: string | null; address: string | null }> { - const params = new URLSearchParams({ - lat, - lon: lng, - format: 'json', - addressdetails: '1', - zoom: '18', - 'accept-language': toApiLang(lang), - }); - const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { - headers: { 'User-Agent': UA }, - }); - 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 = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; - return { name, address: data.display_name || null }; -} - -// ── Resolve Google Maps URL ────────────────────────────────────────────────── - -export async function resolveGoogleMapsUrl( - url: string, -): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> { - let resolvedUrl = url; - - // Extract coordinates from a string (URL or page body). Google Maps encodes - // them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=. - const extractCoords = (s: string): { lat: number; lng: number } | null => { - const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); - if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) }; - const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/); - if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) }; - const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/); - if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) }; - return null; - }; - - const followRedirects = async (target: string, init?: RequestInit): Promise => { - try { - return await safeFetchFollow( - target, - { signal: AbortSignal.timeout(10000), ...init }, - { bypassInternalIpAllowed: true }, - ); - } catch (err) { - if (err instanceof SsrfBlockedError) { - throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 }); - } - throw err; - } - }; - - // Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps - // URLs that carry no inline coordinates — e.g. ?cid= links (the format - // get_place_details returns) and "Share"-button links. The redirect target - // usually carries the !3d!4d data param we can then parse. Redirects are - // followed manually so every hop is SSRF-re-checked. - const parsed = new URL(url); - const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com']; - const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname); - const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname); - if (isShort || (isGoogleMaps && !extractCoords(url))) { - resolvedUrl = (await followRedirects(url)).url || resolvedUrl; - } - - let coords = extractCoords(resolvedUrl); - - // Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the - // page body once and parse the coordinates out of the embedded map data. - if (!coords) { - try { - const pageRes = await followRedirects(resolvedUrl, { - headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, - }); - coords = extractCoords(await pageRes.text()); - } catch (err) { - if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it - // Otherwise fall through to the not-found error below. - } - } - - // Extract place name from URL path: /place/Place+Name/@... - let placeName: string | null = null; - const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/); - if (placeMatch) { - placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); - } - - if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) { - throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 }); - } - const { lat, lng } = coords; - - // Reverse geocode to get address - const nominatimRes = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, - { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }, - ); - const nominatim = (await nominatimRes.json()) as { - display_name?: string; - name?: string; - address?: Record; - }; - - const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; - const address = nominatim.display_name || null; - - return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) }; -} diff --git a/server/src/services/placeEnrichment.ts b/server/src/services/placeEnrichment.ts index 693aa1053..d21c88874 100644 --- a/server/src/services/placeEnrichment.ts +++ b/server/src/services/placeEnrichment.ts @@ -1,6 +1,6 @@ import { db, getPlaceWithTags } from '../db/database'; import { broadcast } from '../websocket'; -import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService'; +import { getMapsKey, searchPlaces, getPlacePhoto } from '../nest/maps/maps.bridge'; /** * Background enrichment for list-imported places (#886). diff --git a/server/src/services/transitService.ts b/server/src/services/transitService.ts index 6cdc80485..f0da642a8 100644 --- a/server/src/services/transitService.ts +++ b/server/src/services/transitService.ts @@ -1,5 +1,5 @@ import { readEnv } from '../app-config'; -import { buildUserAgent } from './mapsService'; +import { buildUserAgent } from '../nest/maps/maps.helpers'; import { getAppUrl } from '../app-config'; /** diff --git a/server/tests/e2e/maps.e2e.test.ts b/server/tests/e2e/maps.e2e.test.ts index 8892ae44e..abbd66f45 100644 --- a/server/tests/e2e/maps.e2e.test.ts +++ b/server/tests/e2e/maps.e2e.test.ts @@ -1,8 +1,9 @@ /** * Maps module e2e — exercises the migrated /api/maps endpoints through the real - * JwtAuthGuard against a temp SQLite db. mapsService is mocked (no outbound HTTP), - * and the temp db carries an empty app_settings table so the kill-switch reads - * resolve to "enabled". + * JwtAuthGuard against a temp SQLite db. The DI-native MapsService's provider + * methods are stubbed via instance spies (no outbound HTTP), and the temp db + * carries an empty app_settings table so the kill-switch reads resolve to + * "enabled". */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; @@ -24,25 +25,11 @@ const { db } = vi.hoisted(() => { vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); -const { mocks } = vi.hoisted(() => ({ - mocks: { - searchPlaces: vi.fn(), - autocompletePlaces: vi.fn(), - getPlaceDetails: vi.fn(), - getPlaceDetailsExpanded: vi.fn(), - getPlacePhoto: vi.fn(), - reverseGeocode: vi.fn(), - resolveGoogleMapsUrl: vi.fn(), - }, -})); -vi.mock('../../src/services/mapsService', async (importActual) => { - const actual = await importActual(); - return { ...actual, ...mocks }; -}); - import { MapsModule } from '../../src/nest/maps/maps.module'; +import { MapsService } from '../../src/nest/maps/maps.service'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; describe('Maps e2e (real auth guard + temp SQLite)', () => { let server: Server; @@ -53,6 +40,10 @@ describe('Maps e2e (real auth guard + temp SQLite)', () => { const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); + // Same harness shape as the todo/budget e2e suites: the APP_PIPE from + // app.module.ts isn't in this focused module graph, so wire it by hand — + // it validates any @Body() typed with a Zod DTO metatype. + nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } @@ -61,8 +52,11 @@ describe('Maps e2e (real auth guard + temp SQLite)', () => { seedUser(db as never, { id: 1 }); app = await build(); server = app.getHttpServer(); - mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' }); - mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' }); + // Stub the provider fan-out on the container's MapsService instance — the + // controller-facing wrapper methods delegate to these since the maps fold. + const maps = app.get(MapsService); + vi.spyOn(maps, 'searchPlaces').mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' }); + vi.spyOn(maps, 'reverseGeocode').mockResolvedValue({ name: 'Spot', address: 'Street 1' }); }); afterAll(async () => { @@ -78,7 +72,10 @@ describe('Maps e2e (real auth guard + temp SQLite)', () => { it('400 when authenticated but query is missing', async () => { const res = await request(server).post('/api/maps/search').set('Cookie', sessionCookie(1)).send({}); expect(res.status).toBe(400); - expect(res.body).toEqual({ error: 'Search query is required' }); + // The exact error wording is pinned in maps.controller.test.ts — this suite + // pins the status contract only (the body-contract ratchet reshapes the 400 + // envelope in its own commit). + expect(res.body).toHaveProperty('error'); }); it('200 with results for a search (POST stays 200, not 201)', async () => { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 915e8d747..1da107a18 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -15,6 +15,8 @@ import { DayNotesMcp } from '../../src/nest/days/day-notes.mcp'; import { DayNotesService } from '../../src/nest/days/day-notes.service'; import { DaysMcp } from '../../src/nest/days/days.mcp'; import { DaysService } from '../../src/nest/days/days.service'; +import { MapsMcp } from '../../src/nest/maps/maps.mcp'; +import { MapsService } from '../../src/nest/maps/maps.service'; import { PackingMcp } from '../../src/nest/packing/packing.mcp'; import { PackingService } from '../../src/nest/packing/packing.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; @@ -79,6 +81,7 @@ export function createMcpTestRegistry(): McpRegistry { new VacayMcp(new VacayService(dbService, realtimeService)), new TripsMcp(tripsService, todoService, collabService), new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)), + new MapsMcp(new MapsService(dbService)), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, ); diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index fe937aa8b..01c9127d0 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -46,37 +46,46 @@ vi.mock('../../src/config', () => ({ })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for -// URLs that look internal); individual tests override with mockResolvedValueOnce. -vi.mock('../../src/services/mapsService', () => ({ - searchPlaces: vi.fn(), - autocompletePlaces: vi.fn(), - getPlaceDetails: vi.fn(), - getPlacePhoto: vi.fn(), - reverseGeocode: vi.fn(), - resolveGoogleMapsUrl: vi.fn().mockRejectedValue( - Object.assign(new Error('SSRF or invalid URL'), { status: 400 }) - ), - // Imported at module load by transitService (pulled in via app.module). - buildUserAgent: () => 'TREK-Test-UA', -})); - import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import * as mapsService from '../../src/services/mapsService'; +import { MapsService } from '../../src/nest/maps/maps.service'; let nestApp: INestApplication; let app: Application; +// Instance spies on the container's MapsService (permissions-migration +// precedent) — the DI fold made the provider methods the outbound seam. +// Defaults mirror the old module mock: bare resolves, except +// resolveGoogleMapsUrl which rejects with 400 (SSRF-like behaviour for URLs +// that look internal); individual tests override with mockResolvedValueOnce. +let mapsService: { + searchPlaces: ReturnType; + autocompletePlaces: ReturnType; + getPlaceDetails: ReturnType; + getPlacePhoto: ReturnType; + reverseGeocode: ReturnType; + resolveGoogleMapsUrl: ReturnType; +}; beforeAll(async () => { createTables(testDb); runMigrations(testDb); nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + const maps = nestApp.get(MapsService); + mapsService = { + searchPlaces: vi.spyOn(maps, 'searchPlaces').mockResolvedValue(undefined as never), + autocompletePlaces: vi.spyOn(maps, 'autocompletePlaces').mockResolvedValue(undefined as never), + getPlaceDetails: vi.spyOn(maps, 'getPlaceDetails').mockResolvedValue(undefined as never), + getPlacePhoto: vi.spyOn(maps, 'getPlacePhoto').mockResolvedValue(undefined as never), + reverseGeocode: vi.spyOn(maps, 'reverseGeocode').mockResolvedValue(undefined as never), + resolveGoogleMapsUrl: vi.spyOn(maps, 'resolveGoogleMapsUrl').mockRejectedValue( + Object.assign(new Error('SSRF or invalid URL'), { status: 400 }), + ), + }; }); beforeEach(() => { @@ -161,7 +170,7 @@ describe('Maps SSRF protection', () => { describe('Maps happy paths (mocked service)', () => { it('MAPS-002 — POST /maps/search returns results from service', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.searchPlaces).mockResolvedValueOnce({ + mapsService.searchPlaces.mockResolvedValueOnce({ results: [{ address: 'Paris, France', source: 'nominatim' }], } as any); @@ -177,7 +186,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-003 — GET /maps/details/:placeId returns place details', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.getPlaceDetails).mockResolvedValueOnce({ + mapsService.getPlaceDetails.mockResolvedValueOnce({ name: 'Eiffel Tower', address: 'Champ de Mars, Paris', } as any); @@ -192,7 +201,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-004 — GET /maps/place-photo/:placeId returns photo url', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({ + mapsService.getPlacePhoto.mockResolvedValueOnce({ url: 'https://example.com/photo.jpg', source: 'wikimedia', } as any); @@ -207,7 +216,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-005 — GET /maps/reverse returns geocoded location', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.reverseGeocode).mockResolvedValueOnce({ + mapsService.reverseGeocode.mockResolvedValueOnce({ name: 'Eiffel Tower', address: 'Champ de Mars, Paris', } as any); @@ -222,7 +231,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({ + mapsService.resolveGoogleMapsUrl.mockResolvedValueOnce({ lat: 48.8584, lng: 2.2945, } as any); @@ -240,7 +249,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-002 — search service error propagates correct status', async () => { const { user } = createUser(testDb); const err = Object.assign(new Error('No API key'), { status: 503 }); - vi.mocked(mapsService.searchPlaces).mockRejectedValueOnce(err); + mapsService.searchPlaces.mockRejectedValueOnce(err); const res = await request(app) .post('/api/maps/search') @@ -252,7 +261,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-003 — getPlaceDetails error returns 500', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.getPlaceDetails).mockRejectedValueOnce(new Error('External API failure')); + mapsService.getPlaceDetails.mockRejectedValueOnce(new Error('External API failure')); const res = await request(app) .get('/api/maps/details/some-place-id') @@ -264,7 +273,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-004 — getPlacePhoto error with status returns that status', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce( + mapsService.getPlacePhoto.mockRejectedValueOnce( Object.assign(new Error('Photo provider unavailable'), { status: 503 }) ); @@ -280,7 +289,7 @@ describe('Maps happy paths (mocked service)', () => { // a normal itinerary render into a 404 storm that fail2ban/CrowdSec bans (#1727). it('MAPS-004b — a place with no photo answers 200 with photoUrl null', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({ photoUrl: null, attribution: null }); + mapsService.getPlacePhoto.mockResolvedValueOnce({ photoUrl: null, attribution: null }); const res = await request(app) .get('/api/maps/place-photo/node%3A5255005321?lat=36.76&lng=-3.84&name=Nerja') @@ -306,7 +315,7 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-005 — reverseGeocode error returns null values', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed')); + mapsService.reverseGeocode.mockRejectedValueOnce(new Error('Geocode failed')); const res = await request(app) .get('/api/maps/reverse?lat=48.8584&lng=2.2945') @@ -358,7 +367,7 @@ describe('Maps autocomplete', () => { it('MAPS-013 — POST /maps/autocomplete returns suggestions from service', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({ + mapsService.autocompletePlaces.mockResolvedValueOnce({ suggestions: [ { placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }, ], @@ -378,7 +387,7 @@ describe('Maps autocomplete', () => { it('MAPS-014 — POST /maps/autocomplete passes lang and locationBias to service', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({ + mapsService.autocompletePlaces.mockResolvedValueOnce({ suggestions: [], source: 'google', }); @@ -399,7 +408,7 @@ describe('Maps autocomplete', () => { it('MAPS-015 — autocomplete service error propagates correct status', async () => { const { user } = createUser(testDb); const err = Object.assign(new Error('Rate limited'), { status: 429 }); - vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(err); + mapsService.autocompletePlaces.mockRejectedValueOnce(err); const res = await request(app) .post('/api/maps/autocomplete') @@ -412,7 +421,7 @@ describe('Maps autocomplete', () => { it('MAPS-016 — autocomplete service error without status returns 500', async () => { const { user } = createUser(testDb); - vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(new Error('Unknown')); + mapsService.autocompletePlaces.mockRejectedValueOnce(new Error('Unknown')); const res = await request(app) .post('/api/maps/autocomplete') @@ -431,6 +440,7 @@ describe('Maps autocomplete', () => { .send({ input: 'a'.repeat(201) }); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/too long/i); + // Wording pinned in maps.controller.test.ts (see the e2e suite's note). + expect(res.body).toHaveProperty('error'); }); }); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 5603b7fb7..b163d3309 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -39,7 +39,14 @@ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() })); -vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlacesMock })); +// The maps bridge is what the legacy places registrar (and placeEnrichment, in +// the same import graph) consume since the maps DI fold — mock every export it +// provides so the named imports resolve. +vi.mock('../../../src/nest/maps/maps.bridge', () => ({ + searchPlaces: searchPlacesMock, + getMapsKey: vi.fn(() => null), + getPlacePhoto: vi.fn(async () => ({ photoUrl: null, attribution: null })), +})); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; 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 1f4290fd2..f840d9e62 100644 --- a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts +++ b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts @@ -35,13 +35,6 @@ vi.mock('../../../src/config', () => ({ const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); -vi.mock('../../../src/services/mapsService', () => ({ - searchPlaces: vi.fn(), - getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }), - reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }), - resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }), -})); - vi.mock('../../../src/services/weatherService', () => ({ getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }), getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }), @@ -52,7 +45,21 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; -import * as mapsService from '../../../src/services/mapsService'; +import { MapsService } from '../../../src/nest/maps/maps.service'; + +// The geo tools live on the DI-discovered maps.mcp.ts since the maps fold; the +// test registry builds a real MapsService over the mocked db proxy, so stub the +// provider methods on the prototype (no auto-restore in the vitest config — +// these survive across tests, exactly like the old module mock did). +const getPlaceDetailsSpy = vi + .spyOn(MapsService.prototype, 'getPlaceDetails') + .mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' } as never); +vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: 'Paris', address: 'France' }); +vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({ + lat: 48.8566, + lng: 2.3522, + name: 'Paris', +} as never); beforeAll(() => { createTables(testDb); @@ -305,8 +312,7 @@ describe('Tool: get_place_details', () => { }); it('returns isError when service returns null', async () => { - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - (getPlaceDetails as ReturnType).mockResolvedValueOnce(null); + getPlaceDetailsSpy.mockResolvedValueOnce(null as never); const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ diff --git a/server/tests/unit/nest/booking-import/booking-import.service.test.ts b/server/tests/unit/nest/booking-import/booking-import.service.test.ts index 2c3157c71..4e9792498 100644 --- a/server/tests/unit/nest/booking-import/booking-import.service.test.ts +++ b/server/tests/unit/nest/booking-import/booking-import.service.test.ts @@ -10,7 +10,6 @@ vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() })); const permissionsStub = { checkPermission: vi.fn(() => true) }; vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() })); vi.mock('../../../../src/services/placeService', () => ({ createPlace: vi.fn() })); -vi.mock('../../../../src/services/mapsService', () => ({ searchNominatim: vi.fn() })); import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service'; @@ -21,7 +20,10 @@ function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) }; const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) }; const reservations = { create: vi.fn() }; - return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never), extractor, llmParse, reservations }; + // budget/addons/realtime/maps ride the confirm() path only — the preview() + // tests never reach them, so stubs beyond the positional slots aren't needed. + const maps = { searchNominatim: vi.fn() }; + return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn), reservations as never, permissionsStub as never, undefined as never, undefined as never, undefined as never, maps as never), extractor, llmParse, reservations, maps }; } beforeEach(() => vi.clearAllMocks()); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 5b175e4b1..ae4a6fa1f 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -1,131 +1,2223 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +/** + * Unit tests for the DI-native MapsService — MAPS-001 through MAPS-106. + * Moved 1:1 from the legacy tests/unit/services/mapsService.test.ts when the + * maps domain folded into src/nest/maps/ (case IDs preserved; the wrapper + * suite's delegation cases died with the delegation itself — the kill-switch, + * photoBytesPath and bridge cases live at the bottom). + * Covers parseOpeningHours, buildOsmDetails, getMapsKey, reverseGeocode, + * resolveGoogleMapsUrl (coordinate extraction + short URL / SSRF), + * searchNominatim, fetchOverpassDetails, fetchWikimediaPhoto, searchPlaces, + * getPlaceDetails, and getPlacePhoto (all branches including cache logic). + * fetch is stubbed; DB and ssrfGuard are mocked. + */ +import { + parseOpeningHours, + normalizeOpeningPeriods, + normalizeSpecialDays, + buildOsmDetails, + googleFtidFromMapsUrl, + isGooglePlaceId, + buildUserAgent, + resolveOverpassEndpoints, + resolveOverpassTimeoutMs, +} from '../../../src/nest/maps/maps.helpers'; -const { maps } = vi.hoisted(() => ({ - maps: { - searchPlaces: vi.fn(), - autocompletePlaces: vi.fn(), - getPlaceDetails: vi.fn(), - getPlaceDetailsExpanded: vi.fn(), - getPlacePhoto: vi.fn(), - reverseGeocode: vi.fn(), - resolveGoogleMapsUrl: vi.fn(), - searchOverpassPois: vi.fn(), +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; + +const { + mockDbGet, + mockDbRun, + mockCheckSsrf, + mockCacheGet, + mockCacheGetErrored, + mockCacheMarkError, + mockCachePut, + mockCacheGetInFlight, + mockCacheSetInFlight, + mockServeFilePath, +} = vi.hoisted(() => ({ + mockDbGet: vi.fn(() => undefined as any), + mockDbRun: vi.fn(), + mockCheckSsrf: vi.fn(async () => ({ allowed: true })), + mockCacheGet: vi.fn(() => null as any), + mockCacheGetErrored: vi.fn(() => false), + mockCacheMarkError: vi.fn(), + mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + filePath: `/tmp/${placeId}.jpg`, + attribution, + })), + mockCacheGetInFlight: vi.fn(() => undefined), + mockCacheSetInFlight: vi.fn(), + mockServeFilePath: vi.fn((_placeId: string) => null as string | null), +})); + +vi.mock('../../../src/db/database', () => ({ + db: { + prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: mockDbRun }), }, })); -vi.mock('../../../src/services/mapsService', () => maps); -const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() })); -vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath })); +vi.mock('../../../src/utils/ssrfGuard', () => { + class SsrfBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsrfBlockedError'; + } + } + return { + checkSsrf: mockCheckSsrf, + SsrfBlockedError, + // Mirror the real per-hop helper closely enough for unit tests: run the + // (mocked) SSRF check, then fetch through the (stubbed) global fetch. The + // fetch stubs in these tests already return the final resolved response. + safeFetchFollow: vi.fn(async (url: string, init?: any) => { + const ssrf = await mockCheckSsrf(url); + if (!ssrf.allowed) throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard'); + return (globalThis.fetch as any)(url, init); + }), + }; +}); +vi.mock('../../../src/services/apiKeyCrypto', () => ({ + decrypt_api_key: (v: string | null) => v, +})); + +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: '0'.repeat(64), +})); + +vi.mock('../../../src/services/placePhotoCache', () => ({ + get: (placeId: string) => mockCacheGet(placeId), + getErrored: (placeId: string) => mockCacheGetErrored(placeId), + put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution), + markError: (placeId: string, kind?: string) => mockCacheMarkError(placeId, kind), + getInFlight: (placeId: string) => mockCacheGetInFlight(placeId), + setInFlight: (placeId: string, p: Promise) => mockCacheSetInFlight(placeId, p), + serveFilePath: (placeId: string) => mockServeFilePath(placeId), +})); + +import { db } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; import { MapsService } from '../../../src/nest/maps/maps.service'; -import type { DatabaseService } from '../../../src/nest/database/database.service'; -/** A DatabaseService stub whose get() returns the row the test wants. */ -function makeDb(row?: { value: string }) { - const get = vi.fn(() => row); - const db = { get } as unknown as DatabaseService; - return { db, get }; -} +// The service under test, constructed over the mocked db stub — DatabaseService +// routes get/run through the stubbed prepare(), so mockDbGet/mockDbRun keep +// flowing exactly as they did for the legacy module. +const svc = new MapsService(new DatabaseService(db as never)); -function svc(row?: { value: string }) { - return new MapsService(makeDb(row).db); -} +afterEach(() => { + vi.unstubAllGlobals(); + mockDbGet.mockReset(); + mockDbGet.mockReturnValue(undefined); + mockDbRun.mockReset(); + mockCheckSsrf.mockReset(); + mockCheckSsrf.mockResolvedValue({ allowed: true }); + mockCacheGet.mockReset(); + mockCacheGet.mockReturnValue(null); + mockCacheGetErrored.mockReset(); + mockCacheGetErrored.mockReturnValue(false); + mockCacheMarkError.mockReset(); + mockCachePut.mockReset(); + mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + filePath: `/tmp/${placeId}.jpg`, + attribution, + })); + mockCacheGetInFlight.mockReset(); + mockCacheGetInFlight.mockReturnValue(undefined); + mockCacheSetInFlight.mockReset(); + mockServeFilePath.mockReset(); + mockServeFilePath.mockReturnValue(null); +}); -beforeEach(() => vi.clearAllMocks()); +// ── parseOpeningHours ───────────────────────────────────────────────────────── -describe('MapsService', () => { - describe('kill-switch settings reads', () => { - it('reports a switch disabled when the stored value is exactly "false"', () => { - expect(svc({ value: 'false' }).autocompleteDisabled()).toBe(true); - expect(svc({ value: 'false' }).detailsDisabled()).toBe(true); - expect(svc({ value: 'false' }).photosDisabled()).toBe(true); - }); - - it('reports enabled when the value is "true"', () => { - expect(svc({ value: 'true' }).autocompleteDisabled()).toBe(false); - expect(svc({ value: 'true' }).detailsDisabled()).toBe(false); - expect(svc({ value: 'true' }).photosDisabled()).toBe(false); - }); - - it('reports enabled when the setting row is absent', () => { - expect(svc(undefined).autocompleteDisabled()).toBe(false); - expect(svc(undefined).detailsDisabled()).toBe(false); - expect(svc(undefined).photosDisabled()).toBe(false); - }); - - it('queries the matching app_settings key', () => { - const { db, get } = makeDb({ value: 'true' }); - const s = new MapsService(db); - s.autocompleteDisabled(); - expect(get).toHaveBeenCalledWith(expect.stringContaining('app_settings'), 'places_autocomplete_enabled'); - s.detailsDisabled(); - expect(get).toHaveBeenCalledWith(expect.any(String), 'places_details_enabled'); - s.photosDisabled(); - expect(get).toHaveBeenCalledWith(expect.any(String), 'places_photos_enabled'); - }); +describe('parseOpeningHours', () => { + it('MAPS-001: returns 7 weekday descriptions and openNow', () => { + const result = parseOpeningHours('Mo-Fr 09:00-18:00'); + expect(result.weekdayDescriptions).toHaveLength(7); + expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00'); + expect(typeof result.openNow === 'boolean' || result.openNow === null).toBe(true); }); - describe('delegation to the legacy maps service', () => { - it('search forwards userId, query, lang and bias', () => { - maps.searchPlaces.mockResolvedValue({ places: [], source: 'osm' }); - const bias = { lat: 1, lng: 2, radius: 5 }; - svc().search(3, 'berlin', 'de', bias); - expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', bias); - }); - - it('search works without optional args', () => { - svc().search(3, 'berlin'); - expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', undefined, undefined); - }); - - it('autocomplete forwards through', () => { - const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; - svc().autocomplete(3, 'be', 'en', bias); - expect(maps.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', bias); - }); - - it('details forwards through', () => { - svc().details(3, 'p1', 'de'); - expect(maps.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de'); - }); - - it('detailsExpanded forwards refresh through', () => { - svc().detailsExpanded(3, 'p1', 'de', true); - expect(maps.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true); - }); - - it('photo forwards coords and name through', () => { - svc().photo(3, 'p1', 1.5, 2.5, 'Spot'); - expect(maps.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot'); - }); - - it('reverse forwards through', () => { - svc().reverse('1', '2', 'de'); - expect(maps.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de'); - }); - - it('resolveUrl forwards through', () => { - svc().resolveUrl('https://maps.app.goo.gl/x'); - expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x'); - }); - - it('pois forwards category, bbox and lang through', () => { - const bbox = { south: 1, west: 2, north: 3, east: 4 }; - svc().pois('cafe', bbox, 'de'); - expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox, 'de'); - }); + it('MAPS-002: marks unknown days with ?', () => { + const result = parseOpeningHours('Mo 10:00-12:00'); + expect(result.weekdayDescriptions[1]).toContain('?'); }); - describe('photoBytesPath', () => { - it('returns the cached file path from placePhotoCache', () => { - serveFilePath.mockReturnValue('/cache/p1.jpg'); - expect(svc().photoBytesPath('p1')).toBe('/cache/p1.jpg'); - expect(serveFilePath).toHaveBeenCalledWith('p1'); - }); + it('MAPS-003: handles multiple segments separated by semicolons', () => { + const result = parseOpeningHours('Mo-Fr 09:00-18:00; Sa 10:00-14:00'); + expect(result.weekdayDescriptions[5]).toContain('Saturday: 10:00-14:00'); + expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00'); + }); - it('returns null when nothing is cached', () => { - serveFilePath.mockReturnValue(null); - expect(svc().photoBytesPath('p1')).toBeNull(); - }); + it('MAPS-004: handles 24/7 string gracefully (no crash)', () => { + const result = parseOpeningHours('24/7'); + expect(result.weekdayDescriptions).toHaveLength(7); + }); + + it('MAPS-005: returns openNow null for unparseable format', () => { + const result = parseOpeningHours('invalid-hours-string'); + expect(result.openNow).toBeNull(); + }); + + it('MAPS-006: handles comma-separated days', () => { + const result = parseOpeningHours('Mo,We,Fr 08:00-17:00'); + expect(result.weekdayDescriptions[0]).toContain('Monday: 08:00-17:00'); + expect(result.weekdayDescriptions[2]).toContain('Wednesday: 08:00-17:00'); + expect(result.weekdayDescriptions[4]).toContain('Friday: 08:00-17:00'); + expect(result.weekdayDescriptions[1]).toContain('?'); + }); + + it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms', () => { + const adversarial = 'Mo' + ',Mo'.repeat(500) + ' closed'; + const start = Date.now(); + parseOpeningHours(adversarial); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(100); + }); + + it('MAPS-007b: emits machine-readable periods in Google day numbering (Sunday = 0)', () => { + const result = parseOpeningHours('Mo 09:00-18:00; Su 10:00-14:00'); + expect(result.periods).toEqual([ + { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, + { open: { day: 0, hour: 10, minute: 0 }, close: { day: 0, hour: 14, minute: 0 } }, + ]); + }); + + it('MAPS-007c: a period past midnight closes on the following day', () => { + const result = parseOpeningHours('Sa 20:00-02:00'); + expect(result.periods).toEqual([ + { open: { day: 6, hour: 20, minute: 0 }, close: { day: 0, hour: 2, minute: 0 } }, + ]); + }); + + it('MAPS-007d: the OSM 24:00 spelling becomes midnight of the next day', () => { + // Google's clock has no hour 24, so "00:00-24:00" is a full day, not a rejected range. + const result = parseOpeningHours('Mo 00:00-24:00'); + expect(result.periods).toEqual([ + { open: { day: 1, hour: 0, minute: 0 }, close: { day: 2, hour: 0, minute: 0 } }, + ]); + }); + + it('MAPS-007e: unusable clock values produce no period', () => { + expect(parseOpeningHours('Mo 09:75-18:00').periods).toEqual([]); + expect(parseOpeningHours('Mo 09:00-24:30').periods).toEqual([]); + expect(parseOpeningHours('Mo closed').periods).toEqual([]); + }); +}); + +// ── normalizeOpeningPeriods / normalizeSpecialDays ──────────────────────────── + +describe('normalizeOpeningPeriods', () => { + it('MAPS-007f: keeps well-formed periods and fills the zeroes proto3 JSON omits', () => { + expect( + normalizeOpeningPeriods([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17 } }]), + ).toEqual([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17, minute: 0 } }]); + // Sunday midnight arrives as an empty object. + expect(normalizeOpeningPeriods([{ open: {} }])).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]); + }); + + it('MAPS-007g: a period without a close survives as the round-the-clock marker', () => { + expect(normalizeOpeningPeriods([{ open: { day: 0, hour: 0, minute: 0 } }])).toEqual([ + { open: { day: 0, hour: 0, minute: 0 }, close: null }, + ]); + }); + + it('MAPS-007h: drops out-of-range points and periods with an unusable close', () => { + expect(normalizeOpeningPeriods([{ open: { day: 7, hour: 9, minute: 0 } }])).toBeNull(); + expect(normalizeOpeningPeriods([{ open: { day: 1, hour: 24, minute: 0 } }])).toBeNull(); + // A broken close must not be mistaken for "never closes". + expect( + normalizeOpeningPeriods([{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 9, minute: 90 } }]), + ).toBeNull(); + }); + + it('MAPS-007i: returns null when there is nothing to normalise', () => { + expect(normalizeOpeningPeriods(undefined)).toBeNull(); + expect(normalizeOpeningPeriods([])).toBeNull(); + }); +}); + +describe('normalizeSpecialDays', () => { + it('MAPS-007j: turns Google date parts into ISO dates without duplicates', () => { + expect( + normalizeSpecialDays([ + { date: { year: 2026, month: 12, day: 25 } }, + { date: { year: 2026, month: 1, day: 1 } }, + { date: { year: 2026, month: 12, day: 25 } }, + ]), + ).toEqual(['2026-12-25', '2026-01-01']); + }); + + it('MAPS-007k: skips incomplete or impossible dates and returns null when none are left', () => { + expect(normalizeSpecialDays([{}, { date: { year: 2026, month: 13, day: 1 } }, { date: { year: 2026, day: 5 } }])).toBeNull(); + expect(normalizeSpecialDays(undefined)).toBeNull(); + }); +}); + +// ── buildOsmDetails ─────────────────────────────────────────────────────────── + +describe('buildOsmDetails', () => { + it('MAPS-008: returns website from tags', () => { + const result = buildOsmDetails({ website: 'https://example.com' }, 'way', '123'); + expect(result.website).toBe('https://example.com'); + }); + + it('MAPS-009: prefers contact:website over website', () => { + const result = buildOsmDetails( + { 'contact:website': 'https://contact.example.com', website: 'https://other.com' }, + 'node', + '1', + ); + expect(result.website).toBe('https://contact.example.com'); + }); + + it('MAPS-010: returns null website when no tag', () => { + const result = buildOsmDetails({}, 'node', '1'); + expect(result.website).toBeNull(); + }); + + it('MAPS-011: builds correct osm_url', () => { + const result = buildOsmDetails({}, 'way', '99999'); + expect(result.osm_url).toBe('https://www.openstreetmap.org/way/99999'); + }); + + it('MAPS-012: includes parsed opening_hours when valid', () => { + const result = buildOsmDetails({ opening_hours: 'Mo-Fr 09:00-18:00' }, 'node', '1'); + expect(result.opening_hours).not.toBeNull(); + expect(Array.isArray(result.opening_hours)).toBe(true); + }); + + it('MAPS-013: opening_hours is null when tag is missing', () => { + const result = buildOsmDetails({}, 'node', '1'); + expect(result.opening_hours).toBeNull(); + expect(result.open_now).toBeNull(); + }); + + it('MAPS-014: source is always openstreetmap', () => { + expect(buildOsmDetails({}, 'node', '1').source).toBe('openstreetmap'); + }); + + it('MAPS-012b: carries the structured periods next to the weekday lines', () => { + const result = buildOsmDetails({ opening_hours: 'Mo-Tu 09:00-18:00' }, 'node', '1'); + expect(result.opening_periods).toEqual([ + { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, + { open: { day: 2, hour: 9, minute: 0 }, close: { day: 2, hour: 18, minute: 0 } }, + ]); + }); + + it('MAPS-012c: opening_periods is null when the day lines carry no usable times', () => { + expect(buildOsmDetails({}, 'node', '1').opening_periods).toBeNull(); + expect(buildOsmDetails({ opening_hours: 'Mo closed' }, 'node', '1').opening_periods).toBeNull(); + }); + + it('MAPS-014b: opening_hours is null when all days have unknown times (all "?")', () => { + // "closed" does not match the day+time pattern so all days remain "?" + const result = buildOsmDetails({ opening_hours: 'closed' }, 'node', '1'); + expect(result.opening_hours).toBeNull(); + expect(result.open_now).toBeNull(); + }); +}); + +// ── getMapsKey ──────────────────────────────────────────────────────────────── + +describe('getMapsKey', () => { + it('MAPS-015: returns user key when user has one', () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' }); + expect(svc.getMapsKey(1)).toBe('user-api-key'); + }); + + it('MAPS-016: falls back to admin key when user has none', () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: null }); + mockDbGet.mockReturnValueOnce({ maps_api_key: 'admin-api-key' }); + expect(svc.getMapsKey(1)).toBe('admin-api-key'); + }); + + it('MAPS-017: returns null when neither user nor admin has a key', () => { + mockDbGet.mockReturnValue(undefined); + expect(svc.getMapsKey(1)).toBeNull(); + }); +}); + +// ── reverseGeocode ──────────────────────────────────────────────────────────── + +describe('reverseGeocode (fetch stubbed)', () => { + it('MAPS-018: returns name and address from nominatim response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: 'Eiffel Tower', + display_name: 'Eiffel Tower, Paris, France', + address: {}, + }), + }), + ); + const result = await svc.reverseGeocode('48.8584', '2.2945'); + expect(result.name).toBe('Eiffel Tower'); + expect(result.address).toBe('Eiffel Tower, Paris, France'); + }); + + it('MAPS-019: returns nulls when fetch fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + const result = await svc.reverseGeocode('0', '0'); + expect(result.name).toBeNull(); + expect(result.address).toBeNull(); + }); + + it('MAPS-019b: falls back to address.tourism when name is absent', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + display_name: 'Some Museum, Paris', + address: { tourism: 'Some Museum' }, + }), + }), + ); + const result = await svc.reverseGeocode('48.85', '2.35'); + expect(result.name).toBe('Some Museum'); + }); + + it('MAPS-019c: falls back to address.amenity when name and tourism are absent', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + display_name: 'A Cafe, Paris', + address: { amenity: 'A Cafe' }, + }), + }), + ); + const result = await svc.reverseGeocode('48.85', '2.35'); + expect(result.name).toBe('A Cafe'); + }); + + it('MAPS-019d: falls back to address.road when no higher-priority field exists', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + display_name: 'Rue de Rivoli, Paris', + address: { road: 'Rue de Rivoli' }, + }), + }), + ); + const result = await svc.reverseGeocode('48.85', '2.35'); + expect(result.name).toBe('Rue de Rivoli'); + }); + + it('MAPS-019e: returns null name when address has no recognized fields', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + display_name: 'Somewhere', + address: {}, + }), + }), + ); + const result = await svc.reverseGeocode('0', '0'); + expect(result.name).toBeNull(); + expect(result.address).toBe('Somewhere'); + }); +}); + +// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction +const nominatimStub = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ display_name: 'Paris, France', name: null, address: {} }), +}); + +// ── resolveGoogleMapsUrl coordinate extraction ──────────────────────────────── + +describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => { + it('MAPS-020: extracts lat/lng from @lat,lng pattern', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8566,2.3522,15z'); + expect(result.lat).toBeCloseTo(48.8566, 3); + expect(result.lng).toBeCloseTo(2.3522, 3); + }); + + it('MAPS-021: extracts lat/lng from !3d!4d data pattern', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl( + 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', + ); + expect(result.lat).toBeCloseTo(48.8584, 3); + expect(result.lng).toBeCloseTo(2.2945, 3); + }); + + it('MAPS-022: extracts lat/lng from ?q=lat,lng pattern', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8566,2.3522'); + expect(result.lat).toBeCloseTo(48.8566, 3); + expect(result.lng).toBeCloseTo(2.3522, 3); + }); + + it('MAPS-023: extracts place name from /place/ path', async () => { + vi.stubGlobal('fetch', nominatimStub); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z'); + expect(result.name).toBe('Eiffel Tower'); + }); + + it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => { + // cid URLs (what get_place_details returns, and Google "Share" links) carry no + // inline coords; the redirect target carries the !3d!4d data param. + const fetchMock = vi.fn(async (u: string) => { + if (u.includes('nominatim')) { + return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) }; + } + return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' }; + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890'); + expect(result.lat).toBeCloseTo(48.8584, 3); + expect(result.lng).toBeCloseTo(2.2945, 3); + }); + + it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => { + const fetchMock = vi.fn(async (u: string) => { + if (u.includes('nominatim')) { + return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) }; + } + if (u.includes('cid=')) { + // Redirect target has no inline coords. + return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' }; + } + // Body fetch of the resolved URL embeds coords in the map data. + return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' }; + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999'); + expect(result.lat).toBeCloseTo(40.6892, 3); + expect(result.lng).toBeCloseTo(-74.0445, 3); + }); + + it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => { + const adversarial = '/@' + '1'.repeat(10000) + '.'; + const start = Date.now(); + adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); + expect(Date.now() - start).toBeLessThan(500); + }); + + it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => { + const adversarial = '!3d' + '1'.repeat(10000) + '.'; + const start = Date.now(); + adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); + expect(Date.now() - start).toBeLessThan(500); + }); + + it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => { + const adversarial = '?q=' + '1'.repeat(10000) + '.'; + const start = Date.now(); + adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); + expect(Date.now() - start).toBeLessThan(500); + }); + + it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => { + const adversarial = '<' + 'a'.repeat(10000); + const start = Date.now(); + adversarial.replace(/<[^>]+>/g, ''); + expect(Date.now() - start).toBeLessThan(100); + }); + + it('MAPS-028: throws when no coordinates found in URL', async () => { + vi.stubGlobal('fetch', nominatimStub); + await expect(svc.resolveGoogleMapsUrl('https://www.google.com/maps')).rejects.toThrow(); + }); + + it('MAPS-028b: throws 403 when short URL is blocked by SSRF check', async () => { + mockCheckSsrf.mockResolvedValueOnce({ allowed: false }); + await expect(svc.resolveGoogleMapsUrl('https://goo.gl/maps/abc123')).rejects.toMatchObject({ status: 403 }); + }); + + it('MAPS-028c: follows redirect for short goo.gl URL and extracts coordinates', async () => { + const redirectFetch = vi + .fn() + // First call: the redirect (goo.gl), returns resolved URL in .url + .mockResolvedValueOnce({ + url: 'https://www.google.com/maps/@48.8566,2.3522,15z', + }) + // Second call: the Nominatim reverse geocode + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ display_name: 'Paris, France', name: 'Paris', address: {} }), + }); + vi.stubGlobal('fetch', redirectFetch); + const result = await svc.resolveGoogleMapsUrl('https://goo.gl/maps/abc123'); + expect(result.lat).toBeCloseTo(48.8566, 3); + expect(result.lng).toBeCloseTo(2.3522, 3); + }); + + it('MAPS-028d: falls back to nominatim address fields when no placeName in URL', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + display_name: 'Louvre Museum, Paris', + name: null, + address: { tourism: 'Louvre Museum' }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + // URL with coordinates but no /place/ path segment + const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z'); + expect(result.name).toBe('Louvre Museum'); + }); +}); + +// ── searchNominatim (fetch-dependent) ──────────────────────────────────────── + +describe('searchNominatim (fetch stubbed)', () => { + it('MAPS-029: returns mapped nominatim results on success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'way', osm_id: '1', lat: '48.8', lon: '2.3', name: 'Paris', display_name: 'Paris, France' }, + ], + }), + ); + const results = await svc.searchNominatim('Paris'); + expect(results).toHaveLength(1); + expect((results[0] as any).address).toBe('Paris, France'); + expect((results[0] as any).source).toBe('openstreetmap'); + }); + + 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(); + }); + + it('MAPS-030b: throws when nominatim response is not ok', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => '', + }), + ); + await expect(svc.searchNominatim('fail')).rejects.toThrow('Nominatim API error'); + }); + + it('MAPS-030c: falls back to display_name split when name is absent', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' }], + }), + ); + const results = await svc.searchNominatim('London'); + expect((results[0] as any).name).toBe('London'); + }); +}); + +// ── fetchOverpassDetails (fetch stubbed) ───────────────────────────────────── + +describe('fetchOverpassDetails (fetch stubbed)', () => { + it('MAPS-031: returns element tags on success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }), + }), + ); + const result = await svc.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'); + expect(result).toBeNull(); + }); + + 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'); + 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'); + expect(result).toBeNull(); + }); + + it('MAPS-034b: returns null when elements array is empty', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [] }), + }), + ); + const result = await svc.fetchOverpassDetails('node', '1'); + expect(result).toBeNull(); + }); +}); + +// ── searchOverpassPois localized names (#1655) ─────────────────────────────── + +describe('searchOverpassPois localized names (#1655)', () => { + // Elephant and Obelisk in Rome: OSM ships the native name plus localized tags. + const tags = { + name: 'Obelisco della Minerva', + 'name:en': 'Elephant and Obelisk', + 'name:de': 'Minerva-Obelisk', + int_name: 'Elephant Obelisk', + tourism: 'attraction', + }; + const stubOverpass = (elementTags: Record) => + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: elementTags }] }), + }), + ); + // Distinct bboxes per case so the module-level POI_CACHE (keyed by lang+bbox) + // never serves one case's names to another. + const bbox = (n: number) => ({ south: 41 + n / 100, west: 12, north: 42 + n / 100, east: 13 }); + + it('prefers name: for the user language over the native name', async () => { + stubOverpass(tags); + const { pois } = await svc.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'); + 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'); + expect(pois[0].name).toBe('Elephant Obelisk'); + }); + + 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'); + expect(pois[0].name).toBe('Obelisco della Minerva'); + }); +}); + +// ── fetchWikimediaPhoto (fetch stubbed) ─────────────────────────────────────── + +describe('fetchWikimediaPhoto (fetch stubbed)', () => { + it('MAPS-035: returns photo from Wikipedia article image (strategy 1)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://example.com/thumb.jpg' } } } }, + }), + }), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3, 'Eiffel Tower'); + expect(result).toBeDefined(); + expect(result!.photoUrl).toBe('https://example.com/thumb.jpg'); + expect(result!.attribution).toBe('Wikipedia'); + }); + + it('MAPS-036: falls through to geosearch when Wikipedia has no thumbnail', async () => { + const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) }; + const commonsResponse = { + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [ + { url: 'https://commons.org/img.jpg', mime: 'image/jpeg', extmetadata: { Artist: { value: 'Alice' } } }, + ], + }, + }, + }, + }), + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse)); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); + expect(result).toBeDefined(); + expect(result!.photoUrl).toBe('https://commons.org/img.jpg'); + expect(result!.attribution).toBe('Alice'); + }); + + it('MAPS-036b: geosearch prefers the scaled thumburl over the full-res original', async () => { + const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) }; + const commonsResponse = { + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [ + { + url: 'https://commons.org/original-16mb.jpg', + thumburl: 'https://commons.org/thumb-400.jpg', + mime: 'image/jpeg', + extmetadata: { Artist: { value: 'Alice' } }, + }, + ], + }, + }, + }, + }), + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse)); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); + expect(result).toBeDefined(); + expect(result!.photoUrl).toBe('https://commons.org/thumb-400.jpg'); + expect(result!.attribution).toBe('Alice'); + }); + + it('MAPS-037: returns null when both strategies find nothing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: { pages: {} } }), + }), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result).toBeNull(); + }); + + it('MAPS-037b: skips strategy 1 entirely when name is undefined', async () => { + // Only one fetch call is made (the Commons geosearch), not two + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: { pages: {} } }), + }); + vi.stubGlobal('fetch', fetchMock); + await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('MAPS-037c: falls through to geosearch when Wikipedia fetch throws', async () => { + const commonsResponse = { + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [{ url: 'https://commons.org/fallback.jpg', mime: 'image/png', extmetadata: {} }], + }, + }, + }, + }), + }; + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValueOnce(new Error('Wikipedia network error')).mockResolvedValueOnce(commonsResponse), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); + expect(result).toBeDefined(); + expect(result!.photoUrl).toBe('https://commons.org/fallback.jpg'); + // no Artist in extmetadata -> attribution null + expect(result!.attribution).toBeNull(); + }); + + it('MAPS-037d: falls through to geosearch when Wikipedia response is not ok', async () => { + const wikiNotOk = { ok: false }; + const commonsResponse = { + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [ + { + url: 'https://commons.org/photo.jpg', + mime: 'image/jpeg', + extmetadata: { Artist: { value: 'Bob' } }, + }, + ], + }, + }, + }, + }), + }; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiNotOk).mockResolvedValueOnce(commonsResponse)); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); + expect(result).toBeDefined(); + // HTML tags stripped from attribution + expect(result!.attribution).toBe('Bob'); + }); + + it('MAPS-037e: returns null when Commons geosearch returns not ok', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result).toBeNull(); + }); + + it('MAPS-037f: returns null when Commons geosearch returns no query.pages', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: {} }), + }), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result).toBeNull(); + }); + + it('MAPS-037g: returns null when Commons fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Commons network error'))); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result).toBeNull(); + }); + + it('MAPS-037h: skips Commons page entries with non-photo MIME type (SVG)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [{ url: 'https://commons.org/diagram.svg', mime: 'image/svg+xml' }], + }, + }, + }, + }), + }), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result).toBeNull(); + }); + + it('MAPS-037i: accepts PNG mime type as valid photo', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [ + { + url: 'https://commons.org/photo.png', + mime: 'image/png', + extmetadata: { Artist: { value: 'Carol' } }, + }, + ], + }, + }, + }, + }), + }), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result!.photoUrl).toBe('https://commons.org/photo.png'); + expect(result!.attribution).toBe('Carol'); + }); + + it('MAPS-037j: returns null attribution when Artist extmetadata is absent', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { + pages: { + '1': { + imageinfo: [{ url: 'https://commons.org/noattr.jpg', mime: 'image/jpeg', extmetadata: {} }], + }, + }, + }, + }), + }), + ); + const result = await svc.fetchWikimediaPhoto(48.8, 2.3); + expect(result!.attribution).toBeNull(); + }); +}); + +// ── searchPlaces (fetch stubbed) ───────────────────────────────────────────── + +describe('searchPlaces (fetch stubbed)', () => { + it('MAPS-038: uses Nominatim when user has no API key', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' }, + ], + }), + ); + const result = await svc.searchPlaces(999, 'Paris'); + expect(result.source).toBe('openstreetmap'); + expect(Array.isArray(result.places)).toBe(true); + }); + + it('MAPS-039: uses Google when user has an API key', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + places: [ + { + id: 'gid1', + displayName: { text: 'Eiffel Tower' }, + formattedAddress: 'Paris', + location: { latitude: 48.8, longitude: 2.3 }, + // Real search API returns a cid-style URL with no ftid → google_ftid stays null. + googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', + }, + ], + }), + }), + ); + const result = await svc.searchPlaces(1, 'Eiffel Tower'); + expect(result.source).toBe('google'); + expect((result.places[0] as any).google_place_id).toBe('gid1'); + expect((result.places[0] as any).google_ftid).toBeNull(); + }); + + it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: { message: 'API key invalid' } }), + }), + ); + await expect(svc.searchPlaces(1, 'anything')).rejects.toMatchObject({ + message: 'API key invalid', + status: 403, + }); + }); + + it('MAPS-039c: throws with generic message when Google error has no message', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: {} }), + }), + ); + await expect(svc.searchPlaces(1, 'anything')).rejects.toMatchObject({ + message: 'Google Places API error', + status: 500, + }); + }); + + it('MAPS-039d: returns empty places array when Google returns no results', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ places: [] }), + }), + ); + const result = await svc.searchPlaces(1, 'very obscure place'); + expect(result.source).toBe('google'); + expect(result.places).toHaveLength(0); + }); + + it('MAPS-039e: handles Google result with optional fields absent', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + // id only, no displayName, formattedAddress, location, etc. + places: [{ id: 'gid-sparse' }], + }), + }), + ); + const result = await svc.searchPlaces(1, 'sparse'); + const place = result.places[0] as any; + expect(place.google_place_id).toBe('gid-sparse'); + expect(place.google_ftid).toBeNull(); + expect(place.name).toBe(''); + expect(place.address).toBe(''); + expect(place.lat).toBeNull(); + expect(place.lng).toBeNull(); + expect(place.rating).toBeNull(); + expect(place.website).toBeNull(); + expect(place.phone).toBeNull(); + }); +}); + +// ── autocompletePlaces (fetch stubbed) ────────────────────────────────────── + +describe('autocompletePlaces (fetch stubbed)', () => { + it('MAPS-081: uses Nominatim when user has no API key', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { + osm_type: 'node', + osm_id: '1', + lat: '48.8', + lon: '2.3', + display_name: 'Paris, Île-de-France, France', + name: 'Paris', + }, + ], + }), + ); + const result = await svc.autocompletePlaces(999, 'Paris'); + expect(result.source).toBe('nominatim'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].mainText).toBe('Paris'); + expect(result.suggestions[0].placeId).toBe('node:1'); + }); + + it('MAPS-082: uses Google when user has an API key', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { + placePrediction: { + placeId: 'ChIJ1234', + structuredFormat: { + mainText: { text: 'Eiffel Tower' }, + secondaryText: { text: 'Paris, France' }, + }, + }, + }, + ], + }), + }), + ); + const result = await svc.autocompletePlaces(1, 'Eiffel'); + expect(result.source).toBe('google'); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].placeId).toBe('ChIJ1234'); + expect(result.suggestions[0].mainText).toBe('Eiffel Tower'); + expect(result.suggestions[0].secondaryText).toBe('Paris, France'); + }); + + it('MAPS-083: throws with Google error status when API returns non-ok', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: { message: 'API key invalid' } }), + }), + ); + await expect(svc.autocompletePlaces(1, 'anything')).rejects.toMatchObject({ + message: 'API key invalid', + status: 403, + }); + }); + + it('MAPS-084: throws generic message when Google error has no message', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: {} }), + }), + ); + await expect(svc.autocompletePlaces(1, 'anything')).rejects.toMatchObject({ + message: 'Google Places Autocomplete error', + status: 500, + }); + }); + + it('MAPS-085: returns empty suggestions when Google returns no results', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }), + ); + const result = await svc.autocompletePlaces(1, 'very obscure place'); + expect(result.source).toBe('google'); + expect(result.suggestions).toHaveLength(0); + }); + + it('MAPS-086: filters out suggestions without placePrediction', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [ + { placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } }, + { queryPrediction: { text: 'some query' } }, + { placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } }, + ], + }), + }), + ); + const result = await svc.autocompletePlaces(1, 'test'); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0].placeId).toBe('A'); + expect(result.suggestions[1].placeId).toBe('B'); + }); + + it('MAPS-087: limits results to 5 suggestions', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + const manySuggestions = Array.from({ length: 10 }, (_, i) => ({ + placePrediction: { + placeId: `id-${i}`, + structuredFormat: { mainText: { text: `Place ${i}` } }, + }, + })); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: manySuggestions }), + }), + ); + const result = await svc.autocompletePlaces(1, 'test'); + expect(result.suggestions).toHaveLength(5); + }); + + it('MAPS-088: includes locationBias in Google request when provided', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + await svc.autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.locationBias).toEqual({ + rectangle: { + low: { latitude: 48.5, longitude: 2.0 }, + high: { latitude: 49.0, longitude: 2.8 }, + }, + }); + }); + + it('MAPS-089: omits locationBias from Google request when not provided', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ suggestions: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + await svc.autocompletePlaces(1, 'test', 'en'); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.locationBias).toBeUndefined(); + }); + + it('MAPS-090: handles missing structuredFormat fields gracefully', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + suggestions: [{ placePrediction: { placeId: 'sparse-id' } }], + }), + }), + ); + const result = await svc.autocompletePlaces(1, 'sparse'); + expect(result.suggestions[0].placeId).toBe('sparse-id'); + expect(result.suggestions[0].mainText).toBe(''); + expect(result.suggestions[0].secondaryText).toBe(''); + }); + + it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + const result = await svc.autocompletePlaces(999, 'fail'); + expect(result.source).toBe('nominatim'); + expect(result.suggestions).toHaveLength(0); + }); + + it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { + osm_type: 'way', + osm_id: '42', + lat: '51.5', + lon: '-0.1', + display_name: 'Big Ben, Westminster, London, UK', + name: 'Big Ben', + }, + ], + }), + ); + const result = await svc.autocompletePlaces(999, 'Big Ben'); + expect(result.suggestions[0].mainText).toBe('Big Ben'); + expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK'); + }); + + it('MAPS-093: Nominatim fallback filters out results with empty osm_id', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' }, + { osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' }, + { osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' }, + ], + }), + ); + const result = await svc.autocompletePlaces(999, 'test'); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']); + }); +}); + +// ── getPlaceDetails (fetch stubbed) ───────────────────────────────────────── + +describe('getPlaceDetails (fetch stubbed)', () => { + it('MAPS-040: handles OSM placeId (way:id) via Overpass', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [{ tags: { website: 'https://eiffel.com' } }] }), + }), + ); + const result = await svc.getPlaceDetails(1, 'way:12345'); + expect(result.place).toBeDefined(); + expect((result.place as any).source).toBe('openstreetmap'); + expect((result.place as any).website).toBe('https://eiffel.com'); + }); + + it('MAPS-040b: handles OSM placeId when Overpass returns no tags (element missing)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [] }), + }), + ); + const result = await svc.getPlaceDetails(1, 'node:99999'); + expect((result.place as any).source).toBe('openstreetmap'); + expect((result.place as any).website).toBeNull(); + }); + + it('MAPS-041: throws 400 when Google placeId given but no API key', async () => { + await expect(svc.getPlaceDetails(999, 'ChIJNotAnOsmId')).rejects.toMatchObject({ status: 400 }); + }); + + it('MAPS-041b: returns full Google place details on happy path', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJ123', + displayName: { text: 'Eiffel Tower' }, + formattedAddress: 'Champ de Mars, 5 Av. Anatole France, 75007 Paris', + location: { latitude: 48.8584, longitude: 2.2945 }, + rating: 4.7, + userRatingCount: 200000, + websiteUri: 'https://www.toureiffel.paris', + nationalPhoneNumber: '+33 892 70 12 39', + regularOpeningHours: { + weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'], + openNow: true, + }, + // The Places API returns a cid-style URL with no ftid, so google_ftid stays null + // and the precise query_place_id link is used on the client instead. + googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', + editorialSummary: { text: 'Iconic iron tower.' }, + reviews: [ + { + authorAttribution: { displayName: 'John', photoUri: 'https://photo.url' }, + rating: 5, + text: { text: 'Amazing!' }, + relativePublishTimeDescription: '2 weeks ago', + }, + ], + photos: [{ name: 'places/ChIJ123/photos/photo1', authorAttributions: [{ displayName: 'Jane' }] }], + }), + }), + ); + const result = await svc.getPlaceDetails(1, 'ChIJ123'); + const place = result.place as any; + expect(place.google_place_id).toBe('ChIJ123'); + expect(place.google_ftid).toBeNull(); + expect(place.name).toBe('Eiffel Tower'); + expect(place.rating).toBe(4.7); + expect(place.rating_count).toBe(200000); + expect(place.open_now).toBe(true); + expect(place.source).toBe('google'); + // Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those + expect(place.reviews).toHaveLength(0); + expect(place.summary).toBeNull(); + }); + + it('MAPS-041b2: normalises non-standard TREK language codes for Google (br→pt-BR, gr→el)', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' }, location: { latitude: 0, longitude: 0 } }), + }); + mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); + vi.stubGlobal('fetch', fetchMock); + + await svc.getPlaceDetails(1, 'ChIJ-br', 'br'); + expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=pt-BR'); + + await svc.getPlaceDetails(1, 'ChIJ-gr', 'gr'); + expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=el'); + + // A code that is already valid passes through unchanged. + await svc.getPlaceDetails(1, 'ChIJ-de', 'de'); + expect(String(fetchMock.mock.calls[2][0])).toContain('languageCode=de'); + }); + + it('MAPS-041c: throws with status when Google API returns non-ok response', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ error: { message: 'Place not found' } }), + }), + ); + await expect(svc.getPlaceDetails(1, 'ChIJMissing')).rejects.toMatchObject({ + message: 'Place not found', + status: 404, + }); + }); + + it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss → return undefined + mockDbGet.mockReturnValueOnce(undefined); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJ456', + reviews: [ + // All optional fields absent + {}, + ], + }), + }), + ); + const result = await svc.getPlaceDetailsExpanded(1, 'ChIJ456'); + const review = (result.place as any).reviews[0]; + expect(review.author).toBeNull(); + expect(review.rating).toBeNull(); + expect(review.text).toBeNull(); + expect(review.time).toBeNull(); + expect(review.photo).toBeNull(); + }); + + it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => { + const fetchMock = vi + .fn() + // First call: Overpass (returns element with tags but no coords) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }), + }) + // Second call: Nominatim /lookup + .mockResolvedValueOnce({ + ok: true, + json: async () => [ + { + osm_type: 'way', + osm_id: '5', + lat: '48.85', + lon: '2.29', + display_name: 'Eiffel Tower, Paris, France', + name: 'Eiffel Tower', + }, + ], + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.getPlaceDetails(1, 'way:5'); + const place = result.place as any; + expect(place.name).toBe('Eiffel Tower'); + expect(place.address).toBe('Eiffel Tower, Paris, France'); + expect(place.lat).toBeCloseTo(48.85); + expect(place.lng).toBeCloseTo(2.29); + expect(place.source).toBe('openstreetmap'); + // Overpass first, then Nominatim — two total fetch calls + expect(fetchMock).toHaveBeenCalledTimes(2); + const overpassUrl = fetchMock.mock.calls[0][0] as string; + const nominatimUrl = fetchMock.mock.calls[1][0] as string; + expect(overpassUrl).toContain('overpass'); + expect(nominatimUrl).toContain('nominatim'); + }); + + it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJ789', + regularOpeningHours: { + weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], + // openNow intentionally absent + }, + }), + }), + ); + const result = await svc.getPlaceDetails(1, 'ChIJ789'); + expect((result.place as any).open_now).toBeNull(); + }); + + it('MAPS-041f: open_now is false when regularOpeningHours.openNow is false', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJClosed', + regularOpeningHours: { + weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], + openNow: false, + }, + }), + }), + ); + const result = await svc.getPlaceDetails(1, 'ChIJClosed'); + // false is preserved (not coerced to null) via the ?? null operator + expect((result.place as any).open_now).toBe(false); + }); + + it('MAPS-041f2: hands the structured opening periods and special days to the client', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJPeriods', + regularOpeningHours: { + // Localised display text — the client shows it but must not parse it. + weekdayDescriptions: ['月曜日: 9:00~18:00'], + openNow: true, + periods: [{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }], + specialDays: [{ date: { year: 2026, month: 12, day: 25 } }], + }, + }), + }), + ); + const place = (await svc.getPlaceDetails(1, 'ChIJPeriods')).place as any; + expect(place.opening_periods).toEqual([ + { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, + ]); + expect(place.opening_special_days).toEqual(['2026-12-25']); + expect(place.opening_hours).toEqual(['月曜日: 9:00~18:00']); + }); + + it('MAPS-041f3: opening_periods is null when Google sends no periods', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJNoPeriods', + regularOpeningHours: { weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], openNow: false }, + }), + }), + ); + const place = (await svc.getPlaceDetails(1, 'ChIJNoPeriods')).place as any; + expect(place.opening_periods).toBeNull(); + expect(place.opening_special_days).toBeNull(); + }); + + it('MAPS-041f4: getPlaceDetailsExpanded carries the periods too', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss + mockDbGet.mockReturnValueOnce(undefined); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJExpandedPeriods', + // A place that never closes: one period, no close point. + regularOpeningHours: { openNow: true, periods: [{ open: { day: 0, hour: 0, minute: 0 } }] }, + }), + }), + ); + const place = (await svc.getPlaceDetailsExpanded(1, 'ChIJExpandedPeriods')).place as any; + expect(place.opening_periods).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]); + }); + + it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss + mockDbGet.mockReturnValueOnce(undefined); + const manyReviews = Array.from({ length: 8 }, (_, i) => ({ + authorAttribution: { displayName: `User${i}` }, + rating: 4, + text: { text: 'Good' }, + relativePublishTimeDescription: '1 day ago', + })); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: 'ChIJMany', reviews: manyReviews }), + }), + ); + const result = await svc.getPlaceDetailsExpanded(1, 'ChIJMany'); + expect((result.place as any).reviews).toHaveLength(5); + }); + + // The client sends whatever id the place carries, and the expanded lookup used to + // forward all of them — including OSM ids — to Google, which bills the 400 + // INVALID_ARGUMENT it answers with (#1727). + it('MAPS-041h: getPlaceDetailsExpanded serves an OSM id from Overpass instead of Google', async () => { + mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [{ tags: { website: 'https://nerja.example' } }] }), + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.getPlaceDetailsExpanded(1, 'node:5255005321'); + expect((result.place as any).source).toBe('openstreetmap'); + expect((result.place as any).website).toBe('https://nerja.example'); + expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com'))) + .toBe(false); + }); + + it('MAPS-041i: getPlaceDetailsExpanded answers a coordinate pseudo-id with no place at all', async () => { + mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await expect(svc.getPlaceDetailsExpanded(1, 'coords:48.8,2.3')).resolves.toEqual({ place: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +// ── getPlacePhoto (fetch stubbed) ──────────────────────────────────────────── + +describe('getPlacePhoto (fetch stubbed)', () => { + it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + // First call: Wikimedia Commons API + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } }, + }), + }) + // Second call: fetch Wikimedia image bytes + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(100), + }), + ); + const placeId = 'coords:48.8,2.3'; + const result = await svc.getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`); + expect(mockCachePut).toHaveBeenCalledOnce(); + }); + + it('MAPS-043: resolves with photoUrl null when Wikimedia returns nothing and no API key', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: { pages: {} } }), + }), + ); + await expect(svc.getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + }); + + it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => { + const placeId = `coords:cache-test-${Date.now()}`; + const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`; + mockCacheGet.mockReturnValue({ + photoUrl: cachedUrl, + filePath: `/tmp/${placeId}.jpg`, + attribution: null, + }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test'); + expect(result.photoUrl).toBe(cachedUrl); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('MAPS-043c: serves the negative cache with photoUrl null and no network request', async () => { + mockCacheGetErrored.mockReturnValue(true); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const errorId = `coords:error-cache-${Date.now()}`; + await expect(svc.getPlacePhoto(999, errorId, 0, 0)).resolves.toEqual({ photoUrl: null, attribution: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('MAPS-043d: resolves with photoUrl null when lat/lng are NaN and no API key', async () => { + const nanId = `coords:nan-test-${Date.now()}`; + await expect(svc.getPlacePhoto(999, nanId, NaN, NaN)).resolves.toEqual({ photoUrl: null, attribution: null }); + }); + + it('MAPS-043e: falls through to photoUrl null when the Wikimedia fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network fail'))); + const throwId = `coords:throw-test-${Date.now()}`; + await expect(svc.getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + }); + + it('MAPS-043f: an in-flight lookup that found nothing resolves the waiter with photoUrl null', async () => { + mockCacheGetInFlight.mockReturnValue(Promise.resolve(null) as any); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await expect(svc.getPlacePhoto(999, 'ChIJInFlight', 48.8, 2.3)).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi + .fn() + // First call: get place details (with photos) + .mockResolvedValueOnce({ + ok: true, + text: async () => + JSON.stringify({ + photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }], + }), + }) + // Second call: fetch image bytes + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(200), + }); + vi.stubGlobal('fetch', fetchMock); + const uniqueId = `ChIJABC-${Date.now()}`; + const result = await svc.getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); + expect(result.attribution).toBe('Photographer'); + expect(mockCachePut).toHaveBeenCalledOnce(); + }); + + it('MAPS-044b: resolves with photoUrl null when Google details fetch returns non-ok', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: async () => JSON.stringify({ error: { message: 'Forbidden' } }), + }), + ); + const errId = `ChIJErr-${Date.now()}`; + await expect(svc.getPlacePhoto(1, errId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); + // A rejected provider call says nothing about the place — remember it as a + // failed attempt (minutes), not as "this place has no photo" (a day). + expect(mockCacheMarkError).toHaveBeenCalledWith(errId, 'provider-error'); + }); + + it('MAPS-044b2: remembers a place both providers came up empty for as a lasting miss', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi + .fn() + // Google answers, the place simply has no photos. + .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ photos: [] }) }) + // Wikimedia has no article near the coordinates either. + .mockResolvedValueOnce({ ok: true, json: async () => ({ query: { pages: {} } }) }), + ); + const emptyId = `ChIJEmpty-${Date.now()}`; + await expect(svc.getPlacePhoto(1, emptyId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); + expect(mockCacheMarkError).toHaveBeenCalledWith(emptyId, 'no-photo'); + }); + + it('MAPS-044b3: a failed Wikimedia image download counts as a failed attempt', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } } }), + }) + .mockResolvedValueOnce({ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) }), + ); + const downId = `coords:down-${Date.now()}`; + await expect(svc.getPlacePhoto(999, downId, 48.8, 2.3, 'Place')).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + expect(mockCacheMarkError).toHaveBeenCalledWith(downId, 'provider-error'); + }); + + it('MAPS-044c: resolves with photoUrl null when the Google place has no photos', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ photos: [] }), + }), + ); + const noPhotoId = `ChIJNone-${Date.now()}`; + await expect(svc.getPlacePhoto(1, noPhotoId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); + }); + + it('MAPS-044d: resolves with photoUrl null when the media endpoint returns non-ok status', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + text: async () => + JSON.stringify({ + photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }], + }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + arrayBuffer: async () => new ArrayBuffer(0), + }); + vi.stubGlobal('fetch', fetchMock); + const noUriId = `ChIJXYZ-${Date.now()}`; + await expect(svc.getPlacePhoto(1, noUriId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); + }); + + it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + text: async () => + JSON.stringify({ + photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(150), + }); + vi.stubGlobal('fetch', fetchMock); + const noAttrId = `ChIJNoAttr-${Date.now()}`; + const result = await svc.getPlacePhoto(1, noAttrId, 48.8, 2.3); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`); + expect(result.attribution).toBeNull(); + }); + + it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(120), + }), + ); + const uniqueId = `coords:44f-test-${Date.now()}`; + const result = await svc.getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); + expect(mockCachePut).toHaveBeenCalledOnce(); + }); + + it('MAPS-044g: falls back to Wikipedia/OSM for a Google place_id when the Google photo call fails', async () => { + // A key is present and the placeId is a Google id, but Google rejects the + // photo request (e.g. 403). The lookup must still return an image via the + // coordinate-based Wikipedia fallback instead of giving up with a 404 — + // matching what right-click (coords:) places already do. + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi + .fn() + // 1) Google photo details → 403 + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => JSON.stringify({ error: { message: 'PERMISSION_DENIED' } }), + }) + // 2) Wikipedia pageimages → thumbnail + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/guinness.jpg' } } } } }), + }) + // 3) image bytes + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(200), + }), + ); + const placeId = `ChIJFallback-${Date.now()}`; + const result = await svc.getPlacePhoto(1, placeId, 53.34, -6.28, 'Guinness Storehouse'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`); + expect(result.attribution).toBe('Wikipedia'); + expect(mockCachePut).toHaveBeenCalledOnce(); + }); + + // OSM ids are what the client sends whenever a place has no google_place_id. + // Google rejects them with a billable 400 INVALID_ARGUMENT, so they must go + // straight to the Wikimedia fallback like coords: ids already do. + it.each(['node:5255005321', 'way:84527326', 'relation:345407'])( + 'MAPS-044h: skips Google for the OSM id %s even when an API key is configured', + async (osmId) => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/osm.jpg' } } } } }), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(90), + }); + vi.stubGlobal('fetch', fetchMock); + const result = await svc.getPlacePhoto(1, osmId, 36.7617, -3.8448, 'Cueva de Nerja'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(osmId)}/bytes`); + const requested = fetchMock.mock.calls.map((call) => String(call[0])); + expect(requested.some((url) => url.includes('places.googleapis.com'))).toBe(false); + }, + ); + + it('MAPS-044i: an OSM id with no Wikimedia hit resolves with photoUrl null instead of a 404', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: {} } }) }); + vi.stubGlobal('fetch', fetchMock); + await expect(svc.getPlacePhoto(1, 'node:2481346642', 36.76, -3.84, 'Nerja')).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com'))) + .toBe(false); + }); +}); + +describe('isGooglePlaceId', () => { + it('MAPS-045: accepts Google place ids and rejects the ids Google cannot resolve', () => { + expect(isGooglePlaceId('ChIJLU7jZClu5kcR4PcOOO6p3I0')).toBe(true); + expect(isGooglePlaceId('coords:48.8,2.3')).toBe(false); + expect(isGooglePlaceId('node:5255005321')).toBe(false); + expect(isGooglePlaceId('way:84527326')).toBe(false); + expect(isGooglePlaceId('relation:345407')).toBe(false); + expect(isGooglePlaceId('https://lh3.googleusercontent.com/photo.jpg')).toBe(false); + // The collection views send the bare coordinate pair when a place has no ids. + expect(isGooglePlaceId('36.7617499,-3.8448432')).toBe(false); + }); +}); + +describe('googleFtidFromMapsUrl', () => { + it('MAPS-FTID-001: extracts a valid ftid from a /place/?ftid= URL (resolved share link)', () => { + expect( + googleFtidFromMapsUrl('https://www.google.com/maps/place/?q=X&ftid=0x882bf179e806d471:0x8591dde29c821a93'), + ).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + it('MAPS-FTID-002: returns null for a cid-style URL (the usual Places API shape)', () => { + expect(googleFtidFromMapsUrl('https://maps.google.com/?cid=10403719659250533155')).toBeNull(); + }); + it('MAPS-FTID-003: rejects malformed / hostile ftid values', () => { + expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=not-an-ftid')).toBeNull(); + expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=0xAB%26q%3Devil%3Cscript%3E')).toBeNull(); + expect(googleFtidFromMapsUrl('not a url')).toBeNull(); + expect(googleFtidFromMapsUrl(null)).toBeNull(); + }); +}); + +// ── buildUserAgent (instance-specific UA, #1309) ────────────────────────────── + +describe('buildUserAgent', () => { + const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)'; + + it('MAPS-094: returns the bare base UA when no instance URL is configured', () => { + expect(buildUserAgent(undefined)).toBe(base); + expect(buildUserAgent('')).toBe(base); + }); + + it('MAPS-095: appends a configured https instance URL so the deployment is identifiable', () => { + expect(buildUserAgent('https://trek.example.org')).toBe(`${base}; https://trek.example.org`); + }); + + it('MAPS-096: drops the http://localhost fallback — it is not a unique identifier', () => { + expect(buildUserAgent('http://localhost:3001')).toBe(base); + }); +}); + +// ── resolveOverpassEndpoints (OVERPASS_URL override, #1309) ──────────────────── + +describe('resolveOverpassEndpoints', () => { + it('MAPS-097: falls back to the public mirrors when OVERPASS_URL is unset/empty', () => { + expect(resolveOverpassEndpoints(undefined).length).toBeGreaterThan(1); + expect(resolveOverpassEndpoints('').length).toBeGreaterThan(1); + expect(resolveOverpassEndpoints(undefined)[0]).toContain('overpass-api.de'); + }); + + it('MAPS-098: a single custom endpoint REPLACES the public mirrors (locked-down egress)', () => { + expect(resolveOverpassEndpoints('https://overpass.internal/api/interpreter')).toEqual([ + 'https://overpass.internal/api/interpreter', + ]); + }); + + it('MAPS-099: parses a comma-separated list and trims whitespace', () => { + expect(resolveOverpassEndpoints(' https://a.test/api , http://b.test/api ')).toEqual([ + 'https://a.test/api', + 'http://b.test/api', + ]); + }); + + it('MAPS-100: drops non-http(s) / malformed entries, keeping the valid ones', () => { + expect(resolveOverpassEndpoints('https://ok.test/api, ftp://no.test, not a url')).toEqual(['https://ok.test/api']); + }); + + it('MAPS-101: falls back to the defaults when every custom entry is invalid', () => { + expect(resolveOverpassEndpoints('not a url, ftp://no.test').length).toBeGreaterThan(1); + }); +}); + +// ── resolveOverpassTimeoutMs (OVERPASS_TIMEOUT_MS override, #1309) ───────────── + +describe('resolveOverpassTimeoutMs', () => { + it('MAPS-104: falls back to the 12s default for unset / empty / non-numeric values', () => { + expect(resolveOverpassTimeoutMs(undefined)).toBe(12000); + expect(resolveOverpassTimeoutMs('')).toBe(12000); + expect(resolveOverpassTimeoutMs('abc')).toBe(12000); + }); + + it('MAPS-105: honours a positive numeric override', () => { + expect(resolveOverpassTimeoutMs('30000')).toBe(30000); + }); + + it('MAPS-106: rejects 0, negative and Infinity — a non-positive cap would 502 every search', () => { + expect(resolveOverpassTimeoutMs('0')).toBe(12000); + expect(resolveOverpassTimeoutMs('-5')).toBe(12000); + expect(resolveOverpassTimeoutMs('Infinity')).toBe(12000); + }); +}); + +// ── searchOverpassPois error path (all endpoints down, #1309) ────────────────── + +describe('searchOverpassPois all-endpoints-down', () => { + const bbox = { south: -41.2, west: 146.31, north: -41.16, east: 146.37 }; + + 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({ + status: 502, + message: 'Could not reach any Overpass endpoint', + }); + errSpy.mockRestore(); + }); + + 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(); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all')); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED')); + errSpy.mockRestore(); + }); +}); + +// ── Wrapper surface (kept from the pre-fold wrapper suite) ──────────────────── + +/** A DatabaseService stub whose get() returns the row the test wants. */ +function makeSettingsDb(row?: { value: string }) { + const get = vi.fn(() => row); + return { db: { get } as unknown as DatabaseService, get }; +} + +function settingsSvc(row?: { value: string }) { + return new MapsService(makeSettingsDb(row).db); +} + +describe('kill-switch settings reads', () => { + it('reports a switch disabled when the stored value is exactly "false"', () => { + expect(settingsSvc({ value: 'false' }).autocompleteDisabled()).toBe(true); + expect(settingsSvc({ value: 'false' }).detailsDisabled()).toBe(true); + expect(settingsSvc({ value: 'false' }).photosDisabled()).toBe(true); + }); + + it('reports enabled when the value is "true"', () => { + expect(settingsSvc({ value: 'true' }).autocompleteDisabled()).toBe(false); + expect(settingsSvc({ value: 'true' }).detailsDisabled()).toBe(false); + expect(settingsSvc({ value: 'true' }).photosDisabled()).toBe(false); + }); + + it('reports enabled when the setting row is absent', () => { + expect(settingsSvc(undefined).autocompleteDisabled()).toBe(false); + expect(settingsSvc(undefined).detailsDisabled()).toBe(false); + expect(settingsSvc(undefined).photosDisabled()).toBe(false); + }); + + it('queries the matching app_settings key', () => { + const { db: settingsDb, get } = makeSettingsDb({ value: 'true' }); + const s = new MapsService(settingsDb); + s.autocompleteDisabled(); + expect(get).toHaveBeenCalledWith(expect.stringContaining('app_settings'), 'places_autocomplete_enabled'); + s.detailsDisabled(); + expect(get).toHaveBeenCalledWith(expect.any(String), 'places_details_enabled'); + s.photosDisabled(); + expect(get).toHaveBeenCalledWith(expect.any(String), 'places_photos_enabled'); + }); +}); + +describe('photoBytesPath', () => { + it('returns the cached file path from placePhotoCache', () => { + mockServeFilePath.mockReturnValue('/cache/p1.jpg'); + expect(svc.photoBytesPath('p1')).toBe('/cache/p1.jpg'); + expect(mockServeFilePath).toHaveBeenCalledWith('p1'); + }); + + it('returns null when nothing is cached', () => { + mockServeFilePath.mockReturnValue(null); + expect(svc.photoBytesPath('p1')).toBeNull(); + }); +}); + +describe('controller-facing wrappers delegate to the folded methods', () => { + it('search/autocomplete/details/detailsExpanded/photo/reverse/resolveUrl/pois forward their args', async () => { + const spies = { + searchPlaces: vi.spyOn(MapsService.prototype, 'searchPlaces').mockResolvedValue({ places: [], source: 'osm' }), + autocompletePlaces: vi.spyOn(MapsService.prototype, 'autocompletePlaces').mockResolvedValue({ suggestions: [], source: 'osm' }), + getPlaceDetails: vi.spyOn(MapsService.prototype, 'getPlaceDetails').mockResolvedValue({ place: {} }), + getPlaceDetailsExpanded: vi.spyOn(MapsService.prototype, 'getPlaceDetailsExpanded').mockResolvedValue({ place: {} }), + 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 }), + }; + try { + const circleBias = { lat: 1, lng: 2, radius: 5 }; + await svc.search(3, 'berlin', 'de', circleBias); + expect(spies.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', circleBias); + + const rectBias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; + await svc.autocomplete(3, 'be', 'en', rectBias); + expect(spies.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', rectBias); + + await svc.details(3, 'p1', 'de'); + expect(spies.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de'); + + await svc.detailsExpanded(3, 'p1', 'de', true); + expect(spies.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true); + + await svc.photo(3, 'p1', 1.5, 2.5, 'Spot'); + expect(spies.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot'); + + await svc.reverse('1', '2', 'de'); + expect(spies.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de'); + + await svc.resolveUrl('https://maps.app.goo.gl/x'); + expect(spies.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x'); + + const bbox = { south: 1, west: 2, north: 3, east: 4 }; + await svc.pois('cafe', bbox, 'de'); + expect(spies.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox, 'de'); + } finally { + Object.values(spies).forEach((s) => s.mockRestore()); + } + }); +}); + +// ── maps.bridge delegation ──────────────────────────────────────────────────── + +describe('maps.bridge delegation', () => { + it('MAPS-104: getMapsKey delegates to the shared MapsService instance', async () => { + const bridge = await import('../../../src/nest/maps/maps.bridge'); + const spy = vi.spyOn(MapsService.prototype, 'getMapsKey').mockReturnValueOnce('bridged-key'); + try { + expect(bridge.getMapsKey(7)).toBe('bridged-key'); + expect(spy).toHaveBeenCalledWith(7); + } finally { + spy.mockRestore(); + } + }); + + it('MAPS-105: searchPlaces delegates with all args', async () => { + const bridge = await import('../../../src/nest/maps/maps.bridge'); + const spy = vi.spyOn(MapsService.prototype, 'searchPlaces').mockResolvedValueOnce({ places: [], source: 'osm' }); + try { + await expect(bridge.searchPlaces(7, 'berlin', 'de', { lat: 1, lng: 2, radius: 3 })).resolves.toEqual({ + places: [], + source: 'osm', + }); + expect(spy).toHaveBeenCalledWith(7, 'berlin', 'de', { lat: 1, lng: 2, radius: 3 }); + } finally { + spy.mockRestore(); + } + }); + + it('MAPS-106: getPlacePhoto delegates with coords and name', async () => { + const bridge = await import('../../../src/nest/maps/maps.bridge'); + const spy = vi.spyOn(MapsService.prototype, 'getPlacePhoto').mockResolvedValueOnce({ photoUrl: null, attribution: null }); + try { + await expect(bridge.getPlacePhoto(7, 'p1', 1.5, 2.5, 'Spot')).resolves.toEqual({ photoUrl: null, attribution: null }); + expect(spy).toHaveBeenCalledWith(7, 'p1', 1.5, 2.5, 'Spot'); + } finally { + spy.mockRestore(); + } }); }); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts deleted file mode 100644 index 0e12f639d..000000000 --- a/server/tests/unit/services/mapsService.test.ts +++ /dev/null @@ -1,2162 +0,0 @@ -/** - * Unit tests for mapsService — MAPS-001 through MAPS-080. - * Covers parseOpeningHours, buildOsmDetails, getMapsKey, reverseGeocode, - * resolveGoogleMapsUrl (coordinate extraction + short URL / SSRF), - * searchNominatim, fetchOverpassDetails, fetchWikimediaPhoto, searchPlaces, - * getPlaceDetails, and getPlacePhoto (all branches including cache logic). - * fetch is stubbed; DB and ssrfGuard are mocked. - */ -import { - parseOpeningHours, - normalizeOpeningPeriods, - normalizeSpecialDays, - buildOsmDetails, - getMapsKey, - googleFtidFromMapsUrl, - isGooglePlaceId, - buildUserAgent, - resolveOverpassEndpoints, - resolveOverpassTimeoutMs, - searchOverpassPois, -} from '../../../src/services/mapsService'; - -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; - -const { - mockDbGet, - mockDbRun, - mockCheckSsrf, - mockCacheGet, - mockCacheGetErrored, - mockCacheMarkError, - mockCachePut, - mockCacheGetInFlight, - mockCacheSetInFlight, -} = vi.hoisted(() => ({ - mockDbGet: vi.fn(() => undefined as any), - mockDbRun: vi.fn(), - mockCheckSsrf: vi.fn(async () => ({ allowed: true })), - mockCacheGet: vi.fn(() => null as any), - mockCacheGetErrored: vi.fn(() => false), - mockCacheMarkError: vi.fn(), - mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ - photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, - filePath: `/tmp/${placeId}.jpg`, - attribution, - })), - mockCacheGetInFlight: vi.fn(() => undefined), - mockCacheSetInFlight: vi.fn(), -})); - -vi.mock('../../../src/db/database', () => ({ - db: { - prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: mockDbRun }), - }, -})); - -vi.mock('../../../src/utils/ssrfGuard', () => { - class SsrfBlockedError extends Error { - constructor(message: string) { - super(message); - this.name = 'SsrfBlockedError'; - } - } - return { - checkSsrf: mockCheckSsrf, - SsrfBlockedError, - // Mirror the real per-hop helper closely enough for unit tests: run the - // (mocked) SSRF check, then fetch through the (stubbed) global fetch. The - // fetch stubs in these tests already return the final resolved response. - safeFetchFollow: vi.fn(async (url: string, init?: any) => { - const ssrf = await mockCheckSsrf(url); - if (!ssrf.allowed) throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard'); - return (globalThis.fetch as any)(url, init); - }), - }; -}); - -vi.mock('../../../src/services/apiKeyCrypto', () => ({ - decrypt_api_key: (v: string | null) => v, -})); - -vi.mock('../../../src/config', () => ({ - JWT_SECRET: 'test-secret', - ENCRYPTION_KEY: '0'.repeat(64), -})); - -vi.mock('../../../src/services/placePhotoCache', () => ({ - get: (placeId: string) => mockCacheGet(placeId), - getErrored: (placeId: string) => mockCacheGetErrored(placeId), - put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution), - markError: (placeId: string, kind?: string) => mockCacheMarkError(placeId, kind), - getInFlight: (placeId: string) => mockCacheGetInFlight(placeId), - setInFlight: (placeId: string, p: Promise) => mockCacheSetInFlight(placeId, p), - serveFilePath: vi.fn(() => null), -})); - -afterEach(() => { - vi.unstubAllGlobals(); - mockDbGet.mockReset(); - mockDbGet.mockReturnValue(undefined); - mockDbRun.mockReset(); - mockCheckSsrf.mockReset(); - mockCheckSsrf.mockResolvedValue({ allowed: true }); - mockCacheGet.mockReset(); - mockCacheGet.mockReturnValue(null); - mockCacheGetErrored.mockReset(); - mockCacheGetErrored.mockReturnValue(false); - mockCacheMarkError.mockReset(); - mockCachePut.mockReset(); - mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ - photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, - filePath: `/tmp/${placeId}.jpg`, - attribution, - })); - mockCacheGetInFlight.mockReset(); - mockCacheGetInFlight.mockReturnValue(undefined); - mockCacheSetInFlight.mockReset(); -}); - -// ── parseOpeningHours ───────────────────────────────────────────────────────── - -describe('parseOpeningHours', () => { - it('MAPS-001: returns 7 weekday descriptions and openNow', () => { - const result = parseOpeningHours('Mo-Fr 09:00-18:00'); - expect(result.weekdayDescriptions).toHaveLength(7); - expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00'); - expect(typeof result.openNow === 'boolean' || result.openNow === null).toBe(true); - }); - - it('MAPS-002: marks unknown days with ?', () => { - const result = parseOpeningHours('Mo 10:00-12:00'); - expect(result.weekdayDescriptions[1]).toContain('?'); - }); - - it('MAPS-003: handles multiple segments separated by semicolons', () => { - const result = parseOpeningHours('Mo-Fr 09:00-18:00; Sa 10:00-14:00'); - expect(result.weekdayDescriptions[5]).toContain('Saturday: 10:00-14:00'); - expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00'); - }); - - it('MAPS-004: handles 24/7 string gracefully (no crash)', () => { - const result = parseOpeningHours('24/7'); - expect(result.weekdayDescriptions).toHaveLength(7); - }); - - it('MAPS-005: returns openNow null for unparseable format', () => { - const result = parseOpeningHours('invalid-hours-string'); - expect(result.openNow).toBeNull(); - }); - - it('MAPS-006: handles comma-separated days', () => { - const result = parseOpeningHours('Mo,We,Fr 08:00-17:00'); - expect(result.weekdayDescriptions[0]).toContain('Monday: 08:00-17:00'); - expect(result.weekdayDescriptions[2]).toContain('Wednesday: 08:00-17:00'); - expect(result.weekdayDescriptions[4]).toContain('Friday: 08:00-17:00'); - expect(result.weekdayDescriptions[1]).toContain('?'); - }); - - it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms', () => { - const adversarial = 'Mo' + ',Mo'.repeat(500) + ' closed'; - const start = Date.now(); - parseOpeningHours(adversarial); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(100); - }); - - it('MAPS-007b: emits machine-readable periods in Google day numbering (Sunday = 0)', () => { - const result = parseOpeningHours('Mo 09:00-18:00; Su 10:00-14:00'); - expect(result.periods).toEqual([ - { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, - { open: { day: 0, hour: 10, minute: 0 }, close: { day: 0, hour: 14, minute: 0 } }, - ]); - }); - - it('MAPS-007c: a period past midnight closes on the following day', () => { - const result = parseOpeningHours('Sa 20:00-02:00'); - expect(result.periods).toEqual([ - { open: { day: 6, hour: 20, minute: 0 }, close: { day: 0, hour: 2, minute: 0 } }, - ]); - }); - - it('MAPS-007d: the OSM 24:00 spelling becomes midnight of the next day', () => { - // Google's clock has no hour 24, so "00:00-24:00" is a full day, not a rejected range. - const result = parseOpeningHours('Mo 00:00-24:00'); - expect(result.periods).toEqual([ - { open: { day: 1, hour: 0, minute: 0 }, close: { day: 2, hour: 0, minute: 0 } }, - ]); - }); - - it('MAPS-007e: unusable clock values produce no period', () => { - expect(parseOpeningHours('Mo 09:75-18:00').periods).toEqual([]); - expect(parseOpeningHours('Mo 09:00-24:30').periods).toEqual([]); - expect(parseOpeningHours('Mo closed').periods).toEqual([]); - }); -}); - -// ── normalizeOpeningPeriods / normalizeSpecialDays ──────────────────────────── - -describe('normalizeOpeningPeriods', () => { - it('MAPS-007f: keeps well-formed periods and fills the zeroes proto3 JSON omits', () => { - expect( - normalizeOpeningPeriods([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17 } }]), - ).toEqual([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17, minute: 0 } }]); - // Sunday midnight arrives as an empty object. - expect(normalizeOpeningPeriods([{ open: {} }])).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]); - }); - - it('MAPS-007g: a period without a close survives as the round-the-clock marker', () => { - expect(normalizeOpeningPeriods([{ open: { day: 0, hour: 0, minute: 0 } }])).toEqual([ - { open: { day: 0, hour: 0, minute: 0 }, close: null }, - ]); - }); - - it('MAPS-007h: drops out-of-range points and periods with an unusable close', () => { - expect(normalizeOpeningPeriods([{ open: { day: 7, hour: 9, minute: 0 } }])).toBeNull(); - expect(normalizeOpeningPeriods([{ open: { day: 1, hour: 24, minute: 0 } }])).toBeNull(); - // A broken close must not be mistaken for "never closes". - expect( - normalizeOpeningPeriods([{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 9, minute: 90 } }]), - ).toBeNull(); - }); - - it('MAPS-007i: returns null when there is nothing to normalise', () => { - expect(normalizeOpeningPeriods(undefined)).toBeNull(); - expect(normalizeOpeningPeriods([])).toBeNull(); - }); -}); - -describe('normalizeSpecialDays', () => { - it('MAPS-007j: turns Google date parts into ISO dates without duplicates', () => { - expect( - normalizeSpecialDays([ - { date: { year: 2026, month: 12, day: 25 } }, - { date: { year: 2026, month: 1, day: 1 } }, - { date: { year: 2026, month: 12, day: 25 } }, - ]), - ).toEqual(['2026-12-25', '2026-01-01']); - }); - - it('MAPS-007k: skips incomplete or impossible dates and returns null when none are left', () => { - expect(normalizeSpecialDays([{}, { date: { year: 2026, month: 13, day: 1 } }, { date: { year: 2026, day: 5 } }])).toBeNull(); - expect(normalizeSpecialDays(undefined)).toBeNull(); - }); -}); - -// ── buildOsmDetails ─────────────────────────────────────────────────────────── - -describe('buildOsmDetails', () => { - it('MAPS-008: returns website from tags', () => { - const result = buildOsmDetails({ website: 'https://example.com' }, 'way', '123'); - expect(result.website).toBe('https://example.com'); - }); - - it('MAPS-009: prefers contact:website over website', () => { - const result = buildOsmDetails( - { 'contact:website': 'https://contact.example.com', website: 'https://other.com' }, - 'node', - '1', - ); - expect(result.website).toBe('https://contact.example.com'); - }); - - it('MAPS-010: returns null website when no tag', () => { - const result = buildOsmDetails({}, 'node', '1'); - expect(result.website).toBeNull(); - }); - - it('MAPS-011: builds correct osm_url', () => { - const result = buildOsmDetails({}, 'way', '99999'); - expect(result.osm_url).toBe('https://www.openstreetmap.org/way/99999'); - }); - - it('MAPS-012: includes parsed opening_hours when valid', () => { - const result = buildOsmDetails({ opening_hours: 'Mo-Fr 09:00-18:00' }, 'node', '1'); - expect(result.opening_hours).not.toBeNull(); - expect(Array.isArray(result.opening_hours)).toBe(true); - }); - - it('MAPS-013: opening_hours is null when tag is missing', () => { - const result = buildOsmDetails({}, 'node', '1'); - expect(result.opening_hours).toBeNull(); - expect(result.open_now).toBeNull(); - }); - - it('MAPS-014: source is always openstreetmap', () => { - expect(buildOsmDetails({}, 'node', '1').source).toBe('openstreetmap'); - }); - - it('MAPS-012b: carries the structured periods next to the weekday lines', () => { - const result = buildOsmDetails({ opening_hours: 'Mo-Tu 09:00-18:00' }, 'node', '1'); - expect(result.opening_periods).toEqual([ - { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, - { open: { day: 2, hour: 9, minute: 0 }, close: { day: 2, hour: 18, minute: 0 } }, - ]); - }); - - it('MAPS-012c: opening_periods is null when the day lines carry no usable times', () => { - expect(buildOsmDetails({}, 'node', '1').opening_periods).toBeNull(); - expect(buildOsmDetails({ opening_hours: 'Mo closed' }, 'node', '1').opening_periods).toBeNull(); - }); - - it('MAPS-014b: opening_hours is null when all days have unknown times (all "?")', () => { - // "closed" does not match the day+time pattern so all days remain "?" - const result = buildOsmDetails({ opening_hours: 'closed' }, 'node', '1'); - expect(result.opening_hours).toBeNull(); - expect(result.open_now).toBeNull(); - }); -}); - -// ── getMapsKey ──────────────────────────────────────────────────────────────── - -describe('getMapsKey', () => { - it('MAPS-015: returns user key when user has one', () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' }); - expect(getMapsKey(1)).toBe('user-api-key'); - }); - - it('MAPS-016: falls back to admin key when user has none', () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: null }); - mockDbGet.mockReturnValueOnce({ maps_api_key: 'admin-api-key' }); - expect(getMapsKey(1)).toBe('admin-api-key'); - }); - - it('MAPS-017: returns null when neither user nor admin has a key', () => { - mockDbGet.mockReturnValue(undefined); - expect(getMapsKey(1)).toBeNull(); - }); -}); - -// ── reverseGeocode ──────────────────────────────────────────────────────────── - -describe('reverseGeocode (fetch stubbed)', () => { - it('MAPS-018: returns name and address from nominatim response', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - name: 'Eiffel Tower', - display_name: 'Eiffel Tower, Paris, France', - address: {}, - }), - }), - ); - const { reverseGeocode } = await import('../../../src/services/mapsService'); - const result = await reverseGeocode('48.8584', '2.2945'); - expect(result.name).toBe('Eiffel Tower'); - expect(result.address).toBe('Eiffel Tower, Paris, France'); - }); - - it('MAPS-019: returns nulls when fetch fails', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); - const { reverseGeocode } = await import('../../../src/services/mapsService'); - const result = await reverseGeocode('0', '0'); - expect(result.name).toBeNull(); - expect(result.address).toBeNull(); - }); - - it('MAPS-019b: falls back to address.tourism when name is absent', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - display_name: 'Some Museum, Paris', - address: { tourism: 'Some Museum' }, - }), - }), - ); - const { reverseGeocode } = await import('../../../src/services/mapsService'); - const result = await reverseGeocode('48.85', '2.35'); - expect(result.name).toBe('Some Museum'); - }); - - it('MAPS-019c: falls back to address.amenity when name and tourism are absent', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - display_name: 'A Cafe, Paris', - address: { amenity: 'A Cafe' }, - }), - }), - ); - const { reverseGeocode } = await import('../../../src/services/mapsService'); - const result = await reverseGeocode('48.85', '2.35'); - expect(result.name).toBe('A Cafe'); - }); - - it('MAPS-019d: falls back to address.road when no higher-priority field exists', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - display_name: 'Rue de Rivoli, Paris', - address: { road: 'Rue de Rivoli' }, - }), - }), - ); - const { reverseGeocode } = await import('../../../src/services/mapsService'); - const result = await reverseGeocode('48.85', '2.35'); - expect(result.name).toBe('Rue de Rivoli'); - }); - - it('MAPS-019e: returns null name when address has no recognized fields', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - display_name: 'Somewhere', - address: {}, - }), - }), - ); - const { reverseGeocode } = await import('../../../src/services/mapsService'); - const result = await reverseGeocode('0', '0'); - expect(result.name).toBeNull(); - expect(result.address).toBe('Somewhere'); - }); -}); - -// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction -const nominatimStub = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ display_name: 'Paris, France', name: null, address: {} }), -}); - -// ── resolveGoogleMapsUrl coordinate extraction ──────────────────────────────── - -describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => { - it('MAPS-020: extracts lat/lng from @lat,lng pattern', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8566,2.3522,15z'); - expect(result.lat).toBeCloseTo(48.8566, 3); - expect(result.lng).toBeCloseTo(2.3522, 3); - }); - - it('MAPS-021: extracts lat/lng from !3d!4d data pattern', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl( - 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', - ); - expect(result.lat).toBeCloseTo(48.8584, 3); - expect(result.lng).toBeCloseTo(2.2945, 3); - }); - - it('MAPS-022: extracts lat/lng from ?q=lat,lng pattern', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8566,2.3522'); - expect(result.lat).toBeCloseTo(48.8566, 3); - expect(result.lng).toBeCloseTo(2.3522, 3); - }); - - it('MAPS-023: extracts place name from /place/ path', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z'); - expect(result.name).toBe('Eiffel Tower'); - }); - - it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => { - // cid URLs (what get_place_details returns, and Google "Share" links) carry no - // inline coords; the redirect target carries the !3d!4d data param. - const fetchMock = vi.fn(async (u: string) => { - if (u.includes('nominatim')) { - return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) }; - } - return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' }; - }); - vi.stubGlobal('fetch', fetchMock); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890'); - expect(result.lat).toBeCloseTo(48.8584, 3); - expect(result.lng).toBeCloseTo(2.2945, 3); - }); - - it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => { - const fetchMock = vi.fn(async (u: string) => { - if (u.includes('nominatim')) { - return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) }; - } - if (u.includes('cid=')) { - // Redirect target has no inline coords. - return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' }; - } - // Body fetch of the resolved URL embeds coords in the map data. - return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' }; - }); - vi.stubGlobal('fetch', fetchMock); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999'); - expect(result.lat).toBeCloseTo(40.6892, 3); - expect(result.lng).toBeCloseTo(-74.0445, 3); - }); - - it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => { - const adversarial = '/@' + '1'.repeat(10000) + '.'; - const start = Date.now(); - adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); - expect(Date.now() - start).toBeLessThan(500); - }); - - it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => { - const adversarial = '!3d' + '1'.repeat(10000) + '.'; - const start = Date.now(); - adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); - expect(Date.now() - start).toBeLessThan(500); - }); - - it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => { - const adversarial = '?q=' + '1'.repeat(10000) + '.'; - const start = Date.now(); - adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); - expect(Date.now() - start).toBeLessThan(500); - }); - - it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => { - const adversarial = '<' + 'a'.repeat(10000); - const start = Date.now(); - adversarial.replace(/<[^>]+>/g, ''); - expect(Date.now() - start).toBeLessThan(100); - }); - - it('MAPS-028: throws when no coordinates found in URL', async () => { - vi.stubGlobal('fetch', nominatimStub); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - await expect(resolveGoogleMapsUrl('https://www.google.com/maps')).rejects.toThrow(); - }); - - it('MAPS-028b: throws 403 when short URL is blocked by SSRF check', async () => { - mockCheckSsrf.mockResolvedValueOnce({ allowed: false }); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - await expect(resolveGoogleMapsUrl('https://goo.gl/maps/abc123')).rejects.toMatchObject({ status: 403 }); - }); - - it('MAPS-028c: follows redirect for short goo.gl URL and extracts coordinates', async () => { - const redirectFetch = vi - .fn() - // First call: the redirect (goo.gl), returns resolved URL in .url - .mockResolvedValueOnce({ - url: 'https://www.google.com/maps/@48.8566,2.3522,15z', - }) - // Second call: the Nominatim reverse geocode - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ display_name: 'Paris, France', name: 'Paris', address: {} }), - }); - vi.stubGlobal('fetch', redirectFetch); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - const result = await resolveGoogleMapsUrl('https://goo.gl/maps/abc123'); - expect(result.lat).toBeCloseTo(48.8566, 3); - expect(result.lng).toBeCloseTo(2.3522, 3); - }); - - it('MAPS-028d: falls back to nominatim address fields when no placeName in URL', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - display_name: 'Louvre Museum, Paris', - name: null, - address: { tourism: 'Louvre Museum' }, - }), - }); - vi.stubGlobal('fetch', fetchMock); - const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService'); - // URL with coordinates but no /place/ path segment - const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z'); - expect(result.name).toBe('Louvre Museum'); - }); -}); - -// ── searchNominatim (fetch-dependent) ──────────────────────────────────────── - -describe('searchNominatim (fetch stubbed)', () => { - it('MAPS-029: returns mapped nominatim results on success', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { osm_type: 'way', osm_id: '1', lat: '48.8', lon: '2.3', name: 'Paris', display_name: 'Paris, France' }, - ], - }), - ); - const { searchNominatim } = await import('../../../src/services/mapsService'); - const results = await searchNominatim('Paris'); - expect(results).toHaveLength(1); - expect((results[0] as any).address).toBe('Paris, France'); - expect((results[0] as any).source).toBe('openstreetmap'); - }); - - it('MAPS-030: throws on fetch failure', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); - const { searchNominatim } = await import('../../../src/services/mapsService'); - await expect(searchNominatim('fail')).rejects.toThrow(); - }); - - it('MAPS-030b: throws when nominatim response is not ok', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: async () => '', - }), - ); - const { searchNominatim } = await import('../../../src/services/mapsService'); - await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error'); - }); - - it('MAPS-030c: falls back to display_name split when name is absent', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' }], - }), - ); - const { searchNominatim } = await import('../../../src/services/mapsService'); - const results = await searchNominatim('London'); - expect((results[0] as any).name).toBe('London'); - }); -}); - -// ── fetchOverpassDetails (fetch stubbed) ───────────────────────────────────── - -describe('fetchOverpassDetails (fetch stubbed)', () => { - it('MAPS-031: returns element tags on success', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }), - }), - ); - const { fetchOverpassDetails } = await import('../../../src/services/mapsService'); - const result = await 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 { fetchOverpassDetails } = await import('../../../src/services/mapsService'); - const result = await fetchOverpassDetails('unknown', '12345'); - expect(result).toBeNull(); - }); - - it('MAPS-033: returns null when fetch throws', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); - const { fetchOverpassDetails } = await import('../../../src/services/mapsService'); - const result = await 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 { fetchOverpassDetails } = await import('../../../src/services/mapsService'); - const result = await fetchOverpassDetails('node', '99999'); - expect(result).toBeNull(); - }); - - it('MAPS-034b: returns null when elements array is empty', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ elements: [] }), - }), - ); - const { fetchOverpassDetails } = await import('../../../src/services/mapsService'); - const result = await fetchOverpassDetails('node', '1'); - expect(result).toBeNull(); - }); -}); - -// ── searchOverpassPois localized names (#1655) ─────────────────────────────── - -describe('searchOverpassPois localized names (#1655)', () => { - // Elephant and Obelisk in Rome: OSM ships the native name plus localized tags. - const tags = { - name: 'Obelisco della Minerva', - 'name:en': 'Elephant and Obelisk', - 'name:de': 'Minerva-Obelisk', - int_name: 'Elephant Obelisk', - tourism: 'attraction', - }; - const stubOverpass = (elementTags: Record) => - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ elements: [{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: elementTags }] }), - }), - ); - // Distinct bboxes per case so the module-level POI_CACHE (keyed by lang+bbox) - // never serves one case's names to another. - const bbox = (n: number) => ({ south: 41 + n / 100, west: 12, north: 42 + n / 100, east: 13 }); - - it('prefers name: for the user language over the native name', async () => { - stubOverpass(tags); - const { searchOverpassPois } = await import('../../../src/services/mapsService'); - const { pois } = await 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 { searchOverpassPois } = await import('../../../src/services/mapsService'); - const { pois } = await 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 { searchOverpassPois } = await import('../../../src/services/mapsService'); - const { pois } = await searchOverpassPois('sights', bbox(3), 'fr-FR'); - expect(pois[0].name).toBe('Elephant Obelisk'); - }); - - it('falls back to the native name when no localized tag exists', async () => { - stubOverpass({ name: tags.name, tourism: 'attraction' }); - const { searchOverpassPois } = await import('../../../src/services/mapsService'); - const { pois } = await searchOverpassPois('sights', bbox(4), 'fr-FR'); - expect(pois[0].name).toBe('Obelisco della Minerva'); - }); -}); - -// ── fetchWikimediaPhoto (fetch stubbed) ─────────────────────────────────────── - -describe('fetchWikimediaPhoto (fetch stubbed)', () => { - it('MAPS-035: returns photo from Wikipedia article image (strategy 1)', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://example.com/thumb.jpg' } } } }, - }), - }), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3, 'Eiffel Tower'); - expect(result).toBeDefined(); - expect(result!.photoUrl).toBe('https://example.com/thumb.jpg'); - expect(result!.attribution).toBe('Wikipedia'); - }); - - it('MAPS-036: falls through to geosearch when Wikipedia has no thumbnail', async () => { - const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) }; - const commonsResponse = { - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [ - { url: 'https://commons.org/img.jpg', mime: 'image/jpeg', extmetadata: { Artist: { value: 'Alice' } } }, - ], - }, - }, - }, - }), - }; - vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse)); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); - expect(result).toBeDefined(); - expect(result!.photoUrl).toBe('https://commons.org/img.jpg'); - expect(result!.attribution).toBe('Alice'); - }); - - it('MAPS-036b: geosearch prefers the scaled thumburl over the full-res original', async () => { - const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) }; - const commonsResponse = { - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [ - { - url: 'https://commons.org/original-16mb.jpg', - thumburl: 'https://commons.org/thumb-400.jpg', - mime: 'image/jpeg', - extmetadata: { Artist: { value: 'Alice' } }, - }, - ], - }, - }, - }, - }), - }; - vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse)); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); - expect(result).toBeDefined(); - expect(result!.photoUrl).toBe('https://commons.org/thumb-400.jpg'); - expect(result!.attribution).toBe('Alice'); - }); - - it('MAPS-037: returns null when both strategies find nothing', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ query: { pages: {} } }), - }), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result).toBeNull(); - }); - - it('MAPS-037b: skips strategy 1 entirely when name is undefined', async () => { - // Only one fetch call is made (the Commons geosearch), not two - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ query: { pages: {} } }), - }); - vi.stubGlobal('fetch', fetchMock); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - await fetchWikimediaPhoto(48.8, 2.3); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('MAPS-037c: falls through to geosearch when Wikipedia fetch throws', async () => { - const commonsResponse = { - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [{ url: 'https://commons.org/fallback.jpg', mime: 'image/png', extmetadata: {} }], - }, - }, - }, - }), - }; - vi.stubGlobal( - 'fetch', - vi.fn().mockRejectedValueOnce(new Error('Wikipedia network error')).mockResolvedValueOnce(commonsResponse), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); - expect(result).toBeDefined(); - expect(result!.photoUrl).toBe('https://commons.org/fallback.jpg'); - // no Artist in extmetadata -> attribution null - expect(result!.attribution).toBeNull(); - }); - - it('MAPS-037d: falls through to geosearch when Wikipedia response is not ok', async () => { - const wikiNotOk = { ok: false }; - const commonsResponse = { - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [ - { - url: 'https://commons.org/photo.jpg', - mime: 'image/jpeg', - extmetadata: { Artist: { value: 'Bob' } }, - }, - ], - }, - }, - }, - }), - }; - vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiNotOk).mockResolvedValueOnce(commonsResponse)); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place'); - expect(result).toBeDefined(); - // HTML tags stripped from attribution - expect(result!.attribution).toBe('Bob'); - }); - - it('MAPS-037e: returns null when Commons geosearch returns not ok', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result).toBeNull(); - }); - - it('MAPS-037f: returns null when Commons geosearch returns no query.pages', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ query: {} }), - }), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result).toBeNull(); - }); - - it('MAPS-037g: returns null when Commons fetch throws', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Commons network error'))); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result).toBeNull(); - }); - - it('MAPS-037h: skips Commons page entries with non-photo MIME type (SVG)', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [{ url: 'https://commons.org/diagram.svg', mime: 'image/svg+xml' }], - }, - }, - }, - }), - }), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result).toBeNull(); - }); - - it('MAPS-037i: accepts PNG mime type as valid photo', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [ - { - url: 'https://commons.org/photo.png', - mime: 'image/png', - extmetadata: { Artist: { value: 'Carol' } }, - }, - ], - }, - }, - }, - }), - }), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result!.photoUrl).toBe('https://commons.org/photo.png'); - expect(result!.attribution).toBe('Carol'); - }); - - it('MAPS-037j: returns null attribution when Artist extmetadata is absent', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { - pages: { - '1': { - imageinfo: [{ url: 'https://commons.org/noattr.jpg', mime: 'image/jpeg', extmetadata: {} }], - }, - }, - }, - }), - }), - ); - const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService'); - const result = await fetchWikimediaPhoto(48.8, 2.3); - expect(result!.attribution).toBeNull(); - }); -}); - -// ── searchPlaces (fetch stubbed) ───────────────────────────────────────────── - -describe('searchPlaces (fetch stubbed)', () => { - it('MAPS-038: uses Nominatim when user has no API key', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' }, - ], - }), - ); - const { searchPlaces } = await import('../../../src/services/mapsService'); - const result = await searchPlaces(999, 'Paris'); - expect(result.source).toBe('openstreetmap'); - expect(Array.isArray(result.places)).toBe(true); - }); - - it('MAPS-039: uses Google when user has an API key', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - places: [ - { - id: 'gid1', - displayName: { text: 'Eiffel Tower' }, - formattedAddress: 'Paris', - location: { latitude: 48.8, longitude: 2.3 }, - // Real search API returns a cid-style URL with no ftid → google_ftid stays null. - googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', - }, - ], - }), - }), - ); - const { searchPlaces } = await import('../../../src/services/mapsService'); - const result = await searchPlaces(1, 'Eiffel Tower'); - expect(result.source).toBe('google'); - expect((result.places[0] as any).google_place_id).toBe('gid1'); - expect((result.places[0] as any).google_ftid).toBeNull(); - }); - - it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 403, - json: async () => ({ error: { message: 'API key invalid' } }), - }), - ); - const { searchPlaces } = await import('../../../src/services/mapsService'); - await expect(searchPlaces(1, 'anything')).rejects.toMatchObject({ - message: 'API key invalid', - status: 403, - }); - }); - - it('MAPS-039c: throws with generic message when Google error has no message', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: {} }), - }), - ); - const { searchPlaces } = await import('../../../src/services/mapsService'); - await expect(searchPlaces(1, 'anything')).rejects.toMatchObject({ - message: 'Google Places API error', - status: 500, - }); - }); - - it('MAPS-039d: returns empty places array when Google returns no results', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ places: [] }), - }), - ); - const { searchPlaces } = await import('../../../src/services/mapsService'); - const result = await searchPlaces(1, 'very obscure place'); - expect(result.source).toBe('google'); - expect(result.places).toHaveLength(0); - }); - - it('MAPS-039e: handles Google result with optional fields absent', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - // id only, no displayName, formattedAddress, location, etc. - places: [{ id: 'gid-sparse' }], - }), - }), - ); - const { searchPlaces } = await import('../../../src/services/mapsService'); - const result = await searchPlaces(1, 'sparse'); - const place = result.places[0] as any; - expect(place.google_place_id).toBe('gid-sparse'); - expect(place.google_ftid).toBeNull(); - expect(place.name).toBe(''); - expect(place.address).toBe(''); - expect(place.lat).toBeNull(); - expect(place.lng).toBeNull(); - expect(place.rating).toBeNull(); - expect(place.website).toBeNull(); - expect(place.phone).toBeNull(); - }); -}); - -// ── autocompletePlaces (fetch stubbed) ────────────────────────────────────── - -describe('autocompletePlaces (fetch stubbed)', () => { - it('MAPS-081: uses Nominatim when user has no API key', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - osm_type: 'node', - osm_id: '1', - lat: '48.8', - lon: '2.3', - display_name: 'Paris, Île-de-France, France', - name: 'Paris', - }, - ], - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(999, 'Paris'); - expect(result.source).toBe('nominatim'); - expect(result.suggestions).toHaveLength(1); - expect(result.suggestions[0].mainText).toBe('Paris'); - expect(result.suggestions[0].placeId).toBe('node:1'); - }); - - it('MAPS-082: uses Google when user has an API key', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - suggestions: [ - { - placePrediction: { - placeId: 'ChIJ1234', - structuredFormat: { - mainText: { text: 'Eiffel Tower' }, - secondaryText: { text: 'Paris, France' }, - }, - }, - }, - ], - }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(1, 'Eiffel'); - expect(result.source).toBe('google'); - expect(result.suggestions).toHaveLength(1); - expect(result.suggestions[0].placeId).toBe('ChIJ1234'); - expect(result.suggestions[0].mainText).toBe('Eiffel Tower'); - expect(result.suggestions[0].secondaryText).toBe('Paris, France'); - }); - - it('MAPS-083: throws with Google error status when API returns non-ok', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 403, - json: async () => ({ error: { message: 'API key invalid' } }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({ - message: 'API key invalid', - status: 403, - }); - }); - - it('MAPS-084: throws generic message when Google error has no message', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: {} }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({ - message: 'Google Places Autocomplete error', - status: 500, - }); - }); - - it('MAPS-085: returns empty suggestions when Google returns no results', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ suggestions: [] }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(1, 'very obscure place'); - expect(result.source).toBe('google'); - expect(result.suggestions).toHaveLength(0); - }); - - it('MAPS-086: filters out suggestions without placePrediction', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - suggestions: [ - { placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } }, - { queryPrediction: { text: 'some query' } }, - { placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } }, - ], - }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(1, 'test'); - expect(result.suggestions).toHaveLength(2); - expect(result.suggestions[0].placeId).toBe('A'); - expect(result.suggestions[1].placeId).toBe('B'); - }); - - it('MAPS-087: limits results to 5 suggestions', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - const manySuggestions = Array.from({ length: 10 }, (_, i) => ({ - placePrediction: { - placeId: `id-${i}`, - structuredFormat: { mainText: { text: `Place ${i}` } }, - }, - })); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ suggestions: manySuggestions }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(1, 'test'); - expect(result.suggestions).toHaveLength(5); - }); - - it('MAPS-088: includes locationBias in Google request when provided', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ suggestions: [] }), - }); - vi.stubGlobal('fetch', fetchMock); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - await autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } }); - - expect(fetchMock).toHaveBeenCalledOnce(); - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.locationBias).toEqual({ - rectangle: { - low: { latitude: 48.5, longitude: 2.0 }, - high: { latitude: 49.0, longitude: 2.8 }, - }, - }); - }); - - it('MAPS-089: omits locationBias from Google request when not provided', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ suggestions: [] }), - }); - vi.stubGlobal('fetch', fetchMock); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - await autocompletePlaces(1, 'test', 'en'); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.locationBias).toBeUndefined(); - }); - - it('MAPS-090: handles missing structuredFormat fields gracefully', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - suggestions: [{ placePrediction: { placeId: 'sparse-id' } }], - }), - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(1, 'sparse'); - expect(result.suggestions[0].placeId).toBe('sparse-id'); - expect(result.suggestions[0].mainText).toBe(''); - expect(result.suggestions[0].secondaryText).toBe(''); - }); - - it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(999, 'fail'); - expect(result.source).toBe('nominatim'); - expect(result.suggestions).toHaveLength(0); - }); - - it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { - osm_type: 'way', - osm_id: '42', - lat: '51.5', - lon: '-0.1', - display_name: 'Big Ben, Westminster, London, UK', - name: 'Big Ben', - }, - ], - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(999, 'Big Ben'); - expect(result.suggestions[0].mainText).toBe('Big Ben'); - expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK'); - }); - - it('MAPS-093: Nominatim fallback filters out results with empty osm_id', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => [ - { osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' }, - { osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' }, - { osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' }, - ], - }), - ); - const { autocompletePlaces } = await import('../../../src/services/mapsService'); - const result = await autocompletePlaces(999, 'test'); - expect(result.suggestions).toHaveLength(2); - expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']); - }); -}); - -// ── getPlaceDetails (fetch stubbed) ───────────────────────────────────────── - -describe('getPlaceDetails (fetch stubbed)', () => { - it('MAPS-040: handles OSM placeId (way:id) via Overpass', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ elements: [{ tags: { website: 'https://eiffel.com' } }] }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'way:12345'); - expect(result.place).toBeDefined(); - expect((result.place as any).source).toBe('openstreetmap'); - expect((result.place as any).website).toBe('https://eiffel.com'); - }); - - it('MAPS-040b: handles OSM placeId when Overpass returns no tags (element missing)', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ elements: [] }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'node:99999'); - expect((result.place as any).source).toBe('openstreetmap'); - expect((result.place as any).website).toBeNull(); - }); - - it('MAPS-041: throws 400 when Google placeId given but no API key', async () => { - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - await expect(getPlaceDetails(999, 'ChIJNotAnOsmId')).rejects.toMatchObject({ status: 400 }); - }); - - it('MAPS-041b: returns full Google place details on happy path', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJ123', - displayName: { text: 'Eiffel Tower' }, - formattedAddress: 'Champ de Mars, 5 Av. Anatole France, 75007 Paris', - location: { latitude: 48.8584, longitude: 2.2945 }, - rating: 4.7, - userRatingCount: 200000, - websiteUri: 'https://www.toureiffel.paris', - nationalPhoneNumber: '+33 892 70 12 39', - regularOpeningHours: { - weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'], - openNow: true, - }, - // The Places API returns a cid-style URL with no ftid, so google_ftid stays null - // and the precise query_place_id link is used on the client instead. - googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', - editorialSummary: { text: 'Iconic iron tower.' }, - reviews: [ - { - authorAttribution: { displayName: 'John', photoUri: 'https://photo.url' }, - rating: 5, - text: { text: 'Amazing!' }, - relativePublishTimeDescription: '2 weeks ago', - }, - ], - photos: [{ name: 'places/ChIJ123/photos/photo1', authorAttributions: [{ displayName: 'Jane' }] }], - }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJ123'); - const place = result.place as any; - expect(place.google_place_id).toBe('ChIJ123'); - expect(place.google_ftid).toBeNull(); - expect(place.name).toBe('Eiffel Tower'); - expect(place.rating).toBe(4.7); - expect(place.rating_count).toBe(200000); - expect(place.open_now).toBe(true); - expect(place.source).toBe('google'); - // Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those - expect(place.reviews).toHaveLength(0); - expect(place.summary).toBeNull(); - }); - - it('MAPS-041b2: normalises non-standard TREK language codes for Google (br→pt-BR, gr→el)', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' }, location: { latitude: 0, longitude: 0 } }), - }); - mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); - vi.stubGlobal('fetch', fetchMock); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - - await getPlaceDetails(1, 'ChIJ-br', 'br'); - expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=pt-BR'); - - await getPlaceDetails(1, 'ChIJ-gr', 'gr'); - expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=el'); - - // A code that is already valid passes through unchanged. - await getPlaceDetails(1, 'ChIJ-de', 'de'); - expect(String(fetchMock.mock.calls[2][0])).toContain('languageCode=de'); - }); - - it('MAPS-041c: throws with status when Google API returns non-ok response', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: { message: 'Place not found' } }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - await expect(getPlaceDetails(1, 'ChIJMissing')).rejects.toMatchObject({ - message: 'Place not found', - status: 404, - }); - }); - - it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - // expanded=1 cache miss → return undefined - mockDbGet.mockReturnValueOnce(undefined); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJ456', - reviews: [ - // All optional fields absent - {}, - ], - }), - }), - ); - const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetailsExpanded(1, 'ChIJ456'); - const review = (result.place as any).reviews[0]; - expect(review.author).toBeNull(); - expect(review.rating).toBeNull(); - expect(review.text).toBeNull(); - expect(review.time).toBeNull(); - expect(review.photo).toBeNull(); - }); - - it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => { - const fetchMock = vi - .fn() - // First call: Overpass (returns element with tags but no coords) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }), - }) - // Second call: Nominatim /lookup - .mockResolvedValueOnce({ - ok: true, - json: async () => [ - { - osm_type: 'way', - osm_id: '5', - lat: '48.85', - lon: '2.29', - display_name: 'Eiffel Tower, Paris, France', - name: 'Eiffel Tower', - }, - ], - }); - vi.stubGlobal('fetch', fetchMock); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'way:5'); - const place = result.place as any; - expect(place.name).toBe('Eiffel Tower'); - expect(place.address).toBe('Eiffel Tower, Paris, France'); - expect(place.lat).toBeCloseTo(48.85); - expect(place.lng).toBeCloseTo(2.29); - expect(place.source).toBe('openstreetmap'); - // Overpass first, then Nominatim — two total fetch calls - expect(fetchMock).toHaveBeenCalledTimes(2); - const overpassUrl = fetchMock.mock.calls[0][0] as string; - const nominatimUrl = fetchMock.mock.calls[1][0] as string; - expect(overpassUrl).toContain('overpass'); - expect(nominatimUrl).toContain('nominatim'); - }); - - it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJ789', - regularOpeningHours: { - weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], - // openNow intentionally absent - }, - }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJ789'); - expect((result.place as any).open_now).toBeNull(); - }); - - it('MAPS-041f: open_now is false when regularOpeningHours.openNow is false', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJClosed', - regularOpeningHours: { - weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], - openNow: false, - }, - }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJClosed'); - // false is preserved (not coerced to null) via the ?? null operator - expect((result.place as any).open_now).toBe(false); - }); - - it('MAPS-041f2: hands the structured opening periods and special days to the client', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJPeriods', - regularOpeningHours: { - // Localised display text — the client shows it but must not parse it. - weekdayDescriptions: ['月曜日: 9:00~18:00'], - openNow: true, - periods: [{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }], - specialDays: [{ date: { year: 2026, month: 12, day: 25 } }], - }, - }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const place = (await getPlaceDetails(1, 'ChIJPeriods')).place as any; - expect(place.opening_periods).toEqual([ - { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, - ]); - expect(place.opening_special_days).toEqual(['2026-12-25']); - expect(place.opening_hours).toEqual(['月曜日: 9:00~18:00']); - }); - - it('MAPS-041f3: opening_periods is null when Google sends no periods', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJNoPeriods', - regularOpeningHours: { weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], openNow: false }, - }), - }), - ); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const place = (await getPlaceDetails(1, 'ChIJNoPeriods')).place as any; - expect(place.opening_periods).toBeNull(); - expect(place.opening_special_days).toBeNull(); - }); - - it('MAPS-041f4: getPlaceDetailsExpanded carries the periods too', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - // expanded=1 cache miss - mockDbGet.mockReturnValueOnce(undefined); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id: 'ChIJExpandedPeriods', - // A place that never closes: one period, no close point. - regularOpeningHours: { openNow: true, periods: [{ open: { day: 0, hour: 0, minute: 0 } }] }, - }), - }), - ); - const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); - const place = (await getPlaceDetailsExpanded(1, 'ChIJExpandedPeriods')).place as any; - expect(place.opening_periods).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]); - }); - - it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - // expanded=1 cache miss - mockDbGet.mockReturnValueOnce(undefined); - const manyReviews = Array.from({ length: 8 }, (_, i) => ({ - authorAttribution: { displayName: `User${i}` }, - rating: 4, - text: { text: 'Good' }, - relativePublishTimeDescription: '1 day ago', - })); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ id: 'ChIJMany', reviews: manyReviews }), - }), - ); - const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetailsExpanded(1, 'ChIJMany'); - expect((result.place as any).reviews).toHaveLength(5); - }); - - // The client sends whatever id the place carries, and the expanded lookup used to - // forward all of them — including OSM ids — to Google, which bills the 400 - // INVALID_ARGUMENT it answers with (#1727). - it('MAPS-041h: getPlaceDetailsExpanded serves an OSM id from Overpass instead of Google', async () => { - mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ elements: [{ tags: { website: 'https://nerja.example' } }] }), - }); - vi.stubGlobal('fetch', fetchMock); - const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetailsExpanded(1, 'node:5255005321'); - expect((result.place as any).source).toBe('openstreetmap'); - expect((result.place as any).website).toBe('https://nerja.example'); - expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com'))) - .toBe(false); - }); - - it('MAPS-041i: getPlaceDetailsExpanded answers a coordinate pseudo-id with no place at all', async () => { - mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); - await expect(getPlaceDetailsExpanded(1, 'coords:48.8,2.3')).resolves.toEqual({ place: null }); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -// ── getPlacePhoto (fetch stubbed) ──────────────────────────────────────────── - -describe('getPlacePhoto (fetch stubbed)', () => { - it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - // First call: Wikimedia Commons API - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } }, - }), - }) - // Second call: fetch Wikimedia image bytes - .mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(100), - }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const placeId = 'coords:48.8,2.3'; - const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower'); - expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`); - expect(mockCachePut).toHaveBeenCalledOnce(); - }); - - it('MAPS-043: resolves with photoUrl null when Wikimedia returns nothing and no API key', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ query: { pages: {} } }), - }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).resolves.toEqual({ - photoUrl: null, - attribution: null, - }); - }); - - it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => { - const placeId = `coords:cache-test-${Date.now()}`; - const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`; - mockCacheGet.mockReturnValue({ - photoUrl: cachedUrl, - filePath: `/tmp/${placeId}.jpg`, - attribution: null, - }); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test'); - expect(result.photoUrl).toBe(cachedUrl); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('MAPS-043c: serves the negative cache with photoUrl null and no network request', async () => { - mockCacheGetErrored.mockReturnValue(true); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const errorId = `coords:error-cache-${Date.now()}`; - await expect(getPlacePhoto(999, errorId, 0, 0)).resolves.toEqual({ photoUrl: null, attribution: null }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('MAPS-043d: resolves with photoUrl null when lat/lng are NaN and no API key', async () => { - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const nanId = `coords:nan-test-${Date.now()}`; - await expect(getPlacePhoto(999, nanId, NaN, NaN)).resolves.toEqual({ photoUrl: null, attribution: null }); - }); - - it('MAPS-043e: falls through to photoUrl null when the Wikimedia fetch throws', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network fail'))); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const throwId = `coords:throw-test-${Date.now()}`; - await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).resolves.toEqual({ - photoUrl: null, - attribution: null, - }); - }); - - it('MAPS-043f: an in-flight lookup that found nothing resolves the waiter with photoUrl null', async () => { - mockCacheGetInFlight.mockReturnValue(Promise.resolve(null) as any); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - await expect(getPlacePhoto(999, 'ChIJInFlight', 48.8, 2.3)).resolves.toEqual({ - photoUrl: null, - attribution: null, - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - const fetchMock = vi - .fn() - // First call: get place details (with photos) - .mockResolvedValueOnce({ - ok: true, - text: async () => - JSON.stringify({ - photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }], - }), - }) - // Second call: fetch image bytes - .mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(200), - }); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const uniqueId = `ChIJABC-${Date.now()}`; - const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place'); - expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); - expect(result.attribution).toBe('Photographer'); - expect(mockCachePut).toHaveBeenCalledOnce(); - }); - - it('MAPS-044b: resolves with photoUrl null when Google details fetch returns non-ok', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 403, - text: async () => JSON.stringify({ error: { message: 'Forbidden' } }), - }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const errId = `ChIJErr-${Date.now()}`; - await expect(getPlacePhoto(1, errId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); - // A rejected provider call says nothing about the place — remember it as a - // failed attempt (minutes), not as "this place has no photo" (a day). - expect(mockCacheMarkError).toHaveBeenCalledWith(errId, 'provider-error'); - }); - - it('MAPS-044b2: remembers a place both providers came up empty for as a lasting miss', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi - .fn() - // Google answers, the place simply has no photos. - .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ photos: [] }) }) - // Wikimedia has no article near the coordinates either. - .mockResolvedValueOnce({ ok: true, json: async () => ({ query: { pages: {} } }) }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const emptyId = `ChIJEmpty-${Date.now()}`; - await expect(getPlacePhoto(1, emptyId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); - expect(mockCacheMarkError).toHaveBeenCalledWith(emptyId, 'no-photo'); - }); - - it('MAPS-044b3: a failed Wikimedia image download counts as a failed attempt', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } } }), - }) - .mockResolvedValueOnce({ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const downId = `coords:down-${Date.now()}`; - await expect(getPlacePhoto(999, downId, 48.8, 2.3, 'Place')).resolves.toEqual({ - photoUrl: null, - attribution: null, - }); - expect(mockCacheMarkError).toHaveBeenCalledWith(downId, 'provider-error'); - }); - - it('MAPS-044c: resolves with photoUrl null when the Google place has no photos', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - text: async () => JSON.stringify({ photos: [] }), - }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const noPhotoId = `ChIJNone-${Date.now()}`; - await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); - }); - - it('MAPS-044d: resolves with photoUrl null when the media endpoint returns non-ok status', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - text: async () => - JSON.stringify({ - photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }], - }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 403, - arrayBuffer: async () => new ArrayBuffer(0), - }); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const noUriId = `ChIJXYZ-${Date.now()}`; - await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); - }); - - it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - text: async () => - JSON.stringify({ - photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }], - }), - }) - .mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(150), - }); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const noAttrId = `ChIJNoAttr-${Date.now()}`; - const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3); - expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`); - expect(result.attribution).toBeNull(); - }); - - it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } }, - }), - }) - .mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(120), - }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const uniqueId = `coords:44f-test-${Date.now()}`; - const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place'); - expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); - expect(mockCachePut).toHaveBeenCalledOnce(); - }); - - it('MAPS-044g: falls back to Wikipedia/OSM for a Google place_id when the Google photo call fails', async () => { - // A key is present and the placeId is a Google id, but Google rejects the - // photo request (e.g. 403). The lookup must still return an image via the - // coordinate-based Wikipedia fallback instead of giving up with a 404 — - // matching what right-click (coords:) places already do. - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal( - 'fetch', - vi - .fn() - // 1) Google photo details → 403 - .mockResolvedValueOnce({ - ok: false, - status: 403, - text: async () => JSON.stringify({ error: { message: 'PERMISSION_DENIED' } }), - }) - // 2) Wikipedia pageimages → thumbnail - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/guinness.jpg' } } } } }), - }) - // 3) image bytes - .mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(200), - }), - ); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const placeId = `ChIJFallback-${Date.now()}`; - const result = await getPlacePhoto(1, placeId, 53.34, -6.28, 'Guinness Storehouse'); - expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`); - expect(result.attribution).toBe('Wikipedia'); - expect(mockCachePut).toHaveBeenCalledOnce(); - }); - - // OSM ids are what the client sends whenever a place has no google_place_id. - // Google rejects them with a billable 400 INVALID_ARGUMENT, so they must go - // straight to the Wikimedia fallback like coords: ids already do. - it.each(['node:5255005321', 'way:84527326', 'relation:345407'])( - 'MAPS-044h: skips Google for the OSM id %s even when an API key is configured', - async (osmId) => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/osm.jpg' } } } } }), - }) - .mockResolvedValueOnce({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(90), - }); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const result = await getPlacePhoto(1, osmId, 36.7617, -3.8448, 'Cueva de Nerja'); - expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(osmId)}/bytes`); - const requested = fetchMock.mock.calls.map((call) => String(call[0])); - expect(requested.some((url) => url.includes('places.googleapis.com'))).toBe(false); - }, - ); - - it('MAPS-044i: an OSM id with no Wikimedia hit resolves with photoUrl null instead of a 404', async () => { - mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: {} } }) }); - vi.stubGlobal('fetch', fetchMock); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - await expect(getPlacePhoto(1, 'node:2481346642', 36.76, -3.84, 'Nerja')).resolves.toEqual({ - photoUrl: null, - attribution: null, - }); - expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com'))) - .toBe(false); - }); -}); - -describe('isGooglePlaceId', () => { - it('MAPS-045: accepts Google place ids and rejects the ids Google cannot resolve', () => { - expect(isGooglePlaceId('ChIJLU7jZClu5kcR4PcOOO6p3I0')).toBe(true); - expect(isGooglePlaceId('coords:48.8,2.3')).toBe(false); - expect(isGooglePlaceId('node:5255005321')).toBe(false); - expect(isGooglePlaceId('way:84527326')).toBe(false); - expect(isGooglePlaceId('relation:345407')).toBe(false); - expect(isGooglePlaceId('https://lh3.googleusercontent.com/photo.jpg')).toBe(false); - // The collection views send the bare coordinate pair when a place has no ids. - expect(isGooglePlaceId('36.7617499,-3.8448432')).toBe(false); - }); -}); - -describe('googleFtidFromMapsUrl', () => { - it('MAPS-FTID-001: extracts a valid ftid from a /place/?ftid= URL (resolved share link)', () => { - expect( - googleFtidFromMapsUrl('https://www.google.com/maps/place/?q=X&ftid=0x882bf179e806d471:0x8591dde29c821a93'), - ).toBe('0x882bf179e806d471:0x8591dde29c821a93'); - }); - it('MAPS-FTID-002: returns null for a cid-style URL (the usual Places API shape)', () => { - expect(googleFtidFromMapsUrl('https://maps.google.com/?cid=10403719659250533155')).toBeNull(); - }); - it('MAPS-FTID-003: rejects malformed / hostile ftid values', () => { - expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=not-an-ftid')).toBeNull(); - expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=0xAB%26q%3Devil%3Cscript%3E')).toBeNull(); - expect(googleFtidFromMapsUrl('not a url')).toBeNull(); - expect(googleFtidFromMapsUrl(null)).toBeNull(); - }); -}); - -// ── buildUserAgent (instance-specific UA, #1309) ────────────────────────────── - -describe('buildUserAgent', () => { - const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)'; - - it('MAPS-094: returns the bare base UA when no instance URL is configured', () => { - expect(buildUserAgent(undefined)).toBe(base); - expect(buildUserAgent('')).toBe(base); - }); - - it('MAPS-095: appends a configured https instance URL so the deployment is identifiable', () => { - expect(buildUserAgent('https://trek.example.org')).toBe(`${base}; https://trek.example.org`); - }); - - it('MAPS-096: drops the http://localhost fallback — it is not a unique identifier', () => { - expect(buildUserAgent('http://localhost:3001')).toBe(base); - }); -}); - -// ── resolveOverpassEndpoints (OVERPASS_URL override, #1309) ──────────────────── - -describe('resolveOverpassEndpoints', () => { - it('MAPS-097: falls back to the public mirrors when OVERPASS_URL is unset/empty', () => { - expect(resolveOverpassEndpoints(undefined).length).toBeGreaterThan(1); - expect(resolveOverpassEndpoints('').length).toBeGreaterThan(1); - expect(resolveOverpassEndpoints(undefined)[0]).toContain('overpass-api.de'); - }); - - it('MAPS-098: a single custom endpoint REPLACES the public mirrors (locked-down egress)', () => { - expect(resolveOverpassEndpoints('https://overpass.internal/api/interpreter')).toEqual([ - 'https://overpass.internal/api/interpreter', - ]); - }); - - it('MAPS-099: parses a comma-separated list and trims whitespace', () => { - expect(resolveOverpassEndpoints(' https://a.test/api , http://b.test/api ')).toEqual([ - 'https://a.test/api', - 'http://b.test/api', - ]); - }); - - it('MAPS-100: drops non-http(s) / malformed entries, keeping the valid ones', () => { - expect(resolveOverpassEndpoints('https://ok.test/api, ftp://no.test, not a url')).toEqual(['https://ok.test/api']); - }); - - it('MAPS-101: falls back to the defaults when every custom entry is invalid', () => { - expect(resolveOverpassEndpoints('not a url, ftp://no.test').length).toBeGreaterThan(1); - }); -}); - -// ── resolveOverpassTimeoutMs (OVERPASS_TIMEOUT_MS override, #1309) ───────────── - -describe('resolveOverpassTimeoutMs', () => { - it('MAPS-104: falls back to the 12s default for unset / empty / non-numeric values', () => { - expect(resolveOverpassTimeoutMs(undefined)).toBe(12000); - expect(resolveOverpassTimeoutMs('')).toBe(12000); - expect(resolveOverpassTimeoutMs('abc')).toBe(12000); - }); - - it('MAPS-105: honours a positive numeric override', () => { - expect(resolveOverpassTimeoutMs('30000')).toBe(30000); - }); - - it('MAPS-106: rejects 0, negative and Infinity — a non-positive cap would 502 every search', () => { - expect(resolveOverpassTimeoutMs('0')).toBe(12000); - expect(resolveOverpassTimeoutMs('-5')).toBe(12000); - expect(resolveOverpassTimeoutMs('Infinity')).toBe(12000); - }); -}); - -// ── searchOverpassPois error path (all endpoints down, #1309) ────────────────── - -describe('searchOverpassPois all-endpoints-down', () => { - const bbox = { south: -41.2, west: 146.31, north: -41.16, east: 146.37 }; - - 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(searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({ - status: 502, - message: 'Could not reach any Overpass endpoint', - }); - errSpy.mockRestore(); - }); - - 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(searchOverpassPois('bar', bbox)).rejects.toThrow(); - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all')); - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED')); - errSpy.mockRestore(); - }); -}); diff --git a/server/tests/unit/services/placeEnrichment.test.ts b/server/tests/unit/services/placeEnrichment.test.ts index 2cfb61ff0..fbe60e72f 100644 --- a/server/tests/unit/services/placeEnrichment.test.ts +++ b/server/tests/unit/services/placeEnrichment.test.ts @@ -5,11 +5,11 @@ */ import { describe, it, expect, vi } from 'vitest'; -// placeEnrichment pulls in the DB, websocket and maps service at import time; +// placeEnrichment pulls in the DB, websocket and the maps bridge at import time; // stub them so the pure match selector can be tested in isolation. vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null })); vi.mock('../../../src/websocket', () => ({ broadcast: () => {} })); -vi.mock('../../../src/services/mapsService', () => ({ +vi.mock('../../../src/nest/maps/maps.bridge', () => ({ getMapsKey: () => null, searchPlaces: async () => ({ places: [], source: 'none' }), getPlacePhoto: async () => ({ photoUrl: '', attribution: null }), diff --git a/server/tests/unit/services/transitService.test.ts b/server/tests/unit/services/transitService.test.ts index 8e7223175..c589a1d4f 100644 --- a/server/tests/unit/services/transitService.test.ts +++ b/server/tests/unit/services/transitService.test.ts @@ -11,7 +11,7 @@ vi.mock('../../../src/app-config', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getAppUrl: () => 'https://trek.example.com' }; }); -vi.mock('../../../src/services/mapsService', () => ({ buildUserAgent: () => 'TREK-Test-UA' })); +vi.mock('../../../src/nest/maps/maps.helpers', () => ({ buildUserAgent: () => 'TREK-Test-UA' })); const fetchMock = vi.fn();