refactor(maps): key selection, place details, marker photos and link resolving leave the maps orchestrator

This commit is contained in:
Maurice
2026-10-10 12:20:18 +02:00
parent 0cedff94a0
commit 80e64b4f4d
11 changed files with 1149 additions and 866 deletions
-1
View File
@@ -13,7 +13,6 @@
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
"src/nest/journey/journey-domain.service.ts": 1799,
"src/nest/maps/maps.service.ts": 3252,
"src/nest/places/places.service.ts": 2020,
"src/nest/plugins/plugin-runtime.service.ts": 1463,
"src/nest/plugins/runtime/plugin-sdk.ts": 1094,
+224
View File
@@ -0,0 +1,224 @@
/**
* The TREK Places index answers in its own shape; these turn its rows into the
* shapes MapsService already answers with, so the client and the map renderer
* need no branch per source. Pure: the calls themselves are trek-places.client's,
* the decision to make them is MapsService's.
*/
import { normalizePlaceWebsite } from '@trek/shared';
import type { MapsAutocompleteResult } from '@trek/shared';
import { buildOsmDetails, parsePoiCategories } from './maps.helpers';
import { POI_CATEGORY_TO_TREK, isOsmHit, osmPlaceId, toPlaceRecord, type TrekPlace, type TrekSearchHit } from './trek-places.client';
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
type PoiBbox = { south: number; west: number; north: number; east: number };
/** What one POI request asks the index, and how its answer is labelled. */
export interface IndexPoiPlan {
wanted: string[];
terms: string[];
lat: number;
lng: number;
radius: number;
/** Half the viewport diagonal; a radius below it means the view was narrowed. */
reach: number;
cap: number;
labelFor: (leaf: string | null, path: string | null) => string | undefined;
}
/**
* The index query for a POI request, or null when the index cannot answer it.
*
* One or several categories, the way searchOverpassPois reads them. The
* corridor search sends a comma-separated list, and looking the whole string up
* as one key missed every time — so every corridor query fell through to
* Overpass, on the one path where that hurts most: a single search fans out over
* sixteen boxes, and each of those races four public mirrors.
*/
export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): IndexPoiPlan | null {
const wanted = parsePoiCategories(category);
// Which category each Overture term belongs to, so a hit can be labelled
// with the category that actually produced it rather than with the whole
// list. The client colours and groups its markers by that field.
const categoryOfTerm = new Map<string, string>();
for (const key of wanted) {
for (const term of POI_CATEGORY_TO_TREK[key] ?? []) categoryOfTerm.set(term, key);
}
// All or nothing: a category the index has no terms for has to be answered
// by Overpass, and a mixed answer would silently drop it.
const indexKnowsAll = wanted.length > 0 && wanted.every((key) => POI_CATEGORY_TO_TREK[key]?.length);
if (!indexKnowsAll) return null;
// The index matches a term as a SUBSTRING of `category` and `category_path`
// (see POI_CATEGORY_TO_TREK), so an exact lookup misses every leaf that is
// not literally a term: `italian_restaurant` is a hit for `restaurant` and
// finds nothing here. Falling through to wanted[0] then labelled it with
// whichever pill the user happened to tap first, and the corridor panel
// groups and colours on exactly that field, so a trattoria came back as a
// petrol station. Longest match wins, because `fast_food` must not lose to
// `food` when both are terms of different categories.
const labelFor = (leaf: string | null, path: string | null): string | undefined => {
const haystack = `${leaf ?? ''} ${path ?? ''}`;
let best: string | undefined;
for (const term of categoryOfTerm.keys()) {
if (!haystack.includes(term)) continue;
if (best === undefined || term.length > best.length) best = term;
}
return best === undefined ? undefined : categoryOfTerm.get(best);
};
const lat = (bbox.south + bbox.north) / 2;
const lng = (bbox.west + bbox.east) / 2;
// Half the diagonal, so the circle covers the viewport corners rather
// than leaving the edges of the map empty.
const reach = Math.round(
Math.hypot((bbox.north - bbox.south) * 111_320, (bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180)) / 2,
);
const radius = Math.min(20000, Math.max(300, reach));
// The same budget the Overpass path spends: per category, capped, so a
// mixed search does not spend the whole allowance on whichever kind
// happens to be densest.
const cap = Math.min(limit * wanted.length, POI_RESULT_CAP);
return { wanted, terms: [...categoryOfTerm.keys()], lat, lng, radius, reach, cap, labelFor };
}
/**
* The index's POI hits in the shape the Overpass path produces, so the client
* and the map renderer need no branch — but named as what it is. Overture is not
* OpenStreetMap: it carries OSM among other sources under other licences, and
* this branch argues elsewhere that naming a source is a licence obligation. The
* wire contract keeps `source` an open string, so widening it costs nothing.
*
* One thing the index cannot do is localise. The Overpass path picks
* `name:<lang>` and falls back to `int_name`; the service has no language
* parameter at all, so a German user exploring Tokyo gets the Japanese primary
* names here. Named rather than hidden: whoever adds localisation upstream
* should find this comment.
*/
export function indexPoiAnswer(
found: (TrekPlace & { categoryPath?: string | null })[],
plan: IndexPoiPlan,
): PoiSearchResult {
const { wanted, labelFor, cap, radius, reach } = plan;
return {
pois: found.map((p) => ({
osm_id: `gers:${p.gers}`,
name: p.name,
lat: p.lat,
lng: p.lng,
// The category that produced the hit, not the list that was
// asked for: a mixed search must not label a petrol station as
// "fuel,charging,restaurant".
category: labelFor(p.category ?? null, p.categoryPath ?? null) ?? wanted[0],
poi_type: p.category ?? wanted[0],
address: p.address?.freeform ?? null,
website: normalizePlaceWebsite(p.contact?.website),
phone: p.contact?.phone ?? null,
opening_hours: p.hours?.osm ?? null,
// The index carries the chain and its Wikidata item, which is what
// the logo on the pin is looked up from — so a branch of a chain
// gets its own mark here exactly as it does on the Overpass path.
brand: p.brand?.name ?? null,
brand_wikidata: p.brand?.wikidata ?? null,
// Sockets are an OSM thing; the index has no charging fields, so
// a station answered from here reports "not stated" rather than
// claiming it offers nothing.
charging: null,
// The index has no cuisine field, so this null is the truth
// rather than a field being dropped on the way through.
cuisine: null,
source: 'trek-places' as const,
})),
source: 'trek-places' as const,
truncated: found.length >= cap,
// A wide viewport is narrowed here too, and the caller is told so
// for the same reason the Overpass path tells it.
clamped: radius < reach,
};
}
/** Autocomplete rows out of the index and its OpenStreetMap layer, interleaved as they came. */
export function indexSuggestions(found: TrekSearchHit[]): MapsAutocompleteResult['suggestions'] {
return found.map((p) =>
isOsmHit(p)
? {
// The service's own id form, translated into the one this
// file already resolves. Leaving it as `osm:node/123` would
// hand the client an id getPlaceDetails does not know, and
// the failure would land after the user had picked it.
placeId: osmPlaceId(p),
mainText: p.name,
// The layer carries no address. The local name is what the
// place is called on the spot, which is more use under a
// translated label than an empty line.
secondaryText: p.local_name && p.local_name !== p.name ? p.local_name : '',
// Per row, because this list is two indexes interleaved.
// The name above the list says `trek-places`, which is true
// of the call and false of half the rows in it — the layer
// is OpenStreetMap, and a reader deciding whether to trust
// a suggestion is asking exactly that.
source: 'openstreetmap',
// Both indexes hand these over with the row. Carried rather
// than dropped so picking a suggestion has something to fall
// back on when the details lookup cannot answer.
lat: p.lat,
lng: p.lng,
}
: {
placeId: `gers:${p.gers}`,
mainText: p.name,
secondaryText: [p.address?.locality, p.address?.country].filter(Boolean).join(', '),
source: 'trek-places',
lat: p.lat,
lng: p.lng,
},
);
}
/**
* The details record of a place picked out of the index, with what
* OpenStreetMap knows about the same building filled in.
*
* What the index does not have, the free sources still do: cuisine,
* wheelchair access, a menu link. OpenStreetMap has all of them for the same
* building, and without this the details a user saw while adding the place
* disappeared from its card afterwards, which reads like data loss. `osmTags`
* is null when no OSM match passed the identity gates.
*/
export function indexPlaceDetails(found: TrekPlace, osmTags: Record<string, string> | null, placeId: string): Record<string, unknown> {
const record = toPlaceRecord(found);
// Hours the index read off the operator's own site, run through the same
// expansion OSM's go through — the client reads a list of weekday lines,
// not the raw syntax, and handing it two shapes for one field would be a
// bug on every card that shows it.
//
// Measured across seven countries, OpenStreetMap has hours for 27.5
// percent of gastronomy; this covers part of the rest. It is the
// fallback, not the first choice: an OSM entry describes this exact
// object and gets corrected by people who walked past, where a chain's
// website often carries one set of hours for every branch.
const fromSite = typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
if (!osmTags) {
return fromSite?.opening_hours
? {
...record,
opening_hours: fromSite.opening_hours,
open_now: fromSite.open_now,
opening_periods: fromSite.opening_periods,
}
: record;
}
const osmDetails = buildOsmDetails(osmTags, '', '');
const hoursFrom = osmDetails.opening_hours ? osmDetails : fromSite;
return {
// Index first: its name, coordinate and contact details are the ones
// the user picked. OSM only fills what is still missing.
...osmDetails,
...record,
opening_hours: hoursFrom?.opening_hours ?? null,
open_now: hoursFrom?.open_now ?? null,
opening_periods: hoursFrom?.opening_periods ?? null,
website: record.website ?? osmDetails.website ?? null,
phone: record.phone ?? osmDetails.phone ?? null,
osm_id: placeId,
source: 'trek-places',
};
}
+157
View File
@@ -0,0 +1,157 @@
/**
* A pasted Google Maps or Amap link, turned into a coordinate.
*
* Short links are followed hop by hop through the SSRF guard, a Google page
* without inline coordinates is read (capped) for the ones in its embedded map
* data, and a Google point is reverse-geocoded through Nominatim for its
* address. An Amap link is parsed here too, but handed back as a point for the
* caller to reverse-geocode: inside China Amap answers that before Nominatim,
* and choosing who answers is MapsService's job, not this resolver's.
*/
import { Injectable } from '@nestjs/common';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { discardBody, exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
import { UA, googleFtidFromMapsUrl } from './maps.helpers';
import { AMAP_SHORT_HOSTS, isAmapHost, parseAmapUrl } from './providers/amap.provider';
import { OsmClient } from './providers/osm.client';
// A Google Maps place page is a few hundred KB; the coordinates sit in the
// embedded map data near the top, so two megabytes is plenty and keeps an
// unbounded body out of memory.
const MAX_MAPS_PAGE_BYTES = 2_000_000;
export const GOOGLE_SHORT_HOSTS = ['goo.gl', 'maps.app.goo.gl'];
/**
* Google Maps lives on every country domain — google.de, maps.google.co.uk,
* google.com.au — so the host is matched by shape. A fixed list of .com hosts
* would quietly stop resolving the ccTLD links people actually paste. The TLD
* labels stay short (2-3 letters, optionally two of them) so that
* `google.evil.com` is not a Google host.
*/
export function isGoogleMapsHost(hostname: string): boolean {
return GOOGLE_SHORT_HOSTS.includes(hostname)
|| /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname);
}
export interface ResolvedMapsUrl {
lat: number;
lng: number;
name: string | null;
address: string | null;
google_ftid: string | null;
}
/** A Google link resolved in full, or an Amap point still to be named. */
export type MapsLink =
| { kind: 'resolved'; result: ResolvedMapsUrl }
| { kind: 'amap'; lat: number; lng: number; name: string | null };
// Extract coordinates from a string (URL or page body). Google Maps encodes
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
function extractCoords(s: string): { lat: number; lng: number } | null {
const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
if (at) return { lat: Number.parseFloat(at[1]), lng: Number.parseFloat(at[2]) };
const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
if (data) return { lat: Number.parseFloat(data[1]), lng: Number.parseFloat(data[2]) };
const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
if (q) return { lat: Number.parseFloat(q[1]), lng: Number.parseFloat(q[2]) };
return null;
}
async function followRedirects(target: string, init?: RequestInit): Promise<Response> {
try {
return await safeFetchFollow(target, { signal: AbortSignal.timeout(10000), ...init }, { bypassInternalIpAllowed: true });
} catch (err) {
if (err instanceof SsrfBlockedError) {
throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 });
}
throw err;
}
}
const notFound = () => Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
@Injectable()
export class MapsUrlResolver {
constructor(private readonly osm: OsmClient) {}
async resolve(url: string): Promise<MapsLink> {
let resolvedUrl = url;
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps
// URLs that carry no inline coordinates — e.g. ?cid= links (the format
// get_place_details returns) and "Share"-button links. The redirect target
// usually carries the !3d!4d data param we can then parse. Redirects are
// followed manually so every hop is SSRF-re-checked.
const parsed = new URL(url);
const isShort = GOOGLE_SHORT_HOSTS.includes(parsed.hostname) || AMAP_SHORT_HOSTS.includes(parsed.hostname);
const isGoogleMaps = isGoogleMapsHost(parsed.hostname);
if (isShort || (isGoogleMaps && !extractCoords(url))) {
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
}
let resolvedHost = '';
try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, both host branches are skipped */ }
// Amap links first, and on their own: they spell the coordinate `lng,lat`
// in GCJ-02, which the Google patterns below would read as a WGS-84
// `lat,lng` and put a Shanghai restaurant in the East China Sea.
// parseAmapUrl owns both the ordering and the datum conversion.
if (isAmapHost(resolvedHost)) {
const amap = parseAmapUrl(resolvedUrl);
// A POI page without a coordinate would need a keyed detail lookup, and
// this method has no user to resolve a key for: the same answer a Google
// page without coordinates gets.
if (!amap || !Number.isFinite(amap.lat) || !Number.isFinite(amap.lng)) throw notFound();
return { kind: 'amap', lat: amap.lat, lng: amap.lng, name: amap.name };
}
let coords = extractCoords(resolvedUrl);
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
// page body once and parse the coordinates out of the embedded map data.
// Only Google's own pages get read; the resolved host is what counts, so a
// short link that lands on maps.google.com still qualifies.
if (!coords && isGoogleMapsHost(resolvedHost)) {
try {
const pageRes = await followRedirects(resolvedUrl, {
headers: { 'User-Agent': UA },
});
if (exceedsDeclaredLength(pageRes, MAX_MAPS_PAGE_BYTES)) {
// Nothing here will read it, and an unread body keeps its socket.
discardBody(pageRes);
} else {
// The map data sits near the top of the document, so a truncated read
// still finds the coordinates; an oversized page degrades to the same
// 400 an unparseable one already produced.
const { text } = await readCappedText(pageRes, MAX_MAPS_PAGE_BYTES);
coords = extractCoords(text);
}
} catch (err) {
if ((err as { status?: number })?.status === 403) throw err; // SSRF block, surface it
// Otherwise fall through to the not-found error below.
}
}
// Extract place name from URL path: /place/Place+Name/@...
let placeName: string | null = null;
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replaceAll(/\+/g, ' '));
}
if (!coords || Number.isNaN(coords.lat) || Number.isNaN(coords.lng)) throw notFound();
const { lat, lng } = coords;
// Reverse geocode to get address. A non-ok answer (Nominatim 5xx/429) must
// not fail the whole resolution — the coordinates are already extracted, so
// fall back to the URL-derived name and a null address.
const nominatim = await this.osm.reverseRaw(lat, lng);
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
return { kind: 'resolved', result: { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) } };
}
}
+30 -12
View File
@@ -6,6 +6,10 @@ import { MapsMcp } from './maps.mcp';
import { GooglePlacesClient } from './providers/google-places.provider';
import { OsmClient } from './providers/osm.client';
import { WikimediaClient } from './providers/wikimedia.client';
import { PlacesProviderSelector } from './places-provider.selector';
import { PlacePhotoResolver } from './place-photo.resolver';
import { MapsUrlResolver } from './maps-url.resolver';
import { PlaceDetailsResolver } from './place-details.resolver';
import { PlacePhotosModule } from '../place-photos/place-photos.module';
import { StorageModule } from '../storage/storage.module';
import { GoogleQuotaModule } from '../google-quota/google-quota.module';
@@ -15,23 +19,37 @@ import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
import { Places } from '../../db/entities/Places.entity';
/**
* Maps / geo domain (L3 leaf module). Registered in AppModule. Exports
* MapsService for the in-container consumers (BookingImportModule's Nominatim
* geocoding, PlacesModule's search_place tool and list-import enrichment).
* Nothing outside the container consumes this domain, so there is no bridge.
* Maps / geo domain (L3 leaf module). Registered in AppModule.
*
* MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places]):
* MapsService passes its own AppSettingsRepository/UsersRepository to
* instance-api-keys.ts's resolveApiKey (Plan 3a Task 5). Plan 3h Task 4 (R8)
* adds `PlaceDetailsCache` (MAP3-8 reuse `PlaceDetailsCacheRepository`, built
* for `place-enrichment.service.ts` — only the entity class is imported
* here, not that domain's own Nest module, so there is no new module edge/
* cycle risk) and `Places` (MAP9's additive `setImageUrlIfUnset`).
* MapsService is the orchestrator every consumer injects (BookingImportModule's
* geocoding, PlacesModule's search_place tool and list-import enrichment, the
* road trip search). Behind it sit one provider per outbound source
* (GooglePlacesClient, OsmClient, WikimediaClient; Amap is built per request by
* PlacesProviderSelector) and the resolvers it delegates to. The three clients
* are exported too, so the enrichment column asks Google, OpenStreetMap and
* Wikimedia directly rather than through a MapsService facade. Nothing outside
* the container consumes this domain, so there is no bridge.
*
* MikroOrmModule.forFeature: AppSettings and Users for the key chain
* (instance-api-keys.ts's resolveApiKey) and the kill switches,
* PlaceDetailsCache for the details cache (the entity only, not
* place-enrichment's module, so there is no new module edge) and Places for the
* marker photo's `setImageUrlIfUnset`.
*/
@Module({
imports: [PlacePhotosModule, StorageModule, GoogleQuotaModule, MikroOrmModule.forFeature([AppSettings, Users, PlaceDetailsCache, Places])],
controllers: [MapsController],
providers: [MapsService, MapsMcp, GooglePlacesClient, OsmClient, WikimediaClient],
providers: [
MapsService,
MapsMcp,
GooglePlacesClient,
OsmClient,
WikimediaClient,
PlacesProviderSelector,
PlacePhotoResolver,
MapsUrlResolver,
PlaceDetailsResolver,
],
exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient],
})
export class MapsModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
/**
* Place details by id, from whichever source the id belongs to: the TREK index
* (`gers:`), Amap (`amap:`), OpenStreetMap (`node:` / `way:` / `relation:`) or
* Google (everything else that is a Google place id), with the lean and the
* expanded details cache in front of the keyed providers.
*
* MapsService.getPlaceDetails / getPlaceDetailsExpanded delegate here and hand
* in whether the index is switched on, which is a deployment property they own.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { normalizePlaceWebsite } from '@trek/shared';
import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity';
import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository';
import { GooglePlacesClient } from './providers/google-places.provider';
import { OsmClient } from './providers/osm.client';
import { isAmapPlaceId } from './providers/amap.provider';
import { PlacesProviderSelector } from './places-provider.selector';
import { trekPlacesById } from './trek-places.client';
import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers';
import { indexPlaceDetails } from './maps-index.helpers';
/**
* A details row as the cache holds it. A row written before #2483 still has a
* source's website as it came, `www.hotel.cn` from Amap for one, and keeps for a
* week (an expanded one until a refresh), so the website is normalized on the
* way out of the cache as well as on the way in.
*/
function cachedDetails(payload: string): Record<string, unknown> | null {
const place = JSON.parse(payload) as Record<string, unknown> | null;
return place && 'website' in place ? { ...place, website: normalizePlaceWebsite(place.website) } : place;
}
/** How long a lean details answer is served from the cache. */
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
@Injectable()
export class PlaceDetailsResolver {
constructor(
@InjectRepository(PlaceDetailsCache) private readonly placeDetailsCache: PlaceDetailsCacheRepository,
private readonly googlePlaces: GooglePlacesClient,
private readonly osm: OsmClient,
private readonly selector: PlacesProviderSelector,
) {}
async lookup(
userId: number,
placeId: string,
lang: string | undefined,
sessionToken: string | undefined,
indexEnabled: boolean,
): Promise<{ place: Record<string, unknown> | null }> {
// A place picked out of the TREK index. Checked BEFORE the generic
// colon branch below, which would otherwise read "gers" as an OSM type
// and ask Overpass for an element that does not exist.
if (placeId.startsWith('gers:')) {
// The switch is a deployment property, and off means nothing leaves for
// the index: a place saved while it was on is still opened from what the
// trip holds, not looked up again.
if (!indexEnabled) return { place: null };
const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null);
if (!found) return { place: null };
// What the index lacks, OpenStreetMap often has for the same building
// (see indexPlaceDetails). Matched by name and coordinate, with the same two gates
// resolveOsmIdentity applies everywhere: within range, and sharing a
// substantial word of the name. A confident description of the building
// next door is worse than none.
const osm = await this.osm.resolveOsmIdentity(found.name, found.lat, found.lng, {
lang,
maxDistanceM: 150,
}).catch(() => null);
return { place: indexPlaceDetails(found, osm?.tags ?? null, placeId) };
}
// An Amap id is `amap:<poiid>` and so carries a colon too. Before the OSM
// branch, which would otherwise send "amap" to Overpass as an element type
// and answer every Chinese place with an empty record.
if (isAmapPlaceId(placeId)) return this.amapDetails(userId, placeId, lang);
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) return { place: await this.osm.elementDetails(placeId, lang) };
// Google details
// 'en' default, aligned with search/autocomplete and the MCP tools' ?? 'en'
// (the 'de' the legacy service defaulted to was a development leftover;
// cache rows keyed 'de' for lang-less callers go cold once — 7-day TTL).
const langKey = toApiLang(lang);
const apiKey = await this.selector.getMapsKey(userId);
// No key means no way to resolve a Google id: they have no OpenStreetMap
// equivalent to fall back to. That is an empty result, not a client error.
// Search and autocomplete already answer their keyless case with the OSM
// stack; this used to be the one place that threw instead, which turned an
// instance without a key into a stream of 400s whenever an older Google
// place was opened. Callers already treat a null place as a miss.
if (!apiKey) return { place: null };
// The details call closes the autocomplete session this lookup belongs to,
// so Google bills the search once instead of per keystroke. A cache hit
// never reaches it, which is billing-neutral: an unclosed session is
// charged as a plain autocomplete session.
const google = this.googlePlaces.provider({ key: apiKey, source: null, userId });
return this.detailsThroughCache(placeId, langKey, () => google.placeDetails(placeId, lang, sessionToken));
}
/**
* The lean details cache (expanded=0, seven days) in front of one provider
* lookup. Keyed by place_id, and an Amap id carries its `amap:` prefix, so the
* providers' rows cannot collide. A failed cache write costs the cache, not
* the answer.
*/
private async detailsThroughCache(
placeId: string,
langKey: string,
lookup: () => Promise<Record<string, unknown> | null>,
): Promise<{ place: Record<string, unknown> | null }> {
const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 0);
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: cachedDetails(cached.payload_json) };
const place = await lookup();
if (!place) return { place: null };
try {
await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: Date.now() });
} catch (dbErr) {
console.error('Failed to cache place details:', dbErr);
}
return { place };
}
/**
* The Amap half of getPlaceDetails, behind the same cache the Google half
* uses.
*
* No key for the id is an empty result, not a client error, for the same
* reason the Google half answers its keyless case that way: an Amap place
* opened on an install that has since dropped its Amap key is a miss.
*/
private async amapDetails(
userId: number,
placeId: string,
lang?: string,
): Promise<{ place: Record<string, unknown> | null }> {
const provider = await this.selector.providerForPlaceId(userId, placeId);
if (!provider) return { place: null };
return this.detailsThroughCache(placeId, toApiLang(lang), () => provider.placeDetails(placeId, lang));
}
async lookupExpanded(
userId: number,
placeId: string,
lang: string | undefined,
refresh: boolean,
indexEnabled: boolean,
): Promise<{ place: Record<string, unknown> | null }> {
// Reviews and the editorial summary only exist at Google, but the id does not
// have to be a Google one — the client sends whatever the place carries. OSM ids
// keep the details they do have (Overpass, via the plain lookup); coordinate
// pseudo-ids and legacy image URLs have no details source at all. Neither may be
// forwarded to Google, which bills the 400 INVALID_ARGUMENT it answers with.
//
// Index ids degrade the same way, for the same reason: the plain lookup has a
// whole record for them — name, address, contact, hours — and only the reviews
// and the editorial summary are Google's to add. Answering `expand=1` with a
// null while `expand=0` answers in full would make the richer request the
// poorer one. An Amap id has no richer tier either, so it takes the plain
// lookup as well.
if (!isGooglePlaceId(placeId)) {
return OSM_PLACE_ID.test(placeId) || placeId.startsWith('gers:') || isAmapPlaceId(placeId)
? this.lookup(userId, placeId, lang, undefined, indexEnabled)
: { place: null };
}
const langKey = toApiLang(lang); // 'en' default — see lookup
const apiKey = await this.selector.getMapsKey(userId);
// Same as the lean lookup above: an empty result, not a client error.
if (!apiKey) return { place: null };
// Check DB cache for expanded result
if (!refresh) {
const cached = await this.placeDetailsCache.findEntry(placeId, langKey, 1);
if (cached) return { place: cachedDetails(cached.payload_json) };
}
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() });
} catch (dbErr) {
console.error('Failed to cache expanded place details:', dbErr);
}
return { place };
}
}
@@ -0,0 +1,131 @@
/**
* The marker photo of a place: Google's first photo for a Google id, otherwise
* (and as the fallback) a Wikipedia or Commons picture near its coordinates,
* cached on disk and served through /api/maps/place-photo/:id/bytes.
*
* Everything the cache needs to stay cheap lives here: the disk hit, the
* negative cache that tells "no photo anywhere" (a day) from "the provider
* failed" (minutes), the in-flight dedupe of concurrent requests for one place
* and the shared photo-fetch slots. MapsService.getPlacePhoto delegates to it.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Places } from '../../db/entities/Places.entity';
import type { PlacesRepository } from '../../db/repositories/Places.repository';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { GooglePlacesClient } from './providers/google-places.provider';
import { WikimediaClient } from './providers/wikimedia.client';
import { PlacesProviderSelector } from './places-provider.selector';
import { withPhotoFetchSlot } from './photo-fetch-slot';
import { isGooglePlaceId } from './maps.helpers';
@Injectable()
export class PlacePhotoResolver {
constructor(
private readonly photoCache: PlacePhotoCacheService,
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
private readonly googlePlaces: GooglePlacesClient,
private readonly wiki: WikimediaClient,
private readonly selector: PlacesProviderSelector,
) {}
async resolve(
userId: number,
placeId: string,
lat: number,
lng: number,
name?: string,
): Promise<{ photoUrl: string | null; attribution: string | null }> {
// Disk cache hit — serve immediately, no Google call
const diskHit = await this.photoCache.get(placeId);
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
// "No photo for this place" is an empty result, not a missing resource: a trip
// view asks for one photo per place, so answering each miss with a 404 makes a
// normal itinerary render look like a 404 scan to fail2ban/CrowdSec and gets
// the user's IP banned. Every miss below returns photoUrl: null instead — the
// same shape the photos kill-switch already returns.
const noPhoto = { photoUrl: null, attribution: null };
// Recent miss — don't hammer the API
if (await this.photoCache.getErrored(placeId)) return noPhoto;
// Deduplicate concurrent requests for the same placeId
const existing = this.photoCache.getInFlight(placeId);
if (existing !== undefined) {
const result = await existing;
if (!result) return noPhoto;
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
// Tells the two empty outcomes apart for the negative cache below: a place that
// has no photo anywhere is worth remembering for a day, a provider that refused
// or timed out only for a few minutes.
let providerFailed = false;
const fetchPromise = (async (): Promise<{ attribution: string | null } | null> => {
return withPhotoFetchSlot(async () => {
const apiKey = await this.selector.getMapsKey(userId);
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
// so a place added via search still gets a marker image when Google returns
// nothing. Returns null (without marking an error) so the caller decides.
const fetchWikimediaFallback = async (): Promise<{ attribution: string | null } | null> => {
const outcome = await this.wiki.downloadPhoto(lat, lng, name);
if (outcome.kind === 'failed') providerFailed = true;
if (outcome.kind !== 'photo') return null;
try {
const cached = await this.photoCache.put(placeId, outcome.bytes, outcome.attribution);
return { attribution: cached.attribution };
} catch {
providerFailed = true;
return null;
}
};
// Google Places photo for a Google place_id. Returns null on any miss — no
// key, request rejected, no photos, or a failed media download — so the
// caller can fall back to Wikimedia; the misses that were Google's fault
// flag providerFailed on the way out.
const fetchGooglePhoto = async (): Promise<{ 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;
const cached = await this.photoCache.put(placeId, outcome.bytes, outcome.attribution);
// Persist stable proxy URL to database
try {
await this.placesRepo.setImageUrlIfUnset(placeId, cached.photoUrl);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { attribution: outcome.attribution };
};
// Prefer the Google photo (higher quality); if Google yields nothing, fall
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
// places use. Ids Google cannot resolve skip it entirely.
if (isGooglePlaceId(placeId)) {
const googlePhoto = await fetchGooglePhoto();
if (googlePhoto) return googlePhoto;
}
const fallback = await fetchWikimediaFallback();
if (fallback) return fallback;
await this.photoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo');
return null;
});
})();
this.photoCache.setInFlight(placeId, fetchPromise);
const result = await fetchPromise;
if (!result) return noPhoto;
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
}
}
@@ -0,0 +1,206 @@
/**
* Who answers beside the index for one request: the keyed places provider and
* the credential it spends.
*
* Search, autocomplete, nearby, details and the marker photo all start by
* asking this: which provider holds the keyed slot (Google, Amap, or nobody so
* the OpenStreetMap stack answers alone), whether the admin's "Google only"
* switch applies, whether Amap answers first inside China, and which key a
* Google or Amap call spends. MapsService orchestrates on top of the answer;
* this class only resolves it, through the same app_settings and users rows
* the key chain always read, in the same order.
*/
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { isOutsideChina } from '@trek/shared';
import { readEnv } from '../../app-config';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { GoogleQuotaService } from '../google-quota/google-quota.service';
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
import { AmapPlacesProvider, AmapTipStash, isAmapPlaceId } from './providers/amap.provider';
/**
* The app_settings row that names the keyed places provider.
*
* A setting rather than "whichever key is configured": an install can hold both
* credentials (a team split between China and elsewhere), and then only an
* admin can say which one should answer. Absent, which is every install that
* predates Amap, means `auto`, which keeps Google.
*/
export const PLACES_PROVIDER_SETTING = 'places_provider';
/**
* The admin switch that hands search and suggestions to Google alone. Off, the
* index and OpenStreetMap answer first and Google is only asked when they find
* nothing, which is what every install has had since 4.3.0.
*/
export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only';
/**
* Whoever holds the keyed slot beside the index for one request: Google's
* credential, an Amap provider, or nobody (the OpenStreetMap stack alone).
*/
export type KeyedProvider =
| { id: 'google'; key: string; source: ApiKeySource | null }
| { id: 'amap'; provider: AmapPlacesProvider };
@Injectable()
export class PlacesProviderSelector {
constructor(
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
private readonly googleQuota: GoogleQuotaService,
) {}
/** Amap autocomplete tips for the details fallback. Here rather than on the provider:
* a provider is built per request, and a pick is two requests. */
private readonly amapTips = new AmapTipStash();
// ── API key retrieval ──────────────────────────────────────────────────────
/**
* The Places credential for this request, and where it came from.
*
* Operator env first: a per-user key would route around whatever the
* operator's endpoint counts, and unset, that branch never runs. Then the
* instance-wide value the admin panel writes, then the caller's own row.
*
* What is deliberately gone is the old third step, "any admin's key" (#1939):
* it read a stranger's credential, which server/CLAUDE.md forbids, and made
* the answer depend on who was asking — the saving admin got their own key,
* everybody else got the lowest-id admin's and a 403 from Google. The source
* is returned so a provider error can say which of the three was used.
*/
async resolveMapsKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.appSettings, this.usersRepo, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
/**
* The Google key to spend, or null. Also null once today's calls reached the
* admin's daily ceiling (#1582), which makes every caller fall back to what a
* keyless install does instead of failing.
*/
async getMapsKey(userId: number): Promise<string | null> {
if (await this.googleQuota.exhausted()) return null;
return (await this.resolveMapsKey(userId)).key;
}
/** The Amap credential, resolved through the identical three-step chain. */
async resolveAmapKey(userId: number): Promise<{ key: string | null; source: ApiKeySource | null }> {
return resolveApiKey(this.appSettings, this.usersRepo, 'amap_api_key', userId, readEnv().maps.amapApiKey);
}
// ── Keyed provider selection ───────────────────────────────────────────────
/**
* Which keyed provider the admin picked, or `auto`.
*
* An unrecognised stored value degrades to `auto` rather than throwing: this
* is read on the hot path of every search, and a hand-edited settings row must
* not take place search down.
*/
async placesProviderChoice(): Promise<PlacesProviderChoice> {
const value = await this.appSettings.getValue(PLACES_PROVIDER_SETTING);
return isPlacesProviderChoice(value) ? value : 'auto';
}
/**
* Who holds the keyed slot for this request, or null for the OpenStreetMap
* stack alone. The index and OpenStreetMap are asked either way; this only
* decides what answers once they have nothing.
*
* `auto`, the default and what every install that predates Amap has, prefers
* Google. That is deliberately the incumbent rather than "the newest provider
* wins": an existing install must not silently start querying somewhere
* else, with a different bill and different results, because a release added
* a provider. An admin who wants Amap says so.
*
* Key resolution is ordered to match: under `auto` the Amap chain is only
* walked when there is no Google key, so an install on Google issues exactly
* the database reads it always did.
*/
async keyedProvider(userId: number): Promise<KeyedProvider | null> {
const choice = await this.placesProviderChoice();
if (choice === 'openstreetmap') return null;
if (choice !== 'amap') {
const google = await this.resolveMapsKey(userId);
// Past the daily ceiling (#1582) the key is spent for today: answer as if
// there were none, so `auto` moves on and OpenStreetMap fills in.
if (google.key && !(await this.googleQuota.exhausted())) return { id: 'google', key: google.key, source: google.source };
// An explicit 'google' choice with no key is not a reason to query Amap
// instead: this install is on Google and is misconfigured. OSM answers,
// the way a keyless install has always been answered.
if (choice === 'google') return null;
}
const amap = await this.resolveAmapKey(userId);
return amap.key
? { id: 'amap', provider: new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }, this.amapTips) }
: null;
}
/**
* Whether this search goes to Google and nowhere else.
*
* Two ways to ask for that, both born of the same moment: the index answered
* a query with something that is not the place the traveller meant, and with
* the index and OpenStreetMap answering first, Google was never consulted as
* long as they found anything at all. The caller can send one search to
* Google (`requested`, the "search Google instead" link under the results),
* and the admin can make that the rule for every search and suggestion (the
* switch beside the key). Either way it only holds when Google holds the key
* slot: on an install without a Google key, or one that picked Amap or
* OpenStreetMap, both change nothing, and the admin panel says so.
*
* Read after the keyed provider on purpose: that lookup already walked the
* key chain, and a setting read ahead of it would shift the order of the
* app_settings reads every test of the chain stubs by position.
*/
async googleOnly(keyed: KeyedProvider | null, requested = false): Promise<boolean> {
if (keyed?.id !== 'google') return false;
if (requested) return true;
return (await this.appSettings.getValue(PLACES_GOOGLE_ONLY_SETTING)) === 'true';
}
/**
* Whether Amap answers before the index and OpenStreetMap (#1636): only when
* the admin picked Amap outright, not when it holds the slot by default, and
* only for a search centred inside China. Outside it Amap still answers, with
* the wrong place (the Eiffel Tower lands in Macau), so the gate is the point,
* not the provider.
*/
async amapAnswersFirst(point?: { lat: number; lng: number }): Promise<boolean> {
if (!point || !Number.isFinite(point.lat) || !Number.isFinite(point.lng)) return false;
return (await this.placesProviderChoice()) === 'amap' && !isOutsideChina(point.lat, point.lng);
}
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */
async resolvePlacesProvider(userId: number): Promise<AmapPlacesProvider | null> {
const keyed = await this.keyedProvider(userId);
return keyed?.id === 'amap' ? keyed.provider : null;
}
/**
* The Amap provider for an `amap:` id, regardless of which provider is
* currently selected.
*
* Places outlive the setting. An install that ran on Amap for a year and then
* switches to Google still holds its `amap:` places, and every one of those
* keeps opening against the Amap key that is still configured. Google ids do
* not come through here at all: they take the Google provider, whose key is
* resolved the same way.
*
* Null means nobody can resolve it: a Google id, or an Amap place on an
* install that has since dropped its Amap key. Callers treat that as a miss,
* not an error.
*/
async providerForPlaceId(userId: number, placeId: string): Promise<AmapPlacesProvider | null> {
if (!isAmapPlaceId(placeId)) return null;
const amap = await this.resolveAmapKey(userId);
return amap.key ? new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }, this.amapTips) : null;
}
}
@@ -31,6 +31,8 @@ import {
haversineMetres,
namesOverlap,
readChargingInfo,
buildOsmDetails,
OSM_PLACE_ID,
type OverpassPoi,
} from '../maps.helpers';
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
@@ -446,6 +448,46 @@ export class OsmClient {
return res.ok ? ((await res.json()) as NominatimReverse) : {};
}
/**
* The details record of an OpenStreetMap element id (`node:123`, `way:4`,
* `relation:5`), or null for anything else with a colon in it.
*/
async elementDetails(placeId: string, lang?: string): Promise<Record<string, unknown> | null> {
// Only an element type with a numeric id is looked up. The id is written
// into an Overpass query and a Nominatim lookup as it came in, and nothing
// else with a colon in it (a legacy image URL, a coordinate pseudo-id) has
// a details source: answering those with an empty record cost two
// requests that could not succeed, and let anything after the colon be
// sent as a query of its own.
if (!OSM_PLACE_ID.test(placeId)) return null;
const [osmType, osmId] = placeId.split(':');
// buildOsmDetails never yields name/address/coordinates — Nominatim is
// always the source for those (Overpass contributes the tag-derived rest).
const [element, nominatim] = await Promise.all([
this.fetchOverpassDetails(osmType, osmId),
this.lookupNominatim(osmType, osmId, lang),
]);
// Overpass has the fuller tag set and wins where both answer, but it is
// also the one that goes down — overpass-api.de is regularly overloaded.
// Nominatim's extratags carry the wikidata/wikipedia/commons tags too, so
// a place keeps its pictures and its description when Overpass times out
// instead of falling back to "photographed within 300m".
const details = buildOsmDetails(
{ ...(nominatim?.extratags ?? {}), ...(element?.tags ?? {}) },
osmType,
osmId,
);
return {
...details,
name: nominatim?.name || element?.tags?.name || '',
address: nominatim?.address || '',
lat: nominatim?.lat ?? null,
lng: nominatim?.lng ?? null,
osm_id: placeId,
};
}
// ── Overpass API (OSM details) ─────────────────────────────────────────────
async fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
+11 -2
View File
@@ -2,6 +2,10 @@ import { MapsService } from '../../src/nest/maps/maps.service';
import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider';
import { OsmClient } from '../../src/nest/maps/providers/osm.client';
import { WikimediaClient } from '../../src/nest/maps/providers/wikimedia.client';
import { PlacesProviderSelector } from '../../src/nest/maps/places-provider.selector';
import { PlacePhotoResolver } from '../../src/nest/maps/place-photo.resolver';
import { MapsUrlResolver } from '../../src/nest/maps/maps-url.resolver';
import { PlaceDetailsResolver } from '../../src/nest/maps/place-details.resolver';
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';
@@ -16,6 +20,7 @@ export interface MapsParts {
google: GooglePlacesClient;
osm: OsmClient;
wiki: WikimediaClient;
selector: PlacesProviderSelector;
}
/**
@@ -35,8 +40,12 @@ export function buildMapsParts(
const google = new GooglePlacesClient(googleQuota);
const osm = new OsmClient();
const wiki = new WikimediaClient();
const svc = new MapsService(photoCache, appSettings, users, placeDetailsCache, places, googleQuota, google, osm, wiki);
return { svc, google, osm, wiki };
const selector = new PlacesProviderSelector(appSettings, users, googleQuota);
const photos = new PlacePhotoResolver(photoCache, places, google, wiki, selector);
const links = new MapsUrlResolver(osm);
const details = new PlaceDetailsResolver(placeDetailsCache, google, osm, selector);
const svc = new MapsService(photoCache, appSettings, google, osm, wiki, selector, photos, links, details);
return { svc, google, osm, wiki, selector };
}
export function buildMapsService(
@@ -0,0 +1,72 @@
/**
* PlacePhotoResolver on its own: the outcomes of the two photo sources mapped
* onto the negative cache. The full getPlacePhoto flow (cache hit, in-flight
* dedupe, Google then Wikimedia) runs through MapsService in
* maps.service.test.ts; this pins the cases a source answer alone decides.
*/
import { describe, expect, it, vi } from 'vitest';
import { PlacePhotoResolver } from '../../../src/nest/maps/place-photo.resolver';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider';
import type { WikimediaClient } from '../../../src/nest/maps/providers/wikimedia.client';
import type { PlacesProviderSelector } from '../../../src/nest/maps/places-provider.selector';
function cache(over: Record<string, unknown> = {}) {
return {
get: vi.fn(async () => null),
getErrored: vi.fn(async () => false),
getInFlight: vi.fn(() => undefined),
setInFlight: vi.fn(),
markError: vi.fn(async () => {}),
put: vi.fn(async (id: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/p/${id}`, filePath: '/x', attribution })),
...over,
};
}
function resolver(c: ReturnType<typeof cache>, wikiOutcome: unknown, key: string | null = null, googleOutcome: unknown = { kind: 'none' }) {
const places = { setImageUrlIfUnset: vi.fn(async () => 1) };
const google = { firstPhoto: vi.fn(async () => googleOutcome) };
const wiki = { downloadPhoto: vi.fn(async () => wikiOutcome) };
const selector = { getMapsKey: vi.fn(async () => key) };
const r = new PlacePhotoResolver(
c as unknown as PlacePhotoCacheService,
places as unknown as PlacesRepository,
google as unknown as GooglePlacesClient,
wiki as unknown as WikimediaClient,
selector as unknown as PlacesProviderSelector,
);
return { r, places, google, wiki };
}
describe('PlacePhotoResolver', () => {
it('PHOTO-R-001: a Wikimedia picture the disk cache cannot store counts as a provider failure', async () => {
const c = cache({ put: vi.fn(async () => { throw new Error('disk full'); }) });
const { r } = resolver(c, { kind: 'photo', bytes: Buffer.from([1]), attribution: 'A' });
expect(await r.resolve(1, 'coords:1,2', 1, 2, 'X')).toEqual({ photoUrl: null, attribution: null });
expect(c.markError).toHaveBeenCalledWith('coords:1,2', 'provider-error');
});
it('PHOTO-R-002: nothing anywhere is remembered as a plain miss', async () => {
const c = cache();
const { r } = resolver(c, { kind: 'none' });
expect(await r.resolve(1, 'coords:1,2', 1, 2)).toEqual({ photoUrl: null, attribution: null });
expect(c.markError).toHaveBeenCalledWith('coords:1,2', 'no-photo');
});
it('PHOTO-R-003: a Google photo is stored, its proxy URL persisted, and Wikimedia never asked', async () => {
const c = cache();
const { r, places, wiki } = resolver(c, { kind: 'none' }, 'key', { kind: 'photo', bytes: Buffer.from([1]), attribution: 'G' });
expect(await r.resolve(1, 'ChIJabc', 1, 2)).toEqual({ photoUrl: '/api/maps/place-photo/ChIJabc/bytes', attribution: 'G' });
expect(places.setImageUrlIfUnset).toHaveBeenCalledWith('ChIJabc', '/p/ChIJabc');
expect(wiki.downloadPhoto).not.toHaveBeenCalled();
});
it('PHOTO-R-004: a Google failure falls back to Wikimedia and is remembered briefly when that has nothing', async () => {
const c = cache();
const { r, wiki } = resolver(c, { kind: 'none' }, 'key', { kind: 'failed' });
expect(await r.resolve(1, 'ChIJabc', 1, 2)).toEqual({ photoUrl: null, attribution: null });
expect(wiki.downloadPhoto).toHaveBeenCalledWith(1, 2, undefined);
expect(c.markError).toHaveBeenCalledWith('ChIJabc', 'provider-error');
});
});