Let the maps providers hand back candidates, not just one picture

Commons geosearch already returned five files and we threw four away, so a
whole strip costs nothing extra. It now comes back with the licence metadata
too: without LicenseShortName and the file description page there is no way to
credit a CC BY-SA image, and a picker full of them needs that.

Wikipedia descriptions resolve through the OSM wikipedia tag rather than
guessing the article from the place name, which lands on the wrong article for
anything ambiguous. Google photo references and photo bytes are separate calls
now so a caller can list without downloading, and the editorial summary has its
own lean field mask - asking for it through the expanded details would drag
reviews along and move the call into the pricier tier.
This commit is contained in:
Maurice
2026-08-09 22:31:10 +02:00
committed by Maurice
parent 4cd50e6ed3
commit 23e76cc3ec
3 changed files with 613 additions and 17 deletions
+41 -4
View File
@@ -333,19 +333,56 @@ export function buildOsmDetails(tags: Record<string, string>, 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 <a> to the uploader's user page, a licence can carry <span> 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(/&nbsp;/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 "<placeId>~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;
+189 -12
View File
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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<CommonsCandidate[]> {
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<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> };
};
if (!res.ok) return [];
const data = (await res.json()) as { query?: { pages?: Record<string, WikiCommonsPage> } };
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<string, { title?: string; extract?: string }> } };
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<Buffer | null> {
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<string | null> {
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(
+383 -1
View File
@@ -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('<a href="/wiki/User:Alice" title="User:Alice">Alice</a>')).toBe('Alice');
expect(stripWikiMarkup('<span class="fn">Bob</span>&nbsp;/&nbsp;<i>Studio</i>')).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('<span></span>')).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 "<placeId>~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<string, unknown> = {}) => ({
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: '<a href="/wiki/User:Alice">Alice</a>' },
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: '<b>Public domain</b>' } },
}),
},
},
}),
}),
);
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<void>((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<void>((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');
});
});