mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
refactor(maps): google place lookups go through the places provider
This commit is contained in:
@@ -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 = []) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<Response> {
|
||||
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<string, string>) ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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<string, unknown> {
|
||||
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<Response> {
|
||||
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<string, { at: number; logo: BrandLogo | null }>();
|
||||
@@ -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<Buffer | null> {
|
||||
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<string | null> {
|
||||
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<string, unknown> = { 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<string, unknown> = {
|
||||
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<GooglePlaceDetails['reviews']>[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
|
||||
|
||||
@@ -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<Response> {
|
||||
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<string, string>) ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<Response> {
|
||||
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<Buffer | null> {
|
||||
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<string | null> {
|
||||
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<GooglePhotoOutcome> {
|
||||
// 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<ProviderPlace[]> {
|
||||
const searchBody: Record<string, unknown> = { 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<ProviderPlace[]> {
|
||||
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<ProviderSuggestion[]> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<ProviderPlace | null> {
|
||||
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<ProviderPlace> {
|
||||
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<GooglePlaceDetails['reviews']>[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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<McpRegistry> {
|
||||
// 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string, string> = {}) {
|
||||
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. */
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
pageid: 4711,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string, string> | 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' } });
|
||||
|
||||
|
||||
@@ -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<string, string>)['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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {}) =>
|
||||
const usersStub = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
||||
|
||||
const svcWith = (q: ReturnType<typeof quota>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<ReturnType<MapsService['keyedProvider']>>) : null,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = {}) {
|
||||
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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T> = { [K in keyof T]: T[K] };
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient>;
|
||||
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<Record<keyof MapsService, unknown>> = {}) {
|
||||
function mapsStub(over: Partial<Record<keyof EnrichmentSeams, unknown>> = {}) {
|
||||
return {
|
||||
getMapsKey: vi.fn(() => null as string | null),
|
||||
photosDisabled: vi.fn(() => false),
|
||||
@@ -70,7 +75,7 @@ function mapsStub(over: Partial<Record<keyof MapsService, unknown>> = {}) {
|
||||
resolveOsmIdentity: vi.fn(async () => null as { tags: Record<string, string>; 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<Record<keyof MapsService, unknown>> = {}) =>
|
||||
const branchOfAChain = (over: Partial<Record<keyof EnrichmentSeams, unknown>> = {}) =>
|
||||
mapsStub({
|
||||
resolveOsmIdentity: vi.fn(async () => ({ tags: BRANCH_TAGS, osmUrl: null, matchedName: "L'Osteria" })),
|
||||
...over,
|
||||
|
||||
@@ -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<T> = { [K in keyof T]: T[K] };
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient>;
|
||||
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<Record<keyof MapsService, unknown>> = {}) {
|
||||
function mapsStub(over: Partial<Record<keyof EnrichmentSeams, unknown>> = {}) {
|
||||
return {
|
||||
getMapsKey: vi.fn(() => null as string | null),
|
||||
photosDisabled: vi.fn(() => false),
|
||||
@@ -69,7 +74,7 @@ function mapsStub(over: Partial<Record<keyof MapsService, unknown>> = {}) {
|
||||
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<Record<keyof MapsService, unknown>> = {}) =>
|
||||
const googlePlaceAlsoInOsm = (over: Partial<Record<keyof EnrichmentSeams, unknown>> = {}) =>
|
||||
mapsStub({
|
||||
getMapsKey: vi.fn(() => 'key'),
|
||||
details: vi.fn(async () => ({ place: { source: 'google', rating: 4.6, rating_count: 4495 } })),
|
||||
|
||||
@@ -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<T> = { [K in keyof T]: T[K] };
|
||||
type EnrichmentSeams = Seams<MapsService> & Seams<GooglePlacesClient>;
|
||||
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<Record<keyof MapsService, unknown>> = {}) {
|
||||
function mapsStub(over: Partial<Record<keyof EnrichmentSeams, unknown>> = {}) {
|
||||
return {
|
||||
getMapsKey: vi.fn(() => null as string | null),
|
||||
photosDisabled: vi.fn(() => false),
|
||||
@@ -87,7 +92,7 @@ function mapsStub(over: Partial<Record<keyof MapsService, unknown>> = {}) {
|
||||
details: vi.fn(async () => ({ place: null })),
|
||||
trekPlacesEnabled: vi.fn(() => true),
|
||||
...over,
|
||||
} as unknown as MapsService;
|
||||
} as unknown as EnrichmentSeams;
|
||||
}
|
||||
|
||||
function cacheStub(over: Record<string, unknown> = {}) {
|
||||
@@ -102,8 +107,8 @@ function cacheStub(over: Record<string, unknown> = {}) {
|
||||
} 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;
|
||||
|
||||
@@ -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<PlacesService> {
|
||||
return new PlacesService(
|
||||
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
|
||||
|
||||
@@ -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<PlacesService> {
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user