diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index 4dd12df07..07a097561 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -333,19 +333,56 @@ export function buildOsmDetails(tags: Record, osmType: string, o opening_periods, osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, summary: tags.description || null, + // Kept so enrichment can resolve the right Wikipedia article instead of + // guessing one from the place name, which picks the wrong article whenever + // the name is ambiguous ("Bahnhofstraße", "Rathaus", any chain restaurant). + wikipedia: tags.wikipedia || null, + wikidata: tags.wikidata || null, source: 'openstreetmap' as const, }; } +// ── Wiki metadata ──────────────────────────────────────────────────────────── + +/** + * Commons extmetadata values arrive as HTML fragments — an author is typically + * an to the uploader's user page, a licence can carry wrappers. We + * show these as plain text next to a thumbnail, so the markup has to go. + */ +export function stripWikiMarkup(value: string | undefined | null): string | null { + if (!value) return null; + const text = value + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return text || null; +} + +/** + * Splits an OSM `wikipedia` tag ("de:Museum Ludwig") into host language and + * article title. Returns null for the bare-title spelling, which has no language + * and would send us to the wrong wiki. + */ +export function parseWikipediaTag(tag: string | undefined | null): { lang: string; title: string } | null { + if (!tag) return null; + const match = /^([a-z-]{2,12}):(.+)$/i.exec(tag.trim()); + if (!match) return null; + const title = match[2].trim(); + return title ? { lang: match[1].toLowerCase(), title } : null; +} + // ── Place-id classification ────────────────────────────────────────────────── // Ids that can never resolve against the Google Places API: coordinate pseudo-ids // (right-click places, in both the coords: and the bare "lat,lng" spelling the // collection views send), OSM ids the client sends when a place has no -// google_place_id, and the raw photo URLs legacy rows keep in image_url. Google -// answers those with a billable 400 INVALID_ARGUMENT, so every lookup sorts them -// out before the call and uses the OSM/Wikimedia path instead. -const NON_GOOGLE_PLACE_ID = /^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$/i; +// google_place_id, the raw photo URLs legacy rows keep in image_url, and photo +// cache keys of the form "~p3" that enrichment mints for the picker. +// Google answers those with a billable 400 INVALID_ARGUMENT, so every lookup +// sorts them out before the call and uses the OSM/Wikimedia path instead. +const NON_GOOGLE_PLACE_ID = + /^(?:coords|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i; // The subset that still has a provider behind it — Overpass for details, // Wikimedia for photos. export const OSM_PLACE_ID = /^(?:node|way|relation):/i; diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index 0da190ae5..df32db55c 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -25,6 +25,8 @@ import { CATEGORY_OSM_FILTERS, resolveOverpassEndpoints, resolveOverpassTimeoutMs, + stripWikiMarkup, + parseWikipediaTag, type GoogleOpeningHours, type OverpassPoi, } from './maps.helpers'; @@ -59,7 +61,33 @@ interface OverpassElement { } interface WikiCommonsPage { - imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[]; + imageinfo?: { + url?: string; + thumburl?: string; + mime?: string; + /** The file description page — where the full licence terms live. */ + descriptionurl?: string; + extmetadata?: { + Artist?: { value?: string }; + LicenseShortName?: { value?: string }; + LicenseUrl?: { value?: string }; + UsageTerms?: { value?: string }; + }; + }[]; +} + +/** + * One Commons image with everything needed to credit it. Commons is mostly + * CC BY / CC BY-SA, so a candidate that cannot be attributed is not usable in a + * picker — the fields are nullable because Commons metadata is user-maintained + * and genuinely incomplete on some files, not because they are optional to show. + */ +export interface CommonsCandidate { + photoUrl: string; + attribution: string | null; + license: string | null; + licenseUrl: string | null; + sourceUrl: string | null; } interface GooglePlaceResult { @@ -123,6 +151,21 @@ function releasePhotoFetchSlot(): void { } } +/** + * Runs an outbound photo fetch under the shared slot limit. Exported so the + * enrichment module queues behind the same five slots — a picker that grabs + * three images per selected place would otherwise sail past a cap the rest of + * the app respects. + */ +export async function withPhotoFetchSlot(fn: () => Promise): Promise { + await acquirePhotoFetchSlot(); + try { + return await fn(); + } finally { + releasePhotoFetchSlot(); + } +} + // ── Overpass POI search state ──────────────────────────────────────────────── interface OverpassPoiElement { @@ -553,6 +596,20 @@ export class MapsService { } // Strategy 2: Wikimedia Commons geosearch by coordinates + const candidates = await this.fetchCommonsCandidates(lat, lng, 5); + const first = candidates[0]; + return first ? { photoUrl: first.photoUrl, attribution: first.attribution } : null; + } + + /** + * Commons images near a coordinate, licence metadata included. + * + * geosearch already returns up to `limit` files in a single request, so asking + * for a whole strip costs the same as asking for one picture. Callers that only + * want a single image (fetchWikimediaPhoto, and through it getPlacePhoto) take + * the first entry and get exactly the file they got before this existed. + */ + async fetchCommonsCandidates(lat: number, lng: number, limit = 5): Promise { const params = new URLSearchParams({ action: 'query', format: 'json', @@ -561,29 +618,80 @@ export class MapsService { ggsnamespace: '6', ggsradius: '300', ggscoord: `${lat}|${lng}`, - ggslimit: '5', + ggslimit: String(Math.max(1, Math.min(limit, 20))), prop: 'imageinfo', iiprop: 'url|extmetadata|mime', iiurlwidth: '400', }); try { const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); - if (!res.ok) return null; - const data = (await res.json()) as { - query?: { pages?: Record }; - }; + if (!res.ok) return []; + const data = (await res.json()) as { query?: { pages?: Record } }; const pages = data.query?.pages; - if (!pages) return null; + if (!pages) return []; + + const out: CommonsCandidate[] = []; for (const page of Object.values(pages)) { const info = page.imageinfo?.[0]; // Only use actual photos (JPEG/PNG), skip SVGs and PDFs - const mime = (info as { mime?: string })?.mime || ''; - if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) { - const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null; + const mime = info?.mime || ''; + if (!info?.url || !(mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) continue; + const meta = info.extmetadata; + out.push({ // iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it — // info.url is the full-resolution original (multi-megapixel camera exports). - return { photoUrl: info.thumburl ?? info.url, attribution }; - } + photoUrl: info.thumburl ?? info.url, + attribution: stripWikiMarkup(meta?.Artist?.value), + license: stripWikiMarkup(meta?.LicenseShortName?.value) ?? stripWikiMarkup(meta?.UsageTerms?.value), + licenseUrl: meta?.LicenseUrl?.value?.trim() || null, + sourceUrl: info.descriptionurl || null, + }); + if (out.length >= limit) break; + } + return out; + } catch { + return []; + } + } + + /** + * Lead paragraph of a Wikipedia article. + * + * Resolved from the place's OSM `wikipedia` tag when it has one, because + * guessing the article from the place name lands on the wrong article for + * every ambiguous name. Without a tag there is no reliable article, so this + * returns null rather than a plausible-looking description of somewhere else. + */ + async fetchWikipediaExtract( + wikipediaTag: string | null | undefined, + ): Promise<{ text: string; sourceUrl: string } | null> { + const parsed = parseWikipediaTag(wikipediaTag); + if (!parsed) return null; + + const params = new URLSearchParams({ + action: 'query', + format: 'json', + titles: parsed.title, + prop: 'extracts', + exintro: '1', + explaintext: '1', + exsentences: '3', + redirects: '1', + }); + try { + const res = await fetch(`https://${parsed.lang}.wikipedia.org/w/api.php?${params}`, { + headers: { 'User-Agent': UA }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { query?: { pages?: Record } }; + const pages = data.query?.pages; + if (!pages) return null; + for (const page of Object.values(pages)) { + const text = page.extract?.trim(); + // A missing article comes back as a page with no extract, not as a 404. + if (!text) continue; + const title = page.title ?? parsed.title; + return { text, sourceUrl: `https://${parsed.lang}.wikipedia.org/wiki/${encodeURIComponent(title)}` }; } return null; } catch { @@ -591,6 +699,75 @@ export class MapsService { } } + /** + * 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 googleFetch( + `https://places.googleapis.com/v1/places/${placeId}`, + `fetchGooglePhotoRefs(${placeId})`, + { headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' } }, + ); + if (!res.ok) return []; + const data = (await res.json()) as GooglePlaceDetails; + return (data.photos ?? []).slice(0, cap).map((photo) => ({ + name: photo.name, + attribution: photo.authorAttributions?.[0]?.displayName || null, + })); + } catch { + return []; + } + } + + /** Image bytes for one photo reference. Null on any miss; the caller skips it. */ + async fetchGooglePhotoBytes(photoName: string, apiKey: string, maxHeightPx = 400): Promise { + try { + const res = await googleFetch( + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=${maxHeightPx}`, + `fetchGooglePhotoBytes(${photoName})`, + { headers: { 'X-Goog-Api-Key': apiKey } }, + ); + if (!res.ok) return null; + const bytes = Buffer.from(await res.arrayBuffer()); + return bytes.length ? bytes : null; + } catch { + return null; + } + } + + /** + * Google's editorial summary, on its own. + * + * getPlaceDetailsExpanded would also return this, but its field mask includes + * `reviews`, which moves the call into the Enterprise SKU. Enrichment only + * wants the sentence, so it asks for the sentence. + */ + async fetchEditorialSummary(placeId: string, apiKey: string, lang?: string): Promise { + if (!isGooglePlaceId(placeId)) return null; + try { + const res = await googleFetch( + `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( diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 462e840f7..2badea9ff 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -20,6 +20,8 @@ import { buildUserAgent, resolveOverpassEndpoints, resolveOverpassTimeoutMs, + stripWikiMarkup, + parseWikipediaTag, } from '../../../src/nest/maps/maps.helpers'; import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; @@ -102,7 +104,7 @@ const photoCacheStub = { import { db } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; -import { MapsService } from '../../../src/nest/maps/maps.service'; +import { MapsService, withPhotoFetchSlot } from '../../../src/nest/maps/maps.service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; // The service under test, constructed over the mocked db stub — DatabaseService @@ -2254,3 +2256,383 @@ describe('controller-facing wrappers delegate to the folded methods', () => { // its last two consumers — the legacy placeEnrichment helper and the places MCP // registrar — both folded into the DI-native places domain, which injects // MapsService directly. + +// ── Enrichment primitives (MAPS-111..) ─────────────────────────────────────── + +describe('stripWikiMarkup', () => { + it('MAPS-111: reduces a Commons author fragment to plain text', () => { + expect(stripWikiMarkup('Alice')).toBe('Alice'); + expect(stripWikiMarkup('Bob / Studio')).toBe('Bob / Studio'); + }); + + it('MAPS-112: turns empty, absent and markup-only values into null', () => { + expect(stripWikiMarkup(undefined)).toBeNull(); + expect(stripWikiMarkup(null)).toBeNull(); + expect(stripWikiMarkup('')).toBeNull(); + expect(stripWikiMarkup(' ')).toBeNull(); + expect(stripWikiMarkup('')).toBeNull(); + }); +}); + +describe('parseWikipediaTag', () => { + it('MAPS-113: splits the "lang:Title" spelling', () => { + expect(parseWikipediaTag('de:Museum Ludwig')).toEqual({ lang: 'de', title: 'Museum Ludwig' }); + expect(parseWikipediaTag(' en:Eiffel Tower ')).toEqual({ lang: 'en', title: 'Eiffel Tower' }); + // Article titles may themselves contain a colon ("de:Portal:Köln"). + expect(parseWikipediaTag('de:Portal:Köln')).toEqual({ lang: 'de', title: 'Portal:Köln' }); + expect(parseWikipediaTag('zh-yue:香港')).toEqual({ lang: 'zh-yue', title: '香港' }); + }); + + it('MAPS-114: refuses spellings that would send us to the wrong wiki', () => { + // A bare title has no language, and guessing one picks an article at random. + expect(parseWikipediaTag('Museum Ludwig')).toBeNull(); + expect(parseWikipediaTag('de:')).toBeNull(); + expect(parseWikipediaTag('')).toBeNull(); + expect(parseWikipediaTag(undefined)).toBeNull(); + }); +}); + +describe('buildOsmDetails wiki tags', () => { + it('MAPS-115: carries wikipedia/wikidata through instead of dropping them', () => { + const withTags = buildOsmDetails({ wikipedia: 'de:Museum Ludwig', wikidata: 'Q160236' }, 'way', '42'); + expect(withTags.wikipedia).toBe('de:Museum Ludwig'); + expect(withTags.wikidata).toBe('Q160236'); + + const without = buildOsmDetails({ name: 'Kiosk' }, 'node', '7'); + expect(without.wikipedia).toBeNull(); + expect(without.wikidata).toBeNull(); + }); +}); + +describe('isGooglePlaceId with enrichment candidate keys', () => { + it('MAPS-116: rejects "~pN" keys so a candidate never reaches Google', () => { + // Google bills the 400 it answers these with — see NON_GOOGLE_PLACE_ID. + expect(isGooglePlaceId('ChIJN1t_tDeuEmsRUsoyG83frY4~p0')).toBe(false); + expect(isGooglePlaceId('ChIJN1t_tDeuEmsRUsoyG83frY4~p12')).toBe(false); + // The bare id still resolves, and a tilde that is not a candidate suffix does not disarm it. + expect(isGooglePlaceId('ChIJN1t_tDeuEmsRUsoyG83frY4')).toBe(true); + expect(isGooglePlaceId('ChIJabc~photo')).toBe(true); + }); +}); + +describe('fetchCommonsCandidates (fetch stubbed)', () => { + const page = (over: Record = {}) => ({ + imageinfo: [ + { + url: 'https://commons.org/original.jpg', + thumburl: 'https://commons.org/thumb.jpg', + mime: 'image/jpeg', + descriptionurl: 'https://commons.wikimedia.org/wiki/File:X.jpg', + extmetadata: { + Artist: { value: 'Alice' }, + LicenseShortName: { value: 'CC BY-SA 4.0' }, + LicenseUrl: { value: 'https://creativecommons.org/licenses/by-sa/4.0/' }, + }, + ...over, + }, + ], + }); + + it('MAPS-117: returns every usable image with its licence metadata', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: { pages: { '1': page(), '2': page({ thumburl: 'https://commons.org/t2.jpg' }) } } }), + }), + ); + const out = await svc.fetchCommonsCandidates(48.8, 2.3, 5); + expect(out).toHaveLength(2); + expect(out[0]).toEqual({ + photoUrl: 'https://commons.org/thumb.jpg', + attribution: 'Alice', + license: 'CC BY-SA 4.0', + licenseUrl: 'https://creativecommons.org/licenses/by-sa/4.0/', + sourceUrl: 'https://commons.wikimedia.org/wiki/File:X.jpg', + }); + }); + + it('MAPS-118: falls back to UsageTerms when there is no short licence name', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { + pages: { + '1': page({ + extmetadata: { UsageTerms: { value: 'Public domain' } }, + }), + }, + }, + }), + }), + ); + const out = await svc.fetchCommonsCandidates(48.8, 2.3); + expect(out[0].license).toBe('Public domain'); + expect(out[0].attribution).toBeNull(); + expect(out[0].licenseUrl).toBeNull(); + }); + + it('MAPS-119: skips SVGs, PDFs and entries without a URL', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { + pages: { + '1': page({ mime: 'image/svg+xml' }), + '2': page({ mime: 'application/pdf' }), + '3': { imageinfo: [{ mime: 'image/jpeg' }] }, + '4': {}, + '5': page({ mime: 'image/png', thumburl: undefined }), + }, + }, + }), + }), + ); + const out = await svc.fetchCommonsCandidates(48.8, 2.3); + expect(out).toHaveLength(1); + expect(out[0].photoUrl).toBe('https://commons.org/original.jpg'); + }); + + it('MAPS-120: honours the limit and clamps it into the range Commons accepts', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: { pages: { '1': page(), '2': page(), '3': page() } } }), + }); + vi.stubGlobal('fetch', fetchMock); + + expect(await svc.fetchCommonsCandidates(48.8, 2.3, 2)).toHaveLength(2); + expect(String(fetchMock.mock.calls[0][0])).toContain('ggslimit=2'); + + await svc.fetchCommonsCandidates(48.8, 2.3, 0); + expect(String(fetchMock.mock.calls[1][0])).toContain('ggslimit=1'); + + await svc.fetchCommonsCandidates(48.8, 2.3, 99); + expect(String(fetchMock.mock.calls[2][0])).toContain('ggslimit=20'); + }); + + it('MAPS-121: returns an empty list on a bad response, missing pages or a throw', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })); + expect(await svc.fetchCommonsCandidates(1, 2)).toEqual([]); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: {} }) })); + expect(await svc.fetchCommonsCandidates(1, 2)).toEqual([]); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + expect(await svc.fetchCommonsCandidates(1, 2)).toEqual([]); + }); +}); + +describe('fetchWikipediaExtract (fetch stubbed)', () => { + it('MAPS-122: reads the lead paragraph from the wiki the tag names', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + query: { pages: { '123': { title: 'Museum Ludwig', extract: ' Das Museum Ludwig ist ein Museum. ' } } }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const out = await svc.fetchWikipediaExtract('de:Museum Ludwig'); + expect(out).toEqual({ + text: 'Das Museum Ludwig ist ein Museum.', + sourceUrl: 'https://de.wikipedia.org/wiki/Museum%20Ludwig', + }); + // The host follows the tag, not a hard-coded 'en'. + expect(String(fetchMock.mock.calls[0][0])).toContain('https://de.wikipedia.org/w/api.php'); + }); + + it('MAPS-123: prefers the resolved title so a redirect links to where it landed', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ query: { pages: { '9': { title: 'Eiffel Tower', extract: 'A tower.' } } } }), + }), + ); + const out = await svc.fetchWikipediaExtract('en:Eiffelturm'); + expect(out!.sourceUrl).toBe('https://en.wikipedia.org/wiki/Eiffel%20Tower'); + }); + + it('MAPS-124: returns null without calling out when the tag has no language', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + expect(await svc.fetchWikipediaExtract('Museum Ludwig')).toBeNull(); + expect(await svc.fetchWikipediaExtract(null)).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('MAPS-125: treats a missing article, a bad response and a throw as no description', async () => { + // A missing article comes back as a page with no extract, not as a 404. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: { '-1': { title: 'X' } } } }) }), + ); + expect(await svc.fetchWikipediaExtract('de:X')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + expect(await svc.fetchWikipediaExtract('de:X')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })); + expect(await svc.fetchWikipediaExtract('de:X')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + expect(await svc.fetchWikipediaExtract('de:X')).toBeNull(); + }); +}); + +describe('fetchGooglePhotoRefs (fetch stubbed)', () => { + it('MAPS-126: returns capped references with their author attribution', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + photos: [ + { name: 'places/p/photos/a', authorAttributions: [{ displayName: 'Alice' }] }, + { name: 'places/p/photos/b', authorAttributions: [] }, + { name: 'places/p/photos/c' }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const out = await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 2); + expect(out).toEqual([ + { name: 'places/p/photos/a', attribution: 'Alice' }, + { name: 'places/p/photos/b', attribution: null }, + ]); + // One billed Details call for the whole strip, and only the photos field. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].headers['X-Goog-FieldMask']).toBe('photos'); + }); + + 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(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([]); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + expect(await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + expect(await svc.fetchGooglePhotoRefs('ChIJabc', 'key', 3)).toEqual([]); + }); +}); + +describe('fetchGooglePhotoBytes (fetch stubbed)', () => { + it('MAPS-129: downloads the media for one reference at the requested height', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + }); + vi.stubGlobal('fetch', fetchMock); + + const bytes = await svc.fetchGooglePhotoBytes('places/p/photos/a', 'key', 600); + expect(bytes).toBeInstanceOf(Buffer); + expect(bytes!.length).toBe(3); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'https://places.googleapis.com/v1/places/p/photos/a/media?maxHeightPx=600', + ); + }); + + 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(); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(0) })); + expect(await svc.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(); + }); +}); + +describe('fetchEditorialSummary (fetch stubbed)', () => { + it('MAPS-131: asks only for the summary, not for reviews', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ editorialSummary: { text: ' A museum in Cologne. ' } }), + }); + vi.stubGlobal('fetch', fetchMock); + + expect(await svc.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'); + }); + + 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(fetchMock).not.toHaveBeenCalled(); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })); + expect(await svc.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + expect(await svc.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + expect(await svc.fetchEditorialSummary('ChIJabc', 'key')).toBeNull(); + }); +}); + +describe('withPhotoFetchSlot', () => { + it('MAPS-133: caps concurrent fetches at five and lets queued work through', async () => { + // Settling a release hands the slot to a queued run, which then parks on its + // own barrier — several microtasks later. Draining needs a real turn of the + // loop between waves, not a bare `await Promise.resolve()`. + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + + let active = 0; + let peak = 0; + let started = 0; + const release: Array<() => void> = []; + + const runs = Array.from({ length: 8 }, () => + withPhotoFetchSlot(async () => { + started++; + active++; + peak = Math.max(peak, active); + await new Promise((resolve) => release.push(resolve)); + active--; + return 'ok'; + }), + ); + + await tick(); + expect(started).toBe(5); + expect(peak).toBe(5); + + while (release.length) { + release.shift()!(); + await tick(); + } + + expect(await Promise.all(runs)).toEqual(Array(8).fill('ok')); + expect(started).toBe(8); + // The cap held across the whole run, not just the first wave. + expect(peak).toBe(5); + }); + + it('MAPS-134: releases the slot when the work throws', async () => { + await expect( + withPhotoFetchSlot(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + // The slot came back, so the next caller runs immediately. + await expect(withPhotoFetchSlot(async () => 'free')).resolves.toBe('free'); + }); +});