diff --git a/server/scripts/import-boundaries.mjs b/server/scripts/import-boundaries.mjs index a06a87d11..8e52ebfdd 100644 --- a/server/scripts/import-boundaries.mjs +++ b/server/scripts/import-boundaries.mjs @@ -98,6 +98,9 @@ export const PUBLIC_FILES = [ 'audit/client-ip.ts', 'addons/addon-gate.ts', 'addons/mcp-addon-gate.ts', + // The maps domain's outbound clients. MapsModule exports them so enrichment + // asks Google and Wikimedia directly instead of through a MapsService facade. + 'maps/providers/google-places.provider.ts', ]; function walk(dir, files = []) { diff --git a/server/src/nest/maps/maps.module.ts b/server/src/nest/maps/maps.module.ts index 5d8d7fc4d..eb763cfcd 100644 --- a/server/src/nest/maps/maps.module.ts +++ b/server/src/nest/maps/maps.module.ts @@ -3,6 +3,7 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { MapsController } from './maps.controller'; import { MapsService } from './maps.service'; import { MapsMcp } from './maps.mcp'; +import { GooglePlacesClient } from './providers/google-places.provider'; import { PlacePhotosModule } from '../place-photos/place-photos.module'; import { StorageModule } from '../storage/storage.module'; import { GoogleQuotaModule } from '../google-quota/google-quota.module'; @@ -28,7 +29,7 @@ import { Places } from '../../db/entities/Places.entity'; @Module({ imports: [PlacePhotosModule, StorageModule, GoogleQuotaModule, MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places])], controllers: [MapsController], - providers: [MapsService, MapsMcp], - exports: [MapsService], + providers: [MapsService, MapsMcp, GooglePlacesClient], + exports: [MapsService, GooglePlacesClient], }) export class MapsModule {} diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 708b5e5f2..67860308e 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -9,7 +9,7 @@ import type { MapsResolveUrlResult, } from '@trek/shared'; import { Jimp } from 'jimp'; -import { readEnv, getAppUrl } from '../../app-config'; +import { readEnv } from '../../app-config'; import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard'; import { discardBody, exceedsDeclaredLength, readCapped, readCappedText } from '../../utils/cappedFetch'; import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'; @@ -23,7 +23,7 @@ import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDet import { Places } from '../../db/entities/Places.entity'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider'; -import { SEARCH_TEXT_FIELD_MASK } from './providers/google-places.constants'; +import { GooglePlacesClient } from './providers/google-places.provider'; import { AMAP_SHORT_HOSTS, AmapPlacesProvider, @@ -51,11 +51,9 @@ import { import { UA, toApiLang, - googleFtidFromMapsUrl, buildOsmDetails, - normalizeOpeningPeriods, - normalizeSpecialDays, isGooglePlaceId, + googleFtidFromMapsUrl, OSM_PLACE_ID, CATEGORY_OSM_FILTERS, parsePoiCategories, @@ -70,7 +68,6 @@ import { namesOverlap, mergeSearchResults, readChargingInfo, - type GoogleOpeningHours, type OverpassPoi, } from './maps.helpers'; import { @@ -82,64 +79,6 @@ import { overpassNearbyRecords, } from './maps-nearby.helpers'; -// ── Google API call counter ─────────────────────────────────────────────────── - -let googleApiCallCount = 0; - -/** The upstream every Places call is written against. */ -const PLACES_UPSTREAM = 'https://places.googleapis.com'; - -/** - * Sends the call somewhere else when PLACES_API_BASE is set. - * - * The nine Places endpoints below all spell out the upstream host, so an install - * that wants these calls to leave through something of its own — an egress proxy, - * a cache, a gateway holding the key — has no way to say so today. One variable, - * substituted at the one place every call funnels through. - * - * Path and query are untouched, so the replacement has to speak the same API. - * Unset, which is every install today, the string is returned as it came in. - */ -function placesEndpoint(endpoint: string): string { - const base = readEnv().maps.placesApiBase; - if (!base || !endpoint.startsWith(PLACES_UPSTREAM)) return endpoint; - // The character before the run is matched and written straight back. A bare - // /\/+$/ restarts at every slash of a base that does not end in one, reading - // the rest of the run again from each of them. - return base.replace(/([^/]|^)\/+$/, '$1') + endpoint.slice(PLACES_UPSTREAM.length); -} - -/** - * Says which of the three credentials Google rejected, never which value. - * - * The response body Google sends ("The caller does not have permission") is - * identical whichever key was used, so without this line a report of "works for - * the admin, fails for everyone else" cannot be told apart from a genuinely - * broken key. - */ -function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void { - console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`); -} - -/** Ceiling for one Google Places call. Generous — the photo download is the slow one. */ -const GOOGLE_FETCH_TIMEOUT_MS = 20000; - -function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise { - const endpoint = placesEndpoint(rawEndpoint); - googleApiCallCount++; - console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); - const referer = readEnv().app.appUrl ? getAppUrl() : undefined; - return fetch(endpoint, { - ...init, - // A default ceiling here rather than at each of the nine call sites, none of - // which passed one: a hung upstream held the request handler open for as - // long as it liked. A caller that needs longer still wins, it only has to - // say so. - signal: init?.signal ?? AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS), - headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record) ?? {}) }, - }); -} - // ── Interfaces ─────────────────────────────────────────────────────────────── function nominatimCategory(item: { class?: string; type?: string }): string | null { @@ -356,64 +295,6 @@ export interface CommonsCandidate { descriptors: string | null; } -interface GooglePlaceResult { - id: string; - displayName?: { text: string }; - /** OPERATIONAL | CLOSED_TEMPORARILY | CLOSED_PERMANENTLY. Absent on non-business results. */ - businessStatus?: string; - formattedAddress?: string; - location?: { latitude: number; longitude: number }; - rating?: number; - websiteUri?: string; - nationalPhoneNumber?: string; - types?: string[]; - googleMapsUri?: string; -} - -/** A Google place as the record every search answers with. */ -function googlePlaceRecord(p: GooglePlaceResult): Record { - return { - google_place_id: p.id, - google_ftid: googleFtidFromMapsUrl(p.googleMapsUri), - name: p.displayName?.text || '', - address: p.formattedAddress || '', - // `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian). - lat: p.location?.latitude ?? null, - lng: p.location?.longitude ?? null, - rating: p.rating || null, - website: normalizePlaceWebsite(p.websiteUri), - phone: p.nationalPhoneNumber || null, - types: p.types || [], - source: 'google', - }; -} - -/** A place that has shut down for good is never the answer to "where should we go" (#1341). */ -const isOpenGooglePlace = (p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY'; - -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. @@ -768,14 +649,9 @@ export class MapsService { @InjectRepository(PlaceDetailsCache) private readonly placeDetailsCache: PlaceDetailsCacheRepository, @InjectRepository(Places) private readonly placesRepo: PlacesRepository, private readonly googleQuota: GoogleQuotaService, + private readonly googlePlaces: GooglePlacesClient, ) {} - /** Every call to Google goes through here, so the admin's daily ceiling sees it (#1582). */ - private async google(endpoint: string, label: string, init?: RequestInit): Promise { - await this.googleQuota.record(); - return googleFetch(endpoint, label, init); - } - /** Brand id → logo bytes, or null for "asked, has none". Insertion-ordered, so the * oldest entry is the one evicted when it fills up. */ private readonly brandLogoCache = new Map(); @@ -2076,75 +1952,6 @@ export class MapsService { return []; } - /** - * Photo references for a Google place, capped by the caller. - * - * Split out from the bytes download on purpose: this is one billed Details - * call for the whole strip, while every reference turned into an image is a - * separate billed /media call. Callers fetch bytes only for what they show. - */ - async fetchGooglePhotoRefs( - placeId: string, - apiKey: string, - cap: number, - ): Promise<{ name: string; attribution: string | null }[]> { - if (!isGooglePlaceId(placeId) || cap < 1) return []; - try { - const res = await this.google( - `https://places.googleapis.com/v1/places/${placeId}`, - `fetchGooglePhotoRefs(${placeId})`, - { headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' } }, - ); - if (!res.ok) return []; - const data = (await res.json()) as GooglePlaceDetails; - return (data.photos ?? []).slice(0, cap).map((photo) => ({ - name: photo.name, - attribution: photo.authorAttributions?.[0]?.displayName || null, - })); - } catch { - return []; - } - } - - /** Image bytes for one photo reference. Null on any miss; the caller skips it. */ - async fetchGooglePhotoBytes(photoName: string, apiKey: string, maxHeightPx = 400): Promise { - try { - const res = await this.google( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=${maxHeightPx}`, - `fetchGooglePhotoBytes(${photoName})`, - { headers: { 'X-Goog-Api-Key': apiKey } }, - ); - if (!res.ok) return null; - const bytes = Buffer.from(await res.arrayBuffer()); - return bytes.length ? bytes : null; - } catch { - return null; - } - } - - /** - * Google's editorial summary, on its own. - * - * getPlaceDetailsExpanded would also return this, but its field mask includes - * `reviews`, which moves the call into the Enterprise SKU. Enrichment only - * wants the sentence, so it asks for the sentence. - */ - async fetchEditorialSummary(placeId: string, apiKey: string, lang?: string): Promise { - if (!isGooglePlaceId(placeId)) return null; - try { - const res = await this.google( - `https://places.googleapis.com/v1/places/${placeId}?languageCode=${toApiLang(lang)}`, - `fetchEditorialSummary(${placeId})`, - { headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'editorialSummary' } }, - ); - if (!res.ok) return null; - const data = (await res.json()) as GooglePlaceDetails; - return data.editorialSummary?.text?.trim() || null; - } catch { - return null; - } - } - // ── Search places (Google or Nominatim fallback) ─────────────────────────── async searchPlaces( @@ -2256,49 +2063,9 @@ export class MapsService { 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. - // - // Clamped, like the nearby path above: Google caps the circle at 50 km and - // answers a wider one with a 400 that this method throws, so a trip spread - // across a hundred kilometres would turn an ordinary search into an error - // toast. The client keeps its own ceiling; this one is here because the - // radius arrives over the wire and the schema cannot know Google's limit. - if (locationBias) { - searchBody.locationBias = { - circle: { - center: { latitude: locationBias.lat, longitude: locationBias.lng }, - radius: Math.min(50000, Math.max(1, locationBias.radius ?? 50000)), - }, - }; - } - - const response = await this.google('https://places.googleapis.com/v1/places:searchText', 'searchText', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK, - }, - body: JSON.stringify(searchBody), - }); - - const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } }; - - if (!response.ok) { - logKeyFailure('searchText', response.status, userId, keySource); - const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; - err.status = response.status; - throw err; - } - - // A place that has shut down for good is never the answer to "where should we - // go" (#1341). Temporarily closed stays: a restaurant on holiday next month is - // still worth planning around. Anything without the field is a non-business - // result (a park, a viewpoint) and is kept. - const places = (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord); - + // Google in its slot: the index and OpenStreetMap came back empty, or this + // search was sent to Google alone. Its errors carry Google's own status. + const places = await this.googlePlaces.provider({ key: apiKey, source: keySource, userId }).searchText(query, lang, locationBias); return { places, source: 'google' }; } @@ -2351,22 +2118,8 @@ export class MapsService { } if (keyed?.id === 'google') { - const response = await this.google('https://places.googleapis.com/v1/places:searchNearby', 'searchNearby', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': keyed.key, 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK }, - body: JSON.stringify({ - maxResultCount: limit, - rankPreference: 'DISTANCE', - languageCode: lang, - locationRestriction: { circle: { center: { latitude: origin.lat, longitude: origin.lng }, radius } }, - }), - }); - const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } }; - if (!response.ok) { - logKeyFailure('searchNearby', response.status, userId, keyed.source); - throw Object.assign(new Error(data.error?.message || 'Google Places API error'), { status: response.status }); - } - const places = (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord); + const google = this.googlePlaces.provider({ key: keyed.key, source: keyed.source, userId }); + const places = await google.searchNearby(origin, radius, limit, lang); return { places: nearestFirst(places, origin, limit), source: 'google' }; } @@ -2486,53 +2239,8 @@ export class MapsService { return this.autocompleteNominatim(input, lang); } - const body: Record = { - input, - languageCode: toApiLang(lang), - }; - // With a session token Google bills the whole search as one autocomplete - // session instead of charging each keystroke; the details call that closes - // the session carries the same token. - if (sessionToken) body.sessionToken = sessionToken; - 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 this.google('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) { - logKeyFailure('autocomplete', response.status, userId, keySource); - 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 || '', - })); - + const google = this.googlePlaces.provider({ key: apiKey, source: keySource, userId }); + const suggestions = await google.autocomplete(input, lang, locationBias, sessionToken); return { suggestions, source: 'google' }; } @@ -2701,57 +2409,11 @@ export class MapsService { const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 0); if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: cachedDetails(cached.payload_json) }; - // Closes the autocomplete session this lookup belongs to, so Google bills - // the search once instead of per keystroke. A cache hit above never reaches - // here, which is billing-neutral: an unclosed session is charged as a plain - // autocomplete session. - const sessionParam = sessionToken ? `&sessionToken=${encodeURIComponent(sessionToken)}` : ''; - const response = await this.google( - `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}${sessionParam}`, - `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 || '', - // `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian). - lat: data.location?.latitude ?? null, - lng: data.location?.longitude ?? null, - rating: data.rating || null, - rating_count: data.userRatingCount || null, - website: normalizePlaceWebsite(data.websiteUri), - 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(), - }; + // The details call closes the autocomplete session this lookup belongs to, + // so Google bills the search once instead of per keystroke. A cache hit + // above never reaches here, which is billing-neutral: an unclosed session + // is charged as a plain autocomplete session. + const place = await this.googlePlaces.provider({ key: apiKey, source: null, userId }).placeDetails(placeId, lang, sessionToken); try { await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: Date.now() }); @@ -2831,55 +2493,7 @@ export class MapsService { if (cached) return { place: cachedDetails(cached.payload_json) }; } - const response = await this.google( - `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 || '', - // `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian). - lat: data.location?.latitude ?? null, - lng: data.location?.longitude ?? null, - rating: data.rating || null, - rating_count: data.userRatingCount || null, - website: normalizePlaceWebsite(data.websiteUri), - 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(), - }; + const place = await this.googlePlaces.provider({ key: apiKey, source: null, userId }).placeDetailsExpanded(placeId, lang); try { await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 1, payload_json: JSON.stringify(place), fetched_at: Date.now() }); @@ -2973,56 +2587,11 @@ export class MapsService { // flag providerFailed on the way out. const fetchGooglePhoto = async (): Promise<{ attribution: string | null } | null> => { if (!apiKey) return null; + const outcome = await this.googlePlaces.firstPhoto(placeId, apiKey); + if (outcome.kind === 'failed') providerFailed = true; + if (outcome.kind !== 'photo') return null; - // Fetch details to get the photo name - const detailsRes = await this.google( - `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 this.google( - `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 this.photoCache.put(placeId, bytes, attribution); + const cached = await this.photoCache.put(placeId, outcome.bytes, outcome.attribution); // Persist stable proxy URL to database try { @@ -3031,7 +2600,7 @@ export class MapsService { console.error('Failed to persist photo URL to database:', dbErr); } - return { attribution }; + return { attribution: outcome.attribution }; }; // Prefer the Google photo (higher quality); if Google yields nothing, fall diff --git a/server/src/nest/maps/providers/google-places.provider.ts b/server/src/nest/maps/providers/google-places.provider.ts new file mode 100644 index 000000000..a4e4661b5 --- /dev/null +++ b/server/src/nest/maps/providers/google-places.provider.ts @@ -0,0 +1,525 @@ +/** + * Google Places (New) behind the keyed places-provider seam. + * + * Two halves. `GooglePlacesClient` is the injectable transport: every request to + * places.googleapis.com goes through its `fetch`, which counts the call against + * the admin's daily ceiling (#1582), honours PLACES_API_BASE, sends the Referer + * an HTTP-referrer-restricted key needs and puts a default deadline on the call. + * `GooglePlacesProvider` is that transport bound to one credential for one + * request, the shape AmapPlacesProvider has, and answers in the normalised place + * shape the rest of TREK reads. + * + * MapsService decides WHEN Google is asked (after the index and OpenStreetMap, + * or alone under the admin's "Google only" switch) and caches what it answers; + * this file only knows how to ask. It must never import MapsService: the + * orchestrator depends on its providers, not the other way round + * (lint:boundaries holds that). + */ +import { Injectable } from '@nestjs/common'; +import { normalizePlaceWebsite } from '@trek/shared'; +import { readEnv, getAppUrl } from '../../../app-config'; +import type { ApiKeySource } from '../../settings/instance-api-keys'; +import { GoogleQuotaService } from '../../google-quota/google-quota.service'; +import { + googleFtidFromMapsUrl, + isGooglePlaceId, + normalizeOpeningPeriods, + normalizeSpecialDays, + toApiLang, + type GoogleOpeningHours, +} from '../maps.helpers'; +import { SEARCH_TEXT_FIELD_MASK } from './google-places.constants'; +import type { + PlacesProvider, + ProviderCredential, + ProviderPlace, + ProviderSuggestion, + SearchBias, + ViewportBias, +} from './places-provider'; + +// ── Google API call counter ─────────────────────────────────────────────────── + +let googleApiCallCount = 0; + +/** The upstream every Places call is written against. */ +const PLACES_UPSTREAM = 'https://places.googleapis.com'; + +/** + * Sends the call somewhere else when PLACES_API_BASE is set. + * + * The Places endpoints below all spell out the upstream host, so an install + * that wants these calls to leave through something of its own — an egress proxy, + * a cache, a gateway holding the key — has no way to say so today. One variable, + * substituted at the one place every call funnels through. + * + * Path and query are untouched, so the replacement has to speak the same API. + * Unset, which is every install today, the string is returned as it came in. + */ +function placesEndpoint(endpoint: string): string { + const base = readEnv().maps.placesApiBase; + if (!base || !endpoint.startsWith(PLACES_UPSTREAM)) return endpoint; + // The character before the run is matched and written straight back. A bare + // /\/+$/ restarts at every slash of a base that does not end in one, reading + // the rest of the run again from each of them. + return base.replace(/([^/]|^)\/+$/, '$1') + endpoint.slice(PLACES_UPSTREAM.length); +} + +/** + * Says which of the three credentials Google rejected, never which value. + * + * The response body Google sends ("The caller does not have permission") is + * identical whichever key was used, so without this line a report of "works for + * the admin, fails for everyone else" cannot be told apart from a genuinely + * broken key. + */ +function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void { + console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`); +} + +/** Ceiling for one Google Places call. Generous — the photo download is the slow one. */ +const GOOGLE_FETCH_TIMEOUT_MS = 20000; + +function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise { + const endpoint = placesEndpoint(rawEndpoint); + googleApiCallCount++; + console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); + const referer = readEnv().app.appUrl ? getAppUrl() : undefined; + return fetch(endpoint, { + ...init, + // A default ceiling here rather than at each of the call sites, none of + // which passed one: a hung upstream held the request handler open for as + // long as it liked. A caller that needs longer still wins, it only has to + // say so. + signal: init?.signal ?? AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS), + headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record) ?? {}) }, + }); +} + +// ── Wire shapes ────────────────────────────────────────────────────────────── + +interface GooglePlaceResult { + id: string; + displayName?: { text: string }; + /** OPERATIONAL | CLOSED_TEMPORARILY | CLOSED_PERMANENTLY. Absent on non-business results. */ + businessStatus?: 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 }[] }[]; +} + +/** A Google place as the record every search answers with. */ +function googlePlaceRecord(p: GooglePlaceResult): Record { + return { + google_place_id: p.id, + google_ftid: googleFtidFromMapsUrl(p.googleMapsUri), + name: p.displayName?.text || '', + address: p.formattedAddress || '', + // `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian). + lat: p.location?.latitude ?? null, + lng: p.location?.longitude ?? null, + rating: p.rating || null, + website: normalizePlaceWebsite(p.websiteUri), + phone: p.nationalPhoneNumber || null, + types: p.types || [], + source: 'google', + }; +} + +/** A place that has shut down for good is never the answer to "where should we go" (#1341). */ +const isOpenGooglePlace = (p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY'; + +const DETAILS_FIELD_MASK = + 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri'; + +/** A Google error answer as the Error the callers have always thrown: Google's message, Google's status. */ +function googleError(data: { error?: { message?: string } }, fallback: string, status: number): Error & { status: number } { + const err = new Error(data.error?.message || fallback) as Error & { status: number }; + err.status = status; + return err; +} + +/** The details record both tiers share; the expanded tier adds the summary and reviews. */ +function detailsRecord(data: GooglePlaceDetails): Record { + return { + google_place_id: data.id, + google_ftid: googleFtidFromMapsUrl(data.googleMapsUri), + name: data.displayName?.text || '', + address: data.formattedAddress || '', + // `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian). + lat: data.location?.latitude ?? null, + lng: data.location?.longitude ?? null, + rating: data.rating || null, + rating_count: data.userRatingCount || null, + website: normalizePlaceWebsite(data.websiteUri), + 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, + }; +} + +/** What a photo lookup for one place came back with. */ +export type GooglePhotoOutcome = + | { kind: 'photo'; bytes: Buffer; attribution: string | null } + /** The place has no photo at Google: remembered as a plain miss. */ + | { kind: 'none' } + /** Google refused or the download broke: remembered only briefly. */ + | { kind: 'failed' }; + +// ── Transport ──────────────────────────────────────────────────────────────── + +@Injectable() +export class GooglePlacesClient { + constructor(private readonly googleQuota: GoogleQuotaService) {} + + /** Every call to Google goes through here, so the admin's daily ceiling sees it (#1582). */ + async fetch(endpoint: string, label: string, init?: RequestInit): Promise { + await this.googleQuota.record(); + return googleFetch(endpoint, label, init); + } + + /** The provider for one request's credential. Cheap: it holds the key and this client. */ + provider(credential: ProviderCredential): GooglePlacesProvider { + return new GooglePlacesProvider(credential, this); + } + + /** + * Photo references for a Google place, capped by the caller. + * + * Split out from the bytes download on purpose: this is one billed Details + * call for the whole strip, while every reference turned into an image is a + * separate billed /media call. Callers fetch bytes only for what they show. + */ + async fetchGooglePhotoRefs(placeId: string, apiKey: string, cap: number): Promise<{ name: string; attribution: string | null }[]> { + if (!isGooglePlaceId(placeId) || cap < 1) return []; + try { + const res = await this.fetch(`https://places.googleapis.com/v1/places/${placeId}`, `fetchGooglePhotoRefs(${placeId})`, { + headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' }, + }); + if (!res.ok) return []; + const data = (await res.json()) as GooglePlaceDetails; + return (data.photos ?? []).slice(0, cap).map((photo) => ({ + name: photo.name, + attribution: photo.authorAttributions?.[0]?.displayName || null, + })); + } catch { + return []; + } + } + + /** Image bytes for one photo reference. Null on any miss; the caller skips it. */ + async fetchGooglePhotoBytes(photoName: string, apiKey: string, maxHeightPx = 400): Promise { + try { + const res = await this.fetch( + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=${maxHeightPx}`, + `fetchGooglePhotoBytes(${photoName})`, + { headers: { 'X-Goog-Api-Key': apiKey } }, + ); + if (!res.ok) return null; + const bytes = Buffer.from(await res.arrayBuffer()); + return bytes.length ? bytes : null; + } catch { + return null; + } + } + + /** + * Google's editorial summary, on its own. + * + * The expanded details would also return this, but that field mask includes + * `reviews`, which moves the call into the Enterprise SKU. Enrichment only + * wants the sentence, so it asks for the sentence. + */ + async fetchEditorialSummary(placeId: string, apiKey: string, lang?: string): Promise { + if (!isGooglePlaceId(placeId)) return null; + try { + const res = await this.fetch( + `https://places.googleapis.com/v1/places/${placeId}?languageCode=${toApiLang(lang)}`, + `fetchEditorialSummary(${placeId})`, + { headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'editorialSummary' } }, + ); + if (!res.ok) return null; + const data = (await res.json()) as GooglePlaceDetails; + return data.editorialSummary?.text?.trim() || null; + } catch { + return null; + } + } + + /** + * The first photo of a place, bytes and credit, for the marker image. + * + * Two billed calls: the details call that names the photo and the media + * download. The outcome tells a place with no photo apart from a call that + * went wrong, because the negative cache keeps the first for a day and the + * second for minutes. Throws only when the transport itself does. + */ + async firstPhoto(placeId: string, apiKey: string): Promise { + // Fetch details to get the photo name + const detailsRes = await this.fetch(`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)); + return { kind: 'failed' }; + } + let details: GooglePlaceDetails & { error?: { message?: string } }; + try { + details = body ? JSON.parse(body) : { photos: [] }; + } catch { + return { kind: 'failed' }; + } + if (!details.photos?.length) return { kind: 'none' }; + + const photo = details.photos[0]; + const photoName = photo.name; + const attribution = photo.authorAttributions?.[0]?.displayName || null; + + // Fetch actual image bytes + const mediaRes = await this.fetch( + `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) return { kind: 'failed' }; + + const bytes = Buffer.from(await mediaRes.arrayBuffer()); + if (!bytes.length) return { kind: 'failed' }; + return { kind: 'photo', bytes, attribution }; + } +} + +// ── Provider ───────────────────────────────────────────────────────────────── + +export class GooglePlacesProvider implements PlacesProvider { + readonly id = 'google' as const; + + constructor( + private readonly credential: ProviderCredential, + private readonly client: GooglePlacesClient, + ) {} + + async searchText(query: string, lang?: string, bias?: SearchBias): Promise { + 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. + // + // Clamped, like the nearby path: Google caps the circle at 50 km and answers + // a wider one with a 400 that this method throws, so a trip spread across a + // hundred kilometres would turn an ordinary search into an error toast. The + // client keeps its own ceiling; this one is here because the radius arrives + // over the wire and the schema cannot know Google's limit. + if (bias) { + searchBody.locationBias = { + circle: { + center: { latitude: bias.lat, longitude: bias.lng }, + radius: Math.min(50000, Math.max(1, bias.radius ?? 50000)), + }, + }; + } + + const response = await this.client.fetch('https://places.googleapis.com/v1/places:searchText', 'searchText', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': this.credential.key, + 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK, + }, + body: JSON.stringify(searchBody), + }); + + const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } }; + + if (!response.ok) { + logKeyFailure('searchText', response.status, this.credential.userId, this.credential.source); + throw googleError(data, 'Google Places API error', response.status); + } + + // A place that has shut down for good is never the answer to "where should we + // go" (#1341). Temporarily closed stays: a restaurant on holiday next month is + // still worth planning around. Anything without the field is a non-business + // result (a park, a viewpoint) and is kept. + return (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord); + } + + /** Places of any kind around a point, nearest first by Google's own ranking (#976). */ + async searchNearby(origin: { lat: number; lng: number }, radius: number, limit: number, lang: string): Promise { + const response = await this.client.fetch('https://places.googleapis.com/v1/places:searchNearby', 'searchNearby', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': this.credential.key, 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK }, + body: JSON.stringify({ + maxResultCount: limit, + rankPreference: 'DISTANCE', + languageCode: lang, + locationRestriction: { circle: { center: { latitude: origin.lat, longitude: origin.lng }, radius } }, + }), + }); + const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } }; + if (!response.ok) { + logKeyFailure('searchNearby', response.status, this.credential.userId, this.credential.source); + throw Object.assign(new Error(data.error?.message || 'Google Places API error'), { status: response.status }); + } + return (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord); + } + + async autocomplete(input: string, lang?: string, bias?: ViewportBias, sessionToken?: string): Promise { + const body: Record = { + input, + languageCode: toApiLang(lang), + }; + // With a session token Google bills the whole search as one autocomplete + // session instead of charging each keystroke; the details call that closes + // the session carries the same token. + if (sessionToken) body.sessionToken = sessionToken; + if (bias) { + body.locationBias = { + rectangle: { + low: { latitude: bias.low.lat, longitude: bias.low.lng }, + high: { latitude: bias.high.lat, longitude: bias.high.lng }, + }, + }; + } + + const response = await this.client.fetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': this.credential.key, + }, + body: JSON.stringify(body), + }); + + const data = (await response.json()) as { + suggestions?: GoogleAutocompleteSuggestion[]; + error?: { message?: string }; + }; + + if (!response.ok) { + logKeyFailure('autocomplete', response.status, this.credential.userId, this.credential.source); + throw googleError(data, 'Google Places Autocomplete error', response.status); + } + + return (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 || '', + })); + } + + /** + * The lean details record. A Google error is thrown with Google's message and + * status, never answered as a miss: unlike search, a details call names one + * place the caller already holds. + */ + async placeDetails(placeId: string, lang?: string, sessionToken?: string): Promise { + const langKey = toApiLang(lang); + // Closes the autocomplete session this lookup belongs to, so Google bills + // the search once instead of per keystroke. + const sessionParam = sessionToken ? `&sessionToken=${encodeURIComponent(sessionToken)}` : ''; + const response = await this.client.fetch( + `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}${sessionParam}`, + `getPlaceDetails(${placeId})`, + { + method: 'GET', + headers: { + 'X-Goog-Api-Key': this.credential.key, + 'X-Goog-FieldMask': DETAILS_FIELD_MASK, + }, + }, + ); + + const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } }; + if (!response.ok) throw googleError(data, 'Google Places API error', response.status); + + return { + ...detailsRecord(data), + summary: null, + reviews: [], + source: 'google' as const, + cached_at: Date.now(), + }; + } + + /** The details record with the editorial summary and up to five reviews (the Enterprise SKU). */ + async placeDetailsExpanded(placeId: string, lang?: string): Promise { + const langKey = toApiLang(lang); + const response = await this.client.fetch( + `https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, + `getPlaceDetailsExpanded(${placeId})`, + { + method: 'GET', + headers: { + 'X-Goog-Api-Key': this.credential.key, + 'X-Goog-FieldMask': `${DETAILS_FIELD_MASK},reviews,editorialSummary`, + }, + }, + ); + + const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } }; + if (!response.ok) throw googleError(data, 'Google Places API error', response.status); + + return { + ...detailsRecord(data), + 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(), + }; + } + + /** + * Google is never asked to reverse-geocode: the Places API bills for it and + * Nominatim (or Amap inside China) answers the question for free. Null is the + * interface's "nothing for this point", which sends the caller on. + */ + async reverse(): Promise<{ name: string | null; address: string | null } | null> { + return null; + } +} diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index 859994b5f..0d47c376d 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -27,6 +27,7 @@ import { } from '../maps/maps.service'; import { buildOsmDetails, isGooglePlaceId, parseWikipediaTag, rankCommonsCandidates, toWikiLang } from '../maps/maps.helpers'; import { trekPlacesById } from '../maps/trek-places.client'; +import { GooglePlacesClient } from '../maps/providers/google-places.provider'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; import { readAppSetting } from '../common/app-settings.registry'; @@ -323,6 +324,7 @@ export class PlaceEnrichmentService { @InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository, private readonly maps: MapsService, private readonly photoCache: PlacePhotoCacheService, + private readonly googlePlaces: GooglePlacesClient, ) {} /** @@ -469,7 +471,7 @@ export class PlaceEnrichmentService { // Google's listing is one call for the whole strip and knows nothing about // the free sources, so it runs alongside them rather than in the ladder. const googlePending = wantsGoogle - ? this.maps.fetchGooglePhotoRefs(placeId, apiKey!, GOOGLE_CAP) + ? this.googlePlaces.fetchGooglePhotoRefs(placeId, apiKey!, GOOGLE_CAP) : Promise.resolve([] as { name: string; attribution: string | null }[]); // The free ladder, in order of how much anyone vouched that the picture @@ -543,7 +545,7 @@ export class PlaceEnrichmentService { sourceUrl: null, source: 'google' as const, }, - fetchBytes: () => this.maps.fetchGooglePhotoBytes(ref.name, apiKey!), + fetchBytes: () => this.googlePlaces.fetchGooglePhotoBytes(ref.name, apiKey!), })), ...ranked.map((pick) => ({ identity: `commons:${pick.pageId ?? pick.photoUrl}`, @@ -712,7 +714,7 @@ export class PlaceEnrichmentService { const apiKey = await this.maps.getMapsKey(userId); if (apiKey && !(await this.maps.detailsDisabled()) && isGooglePlaceId(placeId)) { - const summary = await this.maps.fetchEditorialSummary(placeId, apiKey, req.lang); + const summary = await this.googlePlaces.fetchEditorialSummary(placeId, apiKey, req.lang); if (summary) { return { text: summary, diff --git a/server/tests/helpers/maps-service.ts b/server/tests/helpers/maps-service.ts new file mode 100644 index 000000000..5cb3621b4 --- /dev/null +++ b/server/tests/helpers/maps-service.ts @@ -0,0 +1,45 @@ +import { MapsService } from '../../src/nest/maps/maps.service'; +import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider'; +import type { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; +import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository'; +import type { UsersRepository } from '../../src/db/repositories/Users.repository'; +import type { PlaceDetailsCacheRepository } from '../../src/db/repositories/PlaceDetailsCache.repository'; +import type { PlacesRepository } from '../../src/db/repositories/Places.repository'; +import type { GoogleQuotaService } from '../../src/nest/google-quota/google-quota.service'; +import { noGoogleQuota } from './google-quota'; + +/** MapsService and the outbound clients it was wired with, for tests that reach a client directly. */ +export interface MapsParts { + svc: MapsService; + google: GooglePlacesClient; +} + +/** + * MapsService built by hand the way MapsModule wires it: the repositories and + * caches a test hands in, and real clients over the global fetch the test + * stubs. The argument order is MapsService's old six-argument constructor, so + * a hand-built service reads the same as it did before the providers split out. + */ +export function buildMapsParts( + photoCache: PlacePhotoCacheService, + appSettings: AppSettingsRepository, + users: UsersRepository, + placeDetailsCache: PlaceDetailsCacheRepository, + places: PlacesRepository, + googleQuota: GoogleQuotaService = noGoogleQuota, +): MapsParts { + const google = new GooglePlacesClient(googleQuota); + const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google); + return { svc, google }; +} + +export function buildMapsService( + photoCache: PlacePhotoCacheService, + appSettings: AppSettingsRepository, + users: UsersRepository, + placeDetailsCache: PlaceDetailsCacheRepository, + places: PlacesRepository, + googleQuota: GoogleQuotaService = noGoogleQuota, +): MapsService { + return buildMapsParts(photoCache, appSettings, users, placeDetailsCache, places, googleQuota).svc; +} diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index a1c51901b..b7df1233c 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -28,7 +28,7 @@ import { WeatherMcp } from '../../src/nest/weather/weather.mcp'; import { WeatherService } from '../../src/nest/weather/weather.service'; import { AirportsMcp } from '../../src/nest/airports/airports.mcp'; import { AuthMcp } from '../../src/nest/auth/auth.mcp'; -import { MapsService } from '../../src/nest/maps/maps.service'; +import { buildMapsService } from './maps-service'; import { NotificationsMcp } from '../../src/nest/notifications/notifications.mcp'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; import { PackingMcp } from '../../src/nest/packing/packing.mcp'; @@ -243,7 +243,7 @@ export async function createMcpTestRegistry(): Promise { // Exactly one instance, shared by maps, places and share: its stampede guard // and its on-disk set only work if all three readers see the same maps. const placePhotoCache = new PlacePhotoCacheService(makeStorageFixture('photos/google/').storage, await createTestGooglePlacePhotoMetaRepo(db), await createTestPlacesRepo(db), await createTestCollectionPlacesRepo(db)); - const mapsService = new MapsService(placePhotoCache, appSettings, usersRepo, await createTestPlaceDetailsCacheRepo(db), await createTestPlacesRepo(db), noGoogleQuota); + const mapsService = buildMapsService(placePhotoCache, appSettings, usersRepo, await createTestPlaceDetailsCacheRepo(db), await createTestPlacesRepo(db), noGoogleQuota); const journeyDomain = new JourneyDomainService( realtimeService, new TrekPhotoRegistrationService(mcpOrm.repo(TrekPhotos), mcpOrm.repo(TripPhotos), await createTestJourneyPhotosRepo(db)), await createTestUnitOfWork(db), // Plan 3g Task 1 — the constructor-ripple fix (R9): four journey-owned diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 6bef8580d..aac7940b1 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -33,7 +33,7 @@ import { TripsService } from '../../src/nest/trips/trips.service'; import { PlacesService } from '../../src/nest/places/places.service'; import { CollectionsService } from '../../src/nest/collections/collections.service'; import { AtlasService } from '../../src/nest/atlas/atlas.service'; -import { MapsService } from '../../src/nest/maps/maps.service'; +import { buildMapsService } from './maps-service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { AuditService } from '../../src/nest/audit/audit.service'; import { createTestAddonsService } from './test-addons'; @@ -299,7 +299,7 @@ export async function createPluginRpcHostParts( ); // After it: deleting a place cancels the nights booked at it through this one. const places = new PlacesService( - permissions, realtime, new MapsService(photoCache, appSettings, usersRepo, await createTestPlaceDetailsCacheRepo(db), await createTestPlacesRepo(db), noGoogleQuota), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations, await createTestUnitOfWork(db), + permissions, realtime, buildMapsService(photoCache, appSettings, usersRepo, await createTestPlaceDetailsCacheRepo(db), await createTestPlacesRepo(db), noGoogleQuota), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations, await createTestUnitOfWork(db), await createTestPlacesRepo(db), await createTestTagsRepo(db), await createTestPlaceRatingsRepo(db), diff --git a/server/tests/unit/nest/maps.amap.test.ts b/server/tests/unit/nest/maps.amap.test.ts index e2f59a1e3..e9b944958 100644 --- a/server/tests/unit/nest/maps.amap.test.ts +++ b/server/tests/unit/nest/maps.amap.test.ts @@ -61,6 +61,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { trekPlacesSearch } from '../../../src/nest/maps/trek-places.client'; import { AmapPlacesProvider, @@ -127,7 +128,7 @@ const photoCacheStub = { serveKey: vi.fn(() => null), } as unknown as PlacePhotoCacheService; -const svc = new MapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); +const svc = buildMapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); /** A provider over a fixed key, which is all these cases need. */ function provider(tips = new AmapTipStash()): AmapPlacesProvider { diff --git a/server/tests/unit/nest/maps.area.test.ts b/server/tests/unit/nest/maps.area.test.ts index 2e8f4742e..5e8bd13e4 100644 --- a/server/tests/unit/nest/maps.area.test.ts +++ b/server/tests/unit/nest/maps.area.test.ts @@ -18,6 +18,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -59,7 +60,7 @@ afterEach(() => { function make(enabled = true) { if (enabled) delete process.env.TREK_PLACES_ENABLED; else process.env.TREK_PLACES_ENABLED = 'false'; - return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + return buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); } beforeEach(() => { diff --git a/server/tests/unit/nest/maps.autocomplete.test.ts b/server/tests/unit/nest/maps.autocomplete.test.ts index de0508126..4d33772f6 100644 --- a/server/tests/unit/nest/maps.autocomplete.test.ts +++ b/server/tests/unit/nest/maps.autocomplete.test.ts @@ -24,6 +24,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -94,7 +95,7 @@ function make(enabled = true, rows: Record = {}) { const appSettings = { getValue: async (key: string) => (rows[key] !== undefined ? rows[key] : null), } as unknown as AppSettingsRepository; - return new MapsService({} as PlacePhotoCacheService, appSettings, noUsers, {} as never, {} as never, noGoogleQuota); + return buildMapsService({} as PlacePhotoCacheService, appSettings, noUsers, {} as never, {} as never, noGoogleQuota); } /** Google's autocomplete envelope, which is not the shape its text search answers in. */ diff --git a/server/tests/unit/nest/maps.curated-images.test.ts b/server/tests/unit/nest/maps.curated-images.test.ts index eab633df4..3993025ff 100644 --- a/server/tests/unit/nest/maps.curated-images.test.ts +++ b/server/tests/unit/nest/maps.curated-images.test.ts @@ -23,10 +23,10 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ SsrfBlockedError: class extends Error {}, })); -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { noGoogleQuota } from '../../helpers/google-quota'; -const svcOf = () => new MapsService({} as never, {} as never, {} as never, {} as never, {} as never, noGoogleQuota); +const svcOf = () => buildMapsService({} as never, {} as never, {} as never, {} as never, {} as never, noGoogleQuota); const filePage = (over: Record = {}) => ({ pageid: 4711, diff --git a/server/tests/unit/nest/maps.geocode.test.ts b/server/tests/unit/nest/maps.geocode.test.ts index ff119412c..5a42e9726 100644 --- a/server/tests/unit/nest/maps.geocode.test.ts +++ b/server/tests/unit/nest/maps.geocode.test.ts @@ -31,6 +31,7 @@ vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -72,7 +73,7 @@ afterEach(() => { function make(enabled = true) { if (enabled) delete process.env.TREK_PLACES_ENABLED; else process.env.TREK_PLACES_ENABLED = 'false'; - return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + return buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); } beforeEach(() => { diff --git a/server/tests/unit/nest/maps.gers-details.test.ts b/server/tests/unit/nest/maps.gers-details.test.ts index 3569d15b5..c24cf7629 100644 --- a/server/tests/unit/nest/maps.gers-details.test.ts +++ b/server/tests/unit/nest/maps.gers-details.test.ts @@ -18,6 +18,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -45,7 +46,7 @@ const PLACE = { }; function make(osmTags: Record | null) { - const svc = new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); vi.spyOn(svc, 'resolveOsmIdentity').mockResolvedValue( osmTags ? { tags: osmTags, osmUrl: 'https://www.openstreetmap.org/node/1', matchedName: "L'Osteria" } : null, ); @@ -110,7 +111,7 @@ describe('MapsService.getPlaceDetails for a gers: id', () => { // only adds what OSM knows about the same building. Letting its failure // through would turn a working answer into an error for the one user whose // details request happened to land while Overpass was unreachable. - const svc = new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); vi.spyOn(svc, 'resolveOsmIdentity').mockRejectedValue(new Error('overpass down')); mockById.mockResolvedValue({ ...PLACE, hours: { osm: 'Mo-Su 12:00-22:00' } }); diff --git a/server/tests/unit/nest/maps.google-provider.test.ts b/server/tests/unit/nest/maps.google-provider.test.ts new file mode 100644 index 000000000..b207e5c8a --- /dev/null +++ b/server/tests/unit/nest/maps.google-provider.test.ts @@ -0,0 +1,65 @@ +/** + * GooglePlacesProvider / GooglePlacesClient: the Google half of the keyed + * places-provider seam, on its own. The search, details and photo flows are + * covered through MapsService in maps.service.test.ts; this pins what the + * provider adds as a unit: the interface's reverse answer, the counted + * transport and the credential each request carries. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; +import type { GoogleQuotaService } from '../../../src/nest/google-quota/google-quota.service'; + +const quota = () => ({ exhausted: vi.fn(async () => false), record: vi.fn(async () => {}) }); + +function client(q = quota()) { + return { q, client: new GooglePlacesClient(q as unknown as GoogleQuotaService) }; +} + +const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.PLACES_API_BASE; +}); + +describe('GooglePlacesProvider', () => { + it('GPROV-001: is never asked to reverse-geocode, and says so with the interface miss', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { client: c } = client(); + expect(await c.provider({ key: 'k', source: 'env', userId: 1 }).reverse()).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('GPROV-002: a text search sends the credential it was built with and counts the call', async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ok({ places: [{ id: 'ChIJ1', displayName: { text: 'Dom' } }] })); + vi.stubGlobal('fetch', fetchMock); + const { q, client: c } = client(); + const places = await c.provider({ key: 'secret', source: 'instance', userId: 7 }).searchText('dom', 'de', { lat: 50, lng: 7, radius: 900000 }); + expect(places).toEqual([expect.objectContaining({ google_place_id: 'ChIJ1', name: 'Dom', source: 'google' })]); + expect(q.record).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://places.googleapis.com/v1/places:searchText'); + expect((init?.headers as Record)['X-Goog-Api-Key']).toBe('secret'); + // Google caps the bias circle at 50 km and answers a wider one with a 400. + expect(JSON.parse(String(init?.body)).locationBias.circle.radius).toBe(50000); + }); + + it('GPROV-003: PLACES_API_BASE moves the call to the operator gateway, path untouched', async () => { + process.env.PLACES_API_BASE = 'https://gateway.example.test/places///'; + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ok({ editorialSummary: { text: 'Hi' } })); + vi.stubGlobal('fetch', fetchMock); + const { client: c } = client(); + expect(await c.fetchEditorialSummary('ChIJ1', 'k')).toBe('Hi'); + expect(fetchMock.mock.calls[0][0]).toBe('https://gateway.example.test/places/v1/places/ChIJ1?languageCode=en'); + }); + + it('GPROV-004: a details error keeps Google message and status', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'API key not valid' } }), { status: 403 }))); + const { client: c } = client(); + await expect(c.provider({ key: 'k', source: null, userId: 0 }).placeDetailsExpanded('ChIJ1')).rejects.toMatchObject({ + message: 'API key not valid', + status: 403, + }); + }); +}); diff --git a/server/tests/unit/nest/maps.google-quota.test.ts b/server/tests/unit/nest/maps.google-quota.test.ts index 1cf62bbe5..735c7ac1c 100644 --- a/server/tests/unit/nest/maps.google-quota.test.ts +++ b/server/tests/unit/nest/maps.google-quota.test.ts @@ -11,7 +11,8 @@ vi.mock('../../../src/db/database', () => ({ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; +import { buildMapsService } from '../../helpers/maps-service'; import { GoogleTransitProvider } from '../../../src/nest/transit/google-transit.provider'; import type { GoogleQuotaService } from '../../../src/nest/google-quota/google-quota.service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; @@ -30,7 +31,7 @@ const appSettingsStub = (settings: Record = {}) => const usersStub = { getApiKeyColumn: async () => null } as unknown as UsersRepository; const svcWith = (q: ReturnType) => - new MapsService( + buildMapsService( {} as PlacePhotoCacheService, appSettingsStub(), usersStub, @@ -65,9 +66,9 @@ describe('Google daily ceiling in MapsService', () => { it('MAPS-QUOTA-003: every Places call is counted against the day', async () => { const q = quota(false); - const svc = svcWith(q); + const google = new GooglePlacesClient(q as unknown as GoogleQuotaService); vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ editorialSummary: { text: 'Hi' } }), { status: 200 }))); - await svc.fetchEditorialSummary('ChIJ1', 'key'); + await google.fetchEditorialSummary('ChIJ1', 'key'); expect(q.record).toHaveBeenCalledTimes(1); }); }); diff --git a/server/tests/unit/nest/maps.identity.test.ts b/server/tests/unit/nest/maps.identity.test.ts index d9791f58a..24a0bae63 100644 --- a/server/tests/unit/nest/maps.identity.test.ts +++ b/server/tests/unit/nest/maps.identity.test.ts @@ -19,11 +19,11 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({ SsrfBlockedError: class extends Error {}, })); -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { toWikiLang, haversineMetres, namesOverlap } from '../../../src/nest/maps/maps.helpers'; import { noGoogleQuota } from '../../helpers/google-quota'; -const svcOf = () => new MapsService({} as never, {} as never, {} as never, {} as never, {} as never, noGoogleQuota); +const svcOf = () => buildMapsService({} as never, {} as never, {} as never, {} as never, {} as never, noGoogleQuota); // The Brandenburg Gate and the underground station named after it, 250m apart. const GATE = { lat: 52.5163, lng: 13.3777 }; diff --git a/server/tests/unit/nest/maps.nearby.test.ts b/server/tests/unit/nest/maps.nearby.test.ts index c19552bb3..aff6a3694 100644 --- a/server/tests/unit/nest/maps.nearby.test.ts +++ b/server/tests/unit/nest/maps.nearby.test.ts @@ -19,6 +19,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { nearbyCacheKey, nearbyOverpassQuery, @@ -64,7 +65,7 @@ function make(opts: { index?: boolean; google?: boolean } = {}) { // No app_settings row and no per-user key: every setting reads as absent. const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository; const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository; - const svc = new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + const svc = buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); vi.spyOn(svc, 'keyedProvider').mockResolvedValue( opts.google ? ({ id: 'google', key: 'test-key', source: 'user-row' } as Awaited>) : null, ); diff --git a/server/tests/unit/nest/maps.pois.test.ts b/server/tests/unit/nest/maps.pois.test.ts index e75599985..985f22d14 100644 --- a/server/tests/unit/nest/maps.pois.test.ts +++ b/server/tests/unit/nest/maps.pois.test.ts @@ -26,6 +26,7 @@ vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ( vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -116,7 +117,7 @@ afterEach(() => { function make(enabled = true) { if (enabled) delete process.env.TREK_PLACES_ENABLED; else process.env.TREK_PLACES_ENABLED = 'false'; - return new MapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); + return buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota); } // Overpass is the one network call this file must never make; stubbing it is diff --git a/server/tests/unit/nest/maps.search-merge.test.ts b/server/tests/unit/nest/maps.search-merge.test.ts index 20719c807..3f4b80544 100644 --- a/server/tests/unit/nest/maps.search-merge.test.ts +++ b/server/tests/unit/nest/maps.search-merge.test.ts @@ -31,6 +31,7 @@ vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) })); import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { noGoogleQuota } from '../../helpers/google-quota'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -92,7 +93,7 @@ function make(enabled = true, rows: Record = {}) { const appSettings = { getValue: async (key: string) => (rows[key] !== undefined ? rows[key] : null), } as unknown as AppSettingsRepository; - return new MapsService({} as PlacePhotoCacheService, appSettings, noUsers, {} as never, {} as never, noGoogleQuota); + return buildMapsService({} as PlacePhotoCacheService, appSettings, noUsers, {} as never, {} as never, noGoogleQuota); } const googleAnswer = (name: string) => ({ diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 9fc02cd2b..e3ae5fcfd 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -173,6 +173,7 @@ const photoCacheStub = { } as unknown as PlacePhotoCacheService; import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service'; +import { buildMapsParts, buildMapsService } from '../../helpers/maps-service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; @@ -230,7 +231,7 @@ const placesStub = { // through a repository stub that flows into the SAME mockDbGet/mockDbRun/ // mockInstanceGet/mockProviderGet functions, so they keep firing exactly as // they did for the legacy module. -const svc = new MapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); +const { svc, google } = buildMapsParts(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); /** * Switch the TREK Places index off for one case. @@ -2757,7 +2758,7 @@ function makeSettingsRepo(row?: { value: string }) { } function settingsSvc(row?: { value: string }) { - return new MapsService(photoCacheStub, makeSettingsRepo(row).repo, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); + return buildMapsService(photoCacheStub, makeSettingsRepo(row).repo, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); } describe('kill-switch settings reads', () => { @@ -2781,7 +2782,7 @@ describe('kill-switch settings reads', () => { it('queries the matching app_settings key', async () => { const { repo: settingsRepo, getValue } = makeSettingsRepo({ value: 'true' }); - const s = new MapsService(photoCacheStub, settingsRepo, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); + const s = buildMapsService(photoCacheStub, settingsRepo, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); await s.autocompleteDisabled(); expect(getValue).toHaveBeenCalledWith('places_autocomplete_enabled'); await s.detailsDisabled(); @@ -3203,7 +3204,7 @@ describe('fetchGooglePhotoRefs (fetch stubbed)', () => { }); vi.stubGlobal('fetch', fetchMock); - const out = await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 2); + const out = await google.fetchGooglePhotoRefs('ChIJabc', 'key', 2); expect(out).toEqual([ { name: 'places/p/photos/a', attribution: 'Alice' }, { name: 'places/p/photos/b', attribution: null }, @@ -3216,21 +3217,21 @@ describe('fetchGooglePhotoRefs (fetch stubbed)', () => { it('MAPS-127: never calls Google for an id Google cannot resolve', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - expect(await svc.fetchGooglePhotoRefs('node:123', 'key', 3)).toEqual([]); - expect(await svc.fetchGooglePhotoRefs('ChIJabc~p1', 'key', 3)).toEqual([]); - expect(await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 0)).toEqual([]); + expect(await google.fetchGooglePhotoRefs('node:123', 'key', 3)).toEqual([]); + expect(await google.fetchGooglePhotoRefs('ChIJabc~p1', 'key', 3)).toEqual([]); + expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 0)).toEqual([]); expect(fetchMock).not.toHaveBeenCalled(); }); it('MAPS-128: yields nothing on an error response, a photo-less place or a throw', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })); - expect(await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); + expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); - expect(await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); + expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); - expect(await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); + expect(await google.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); }); }); @@ -3242,7 +3243,7 @@ describe('fetchGooglePhotoBytes (fetch stubbed)', () => { }); vi.stubGlobal('fetch', fetchMock); - const bytes = await svc.fetchGooglePhotoBytes('places/p/photos/a', 'key', 600); + const bytes = await google.fetchGooglePhotoBytes('places/p/photos/a', 'key', 600); expect(bytes).toBeInstanceOf(Buffer); expect(bytes!.length).toBe(3); expect(String(fetchMock.mock.calls[0][0])).toBe( @@ -3252,13 +3253,13 @@ describe('fetchGooglePhotoBytes (fetch stubbed)', () => { it('MAPS-130: returns null for an error response, an empty body or a throw', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, arrayBuffer: async () => new ArrayBuffer(0) })); - expect(await svc.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull(); + expect(await google.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(0) })); - expect(await svc.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull(); + expect(await google.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull(); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); - expect(await svc.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull(); + expect(await google.fetchGooglePhotoBytes('places/p/photos/a', 'key')).toBeNull(); }); }); @@ -3270,7 +3271,7 @@ describe('fetchEditorialSummary (fetch stubbed)', () => { }); vi.stubGlobal('fetch', fetchMock); - expect(await svc.fetchEditorialSummary('ChIJabc', 'key', 'de')).toBe('A museum in Cologne.'); + expect(await google.fetchEditorialSummary('ChIJabc', 'key', 'de')).toBe('A museum in Cologne.'); // reviews would move this into the Enterprise SKU — see the method comment. expect(fetchMock.mock.calls[0][1].headers['X-Goog-FieldMask']).toBe('editorialSummary'); expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=de'); @@ -3279,17 +3280,17 @@ describe('fetchEditorialSummary (fetch stubbed)', () => { it('MAPS-132: skips non-Google ids and swallows every miss', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - expect(await svc.fetchEditorialSummary('node:1', 'key')).toBeNull(); + expect(await google.fetchEditorialSummary('node:1', 'key')).toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })); - expect(await svc.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); + expect(await google.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); - expect(await svc.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); + expect(await google.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); - expect(await svc.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); + expect(await google.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); }); }); @@ -3481,7 +3482,7 @@ describe('readWikiIdentity', () => { describe('brandLogo', () => { // A fresh service per case: the logo cache lives on the instance, and a hit from // one case would answer the next one's question before its fetch stub ran. - const service = (): MapsService => new MapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); + const service = (): MapsService => buildMapsService(photoCacheStub, appSettingsStub, usersStub, placeDetailsCacheStub, placesStub, noGoogleQuota); const claimResponse = (file: string | null) => ({ ok: true, diff --git a/server/tests/unit/nest/place-enrichment.brand.test.ts b/server/tests/unit/nest/place-enrichment.brand.test.ts index cf0aede89..59cd82bf0 100644 --- a/server/tests/unit/nest/place-enrichment.brand.test.ts +++ b/server/tests/unit/nest/place-enrichment.brand.test.ts @@ -32,6 +32,11 @@ vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KE import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service'; import { readBrandIdentity } from '../../../src/nest/maps/maps.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; +import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; + +/** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ +type Seams = { [K in keyof T]: T[K] }; +type EnrichmentSeams = Seams & Seams; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -51,7 +56,7 @@ const CHAIN_EXTRACT = { source: 'wikipedia' as const, }; -function mapsStub(over: Partial> = {}) { +function mapsStub(over: Partial> = {}) { return { getMapsKey: vi.fn(() => null as string | null), photosDisabled: vi.fn(() => false), @@ -70,7 +75,7 @@ function mapsStub(over: Partial> = {}) { resolveOsmIdentity: vi.fn(async () => null as { tags: Record; osmUrl: string | null; matchedName: string } | null), details: vi.fn(async () => ({ place: null })), ...over, - } as unknown as MapsService; + } as unknown as EnrichmentSeams; } const cacheStub = () => @@ -84,10 +89,10 @@ function cacheRepoStub(): PlaceDetailsCacheRepository { return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository; } -const make = (maps: MapsService) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps, cacheStub()); +const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient); /** A place whose own identity is empty but that belongs to a chain. */ -const branchOfAChain = (over: Partial> = {}) => +const branchOfAChain = (over: Partial> = {}) => mapsStub({ resolveOsmIdentity: vi.fn(async () => ({ tags: BRANCH_TAGS, osmUrl: null, matchedName: "L'Osteria" })), ...over, diff --git a/server/tests/unit/nest/place-enrichment.description.test.ts b/server/tests/unit/nest/place-enrichment.description.test.ts index 84f619636..471d073fd 100644 --- a/server/tests/unit/nest/place-enrichment.description.test.ts +++ b/server/tests/unit/nest/place-enrichment.description.test.ts @@ -36,6 +36,11 @@ vi.mock('../../../src/nest/maps/trek-places.client', () => ({ import { PlaceEnrichmentService } from '../../../src/nest/place-enrichment/place-enrichment.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; +import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; + +/** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ +type Seams = { [K in keyof T]: T[K] }; +type EnrichmentSeams = Seams & Seams; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -49,7 +54,7 @@ const EXTRACT = { source: 'wikipedia' as const, }; -function mapsStub(over: Partial> = {}) { +function mapsStub(over: Partial> = {}) { return { getMapsKey: vi.fn(() => null as string | null), photosDisabled: vi.fn(() => false), @@ -69,7 +74,7 @@ function mapsStub(over: Partial> = {}) { details: vi.fn(async () => ({ place: null })), trekPlacesEnabled: vi.fn(() => true), ...over, - } as unknown as MapsService; + } as unknown as EnrichmentSeams; } const cacheStub = () => @@ -83,7 +88,7 @@ function cacheRepoStub(): PlaceDetailsCacheRepository { return { findEntry: mockFindEntry, upsertEntry: mockUpsertEntry } as unknown as PlaceDetailsCacheRepository; } -const make = (maps: MapsService) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps, cacheStub()); +const make = (maps: EnrichmentSeams) => new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cacheStub(), maps as unknown as GooglePlacesClient); beforeEach(() => { mockGetValue.mockReset(); @@ -346,7 +351,7 @@ describe('filling Google gaps from the free sources', () => { 'opening_hours': 'Mo-Su 11:30-23:00', }; - const googlePlaceAlsoInOsm = (over: Partial> = {}) => + const googlePlaceAlsoInOsm = (over: Partial> = {}) => mapsStub({ getMapsKey: vi.fn(() => 'key'), details: vi.fn(async () => ({ place: { source: 'google', rating: 4.6, rating_count: 4495 } })), diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 15ff70871..4df49a9ca 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -51,6 +51,11 @@ import { nearbyWouldMislead, } from '../../../src/nest/place-enrichment/place-enrichment.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; +import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; + +/** Every seam enrichment reaches: the maps orchestrator and the outbound clients it injects beside it. */ +type Seams = { [K in keyof T]: T[K] }; +type EnrichmentSeams = Seams & Seams; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlaceDetailsCacheRepository } from '../../../src/db/repositories/PlaceDetailsCache.repository'; import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository'; @@ -67,7 +72,7 @@ const REQ = { lat: 50.9, lng: 6.96, name: 'Museum Ludwig', placeId: 'ChIJmuseum' const OSM_REQ = { ...REQ, placeId: 'way:12345' }; /** Every MapsService seam the service touches, all inert by default. */ -function mapsStub(over: Partial> = {}) { +function mapsStub(over: Partial> = {}) { return { getMapsKey: vi.fn(() => null as string | null), photosDisabled: vi.fn(() => false), @@ -87,7 +92,7 @@ function mapsStub(over: Partial> = {}) { details: vi.fn(async () => ({ place: null })), trekPlacesEnabled: vi.fn(() => true), ...over, - } as unknown as MapsService; + } as unknown as EnrichmentSeams; } function cacheStub(over: Record = {}) { @@ -102,8 +107,8 @@ function cacheStub(over: Record = {}) { } as unknown as PlacePhotoCacheService; } -function make(maps: MapsService, cache: PlacePhotoCacheService) { - return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps, cache); +function make(maps: EnrichmentSeams, cache: PlacePhotoCacheService) { + return new PlaceEnrichmentService(cacheRepoStub(), appSettingsStub(), maps as unknown as MapsService, cache, maps as unknown as GooglePlacesClient); } let candidateSeq = 0; diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index d56aed118..17f3a12c6 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -64,6 +64,7 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { PlacesService } from '../../../src/nest/places/places.service'; import { legacyBoundIntegerText } from '../../../src/nest/common/row-id'; import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service'; @@ -135,7 +136,7 @@ const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); const placesStorageFx = makeStorageFixture(''); async function makePlacesService( - maps: MapsService = new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota), + maps: MapsService = buildMapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota), ): Promise { return new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index 4cff40d23..6dc03c4f7 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -29,7 +29,7 @@ import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.serv import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { PlacesService } from '../../../src/nest/places/places.service'; -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service'; @@ -80,7 +80,7 @@ async function makePlacesService(): Promise { return new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), - new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota), + buildMapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), storage), photoCacheStub, diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index dd47f0ada..d4c0370fd 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -55,7 +55,7 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members. import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service'; import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import { makeAccommodationsService } from '../../helpers/accommodations-service'; -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { notificationsStub } from '../../helpers/notifications'; import { accommodationsOver } from '../../helpers/accommodations-service'; @@ -153,7 +153,7 @@ beforeAll(async () => { ); placesSvc = new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), - new MapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), + buildMapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), makeStorageFixture('').storage), photoCache, new JourneyDomainService( new RealtimeService(), new TrekPhotoRegistrationService((await sharedTestOrm(testDb)).repo(TrekPhotos), (await sharedTestOrm(testDb)).repo(TripPhotos), await createTestJourneyPhotosRepo(testDb)), await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 5788d2ddb..c195bc803 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -65,6 +65,7 @@ import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-rea import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service'; import { accommodationsOver, makeAccommodationsService } from '../../helpers/accommodations-service'; import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; @@ -257,7 +258,7 @@ beforeAll(async () => { placesSvc = new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new RealtimeService(), - new MapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), + buildMapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), new RuntimeEnvService(), coversFx.storage), photoCache, diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index cb67de691..276c01633 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -31,7 +31,7 @@ import { accommodationsOver } from '../../helpers/accommodations-service'; import { isUpdateConflict } from '../../../src/nest/common/conflictResult'; import { PackingService } from '../../../src/nest/packing/packing.service'; import { PlacesService } from '../../../src/nest/places/places.service'; -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { buildMapsService } from '../../helpers/maps-service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; @@ -116,7 +116,7 @@ beforeAll(async () => { places = new PlacesService( new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, - new MapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), + buildMapsService(photoCache, await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), await createTestPlaceDetailsCacheRepo(testDb), await createTestPlacesRepo(testDb), noGoogleQuota), new QueryHelpersService(await createTestTagsRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestAssignmentParticipantsRepo(testDb)), new UnsplashService(await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb), runtimeEnv, makeStorageFixture('').storage), photoCache,