diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index a53239e50..e4c2e2052 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -160,6 +160,9 @@ export class MapsController { if (!placeId.startsWith('coords:') && this.maps.photosDisabled()) { return { photoUrl: null }; } + // A place with no photo resolves to the same { photoUrl: null } body. It is an + // empty result, not a missing resource, and one 404 per photo-less place gets + // the user's IP banned by any 404-rate IPS in front of TREK (#1727). try { return await this.maps.photo(user.id, placeId, parseFloat(lat as string), parseFloat(lng as string), name); } catch (err: unknown) { @@ -173,24 +176,37 @@ export class MapsController { placePhotoBytes(@Param('placeId') placeId: string, @Res() res: Response): void { const fp = this.maps.photoBytesPath(placeId); if (!fp) { - res.status(404).json({ error: 'Photo not cached' }); + // Same reasoning as the JSON endpoint above, and the bigger half of #1727: + // places keep this URL in image_url, so a trip render asks for one photo per + // place and every entry the cache sweep dropped answered 404 — a whole map + // worth of them from one IP in one second. An empty 204 leaves the + // with nothing to show, exactly like the 404 did. + this.emptyPhoto(res); return; } // Stream the cached file directly instead of res.sendFile(): the send library // bundled under @nestjs/platform-express rejects absolute Windows paths (drive // letter, no `root`) with a NotFoundError that surfaced as an unhandled 500, // even though the file exists. A plain read stream serves the bytes - // cross-platform; a read error still yields the legacy 404. Cached photos are - // always JPEG (placePhotoCache writes `.jpg`). + // cross-platform. Cached photos are always JPEG (placePhotoCache writes + // `.jpg`). res.set('Cache-Control', 'public, max-age=2592000, immutable'); res.type('image/jpeg'); const stream = createReadStream(fp); stream.on('error', () => { - if (!res.headersSent) res.status(404).json({ error: 'Photo not cached' }); + if (!res.headersSent) this.emptyPhoto(res); }); stream.pipe(res); } + // 204 for "no bytes to serve". Overrides the immutable Cache-Control the hit + // path already set — a photo that reappears in the cache must not stay hidden + // behind a month-old empty response. + private emptyPhoto(res: Response): void { + res.set('Cache-Control', 'no-store'); + res.status(204).end(); + } + @Get('reverse') async reverse( @Query('lat') lat?: string, diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index f2abe6103..63e46e20f 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -61,9 +61,22 @@ interface GoogleAutocompleteSuggestion { }; } +interface GoogleTimePoint { + day?: number; + hour?: number; + minute?: number; +} + +interface GoogleOpeningHours { + weekdayDescriptions?: string[]; + openNow?: boolean; + periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[]; + specialDays?: { date?: { year?: number; month?: number; day?: number } }[]; +} + interface GooglePlaceDetails extends GooglePlaceResult { userRatingCount?: number; - regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean }; + regularOpeningHours?: GoogleOpeningHours; editorialSummary?: { text: string }; reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; @@ -531,10 +544,107 @@ export async function searchOverpassPois( // ── Opening hours parsing ──────────────────────────────────────────────────── -export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } { +// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday. +// The weekday descriptions next to them are display text in the requested language and +// cannot be parsed reliably; these can. A period without `close` is Google's way of +// saying the place never closes. +export interface OpeningTimePoint { + day: number; + hour: number; + minute: number; +} +export interface OpeningPeriod { + open: OpeningTimePoint; + close: OpeningTimePoint | null; +} + +// Places (New) speaks proto3 JSON, which leaves out fields that hold the default +// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes. +function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null { + if (!point || typeof point !== 'object') return null; + const field = (value: unknown): number | null => { + if (value === undefined || value === null) return 0; + return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null; + }; + const day = field(point.day); + const hour = field(point.hour); + const minute = field(point.minute); + if (day === null || hour === null || minute === null) return null; + if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { day, hour, minute }; +} + +export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null { + if (!Array.isArray(periods) || periods.length === 0) return null; + const result: OpeningPeriod[] = []; + for (const period of periods) { + const open = toTimePoint(period?.open); + if (!open) continue; + // A missing close means "never closes"; a close that is there but unusable makes + // the whole period a guess, so it is dropped rather than read as round-the-clock. + const close = period?.close == null ? null : toTimePoint(period.close); + if (period?.close != null && !close) continue; + result.push({ open, close }); + } + return result.length > 0 ? result : null; +} + +// Holidays and other exceptional days that the weekly periods do not describe. Google +// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can +// compare them against the place's own local date. +export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null { + if (!Array.isArray(specialDays) || specialDays.length === 0) return null; + const dates: string[] = []; + for (const entry of specialDays) { + const date = entry?.date; + if (!date) continue; + const { year, month, day } = date; + if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue; + if (month < 1 || month > 12 || day < 1 || day > 31) continue; + const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + if (!dates.includes(iso)) dates.push(iso); + } + return dates.length > 0 ? dates : null; +} + +// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses. +const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g; + +/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */ +function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] { + const day = (dayIdx + 1) % 7; + const periods: OpeningPeriod[] = []; + for (const match of timePart.matchAll(OSM_TIME_RANGE)) { + const openHour = parseInt(match[1], 10); + const openMinute = parseInt(match[2], 10); + let closeHour = parseInt(match[3], 10); + const closeMinute = parseInt(match[4], 10); + if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue; + // OSM writes the end of a day as 24:00, a clock reading Google's numbering has no + // hour 24 — that is midnight of the following day, same as any range that wraps. + let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute; + if (closeHour === 24) { + if (closeMinute !== 0) continue; + closeHour = 0; + nextDay = true; + } + periods.push({ + open: { day, hour: openHour, minute: openMinute }, + close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute }, + }); + } + return periods; +} + +export function parseOpeningHours(ohString: string): { + weekdayDescriptions: string[]; + openNow: boolean | null; + periods: OpeningPeriod[]; +} { const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const result: string[] = LONG.map((d) => `${d}: ?`); + const periods: OpeningPeriod[] = []; // Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00" for (const segment of ohString.split(';')) { @@ -560,6 +670,7 @@ export function parseOpeningHours(ohString: string): { weekdayDescriptions: stri } for (const idx of dayIndices) { result[idx] = `${LONG[idx]}: ${timePart.trim()}`; + periods.push(...osmPeriods(idx, timePart)); } } @@ -583,7 +694,7 @@ export function parseOpeningHours(ohString: string): { weekdayDescriptions: stri /* best effort */ } - return { weekdayDescriptions: result, openNow }; + return { weekdayDescriptions: result, openNow, periods }; } // ── Build standardized OSM details ─────────────────────────────────────────── @@ -591,12 +702,14 @@ export function parseOpeningHours(ohString: string): { weekdayDescriptions: stri export function buildOsmDetails(tags: Record, osmType: string, osmId: string) { let opening_hours: string[] | null = null; let open_now: boolean | null = null; + let opening_periods: OpeningPeriod[] | null = null; if (tags.opening_hours) { const parsed = parseOpeningHours(tags.opening_hours); const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?')); if (hasData) { opening_hours = parsed.weekdayDescriptions; open_now = parsed.openNow; + opening_periods = parsed.periods.length > 0 ? parsed.periods : null; } } return { @@ -604,6 +717,7 @@ export function buildOsmDetails(tags: Record, osmType: string, o phone: tags['contact:phone'] || tags.phone || null, opening_hours, open_now, + opening_periods, osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, summary: tags.description || null, source: 'openstreetmap' as const, @@ -834,6 +948,21 @@ async function autocompleteNominatim( // ── Place details (Google or OSM) ──────────────────────────────────────────── +// 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; +// The subset that still has a provider behind it — Overpass for details, +// Wikimedia for photos. +const OSM_PLACE_ID = /^(?:node|way|relation):/i; + +export function isGooglePlaceId(placeId: string): boolean { + return !NON_GOOGLE_PLACE_ID.test(placeId); +} + export async function getPlaceDetails( userId: number, placeId: string, @@ -912,6 +1041,11 @@ export async function getPlaceDetails( phone: data.nationalPhoneNumber || null, opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, open_now: data.regularOpeningHours?.openNow ?? null, + // open_now is a snapshot Google took when this payload was fetched and it is cached + // for days; the periods let the client recompute the state in the place's own + // timezone, which the localised weekday lines above cannot do. Issue #1680. + opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods), + opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays), google_maps_url: data.googleMapsUri || null, summary: null, reviews: [], @@ -935,7 +1069,16 @@ export async function getPlaceDetailsExpanded( placeId: string, lang?: string, refresh = false, -): Promise<{ place: Record }> { +): Promise<{ place: Record | 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. + if (!isGooglePlaceId(placeId)) { + return OSM_PLACE_ID.test(placeId) ? getPlaceDetails(userId, placeId, lang) : { place: null }; + } + const langKey = toApiLang(lang, 'de'); const apiKey = getMapsKey(userId); if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); @@ -982,6 +1125,8 @@ export async function getPlaceDetailsExpanded( phone: data.nationalPhoneNumber || null, opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, open_now: data.regularOpeningHours?.openNow ?? null, + opening_periods: normalizeOpeningPeriods(data.regularOpeningHours?.periods), + opening_special_days: normalizeSpecialDays(data.regularOpeningHours?.specialDays), google_maps_url: data.googleMapsUri || null, summary: data.editorialSummary?.text || null, reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable[number]) => ({ @@ -1014,29 +1159,38 @@ export async function getPlacePhoto( lat: number, lng: number, name?: string, -): Promise<{ photoUrl: string; attribution: string | null }> { +): Promise<{ photoUrl: string | null; attribution: string | null }> { // Disk cache hit — serve immediately, no Google call const diskHit = placePhotoCache.get(placeId); if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution }; - // Recent error — don't hammer the API - if (placePhotoCache.getErrored(placeId)) { - throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); - } + // "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 (placePhotoCache.getErrored(placeId)) return noPhoto; // Deduplicate concurrent requests for the same placeId const existing = placePhotoCache.getInFlight(placeId); if (existing) { const result = await existing; - if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); + 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<{ filePath: string; attribution: string | null } | null> => { await acquirePhotoFetchSlot(); try { const apiKey = getMapsKey(userId); - const isCoordLookup = placeId.startsWith('coords:'); // Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only // (right-click) places and as a fallback when a Google place yields no photo, @@ -1050,21 +1204,25 @@ export async function getPlacePhoto( // Follow redirects manually so each hop (the image URL can 3xx to a CDN // host) is re-validated against the SSRF guard, not just the first URL. const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true }); - if (!imgRes.ok) return null; + if (!imgRes.ok) { + providerFailed = true; + return null; + } const bytes = Buffer.from(await imgRes.arrayBuffer()); const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution); return { filePath: cached.filePath, attribution: cached.attribution }; } catch { + providerFailed = true; return null; } }; - // Google Places photo for a Google place_id. Returns null (without marking an - // error) on any miss — no key, URL-shaped id, request rejected, no photos, or - // a failed media download — so the caller can fall back to Wikimedia. + // 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<{ filePath: string; attribution: string | null } | null> => { - // URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url - if (!apiKey || /^https?:\/\//i.test(placeId)) return null; + if (!apiKey) return null; // Fetch details to get the photo name const detailsRes = await googleFetch( @@ -1080,12 +1238,14 @@ export async function getPlacePhoto( const body = await detailsRes.text(); if (!detailsRes.ok) { console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200)); + providerFailed = true; return null; } let details: GooglePlaceDetails & { error?: { message?: string } }; try { details = body ? JSON.parse(body) : { photos: [] }; } catch { + providerFailed = true; return null; } if (!details.photos?.length) return null; @@ -1100,10 +1260,17 @@ export async function getPlacePhoto( `getPlacePhoto/media(${placeId})`, { headers: { 'X-Goog-Api-Key': apiKey } }, ); - if (!mediaRes.ok) return null; + // The place does have a photo — only the download for it went wrong. + if (!mediaRes.ok) { + providerFailed = true; + return null; + } const bytes = Buffer.from(await mediaRes.arrayBuffer()); - if (!bytes.length) return null; + if (!bytes.length) { + providerFailed = true; + return null; + } const cached = await placePhotoCache.put(placeId, bytes, attribution); @@ -1121,8 +1288,8 @@ export async function getPlacePhoto( // 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. Coordinate-only ids skip Google entirely. - if (!isCoordLookup) { + // places use. Ids Google cannot resolve skip it entirely. + if (isGooglePlaceId(placeId)) { const googlePhoto = await fetchGooglePhoto(); if (googlePhoto) return googlePhoto; } @@ -1130,7 +1297,7 @@ export async function getPlacePhoto( const fallback = await fetchWikimediaFallback(); if (fallback) return fallback; - placePhotoCache.markError(placeId); + placePhotoCache.markError(placeId, providerFailed ? 'provider-error' : 'no-photo'); return null; } finally { releasePhotoFetchSlot(); @@ -1140,7 +1307,7 @@ export async function getPlacePhoto( placePhotoCache.setInFlight(placeId, fetchPromise); const result = await fetchPromise; - if (!result) throw Object.assign(new Error('No photo available'), { status: 404 }); + if (!result) return noPhoto; return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; } diff --git a/server/src/services/placeEnrichment.ts b/server/src/services/placeEnrichment.ts index 28e56b7b4..693aa1053 100644 --- a/server/src/services/placeEnrichment.ts +++ b/server/src/services/placeEnrichment.ts @@ -121,8 +121,9 @@ async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, WHERE id = ? AND trip_id = ?`, ).run(gpid, gftid, str(match.address), str(match.website), str(match.phone), place.id, tripId); - // Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in - // that case — a missing photo must never abort the rest of the enrichment. + // Photo is best-effort: Google often has none, in which case getPlacePhoto + // resolves with photoUrl: null. A missing photo (or a provider outage, which + // still throws) must never abort the rest of the enrichment. try { const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name); if (photo?.photoUrl) { diff --git a/server/src/services/placePhotoCache.ts b/server/src/services/placePhotoCache.ts index 7a738b9b4..93e7f21ec 100644 --- a/server/src/services/placePhotoCache.ts +++ b/server/src/services/placePhotoCache.ts @@ -10,7 +10,22 @@ import path from 'node:path'; // Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches // the real uploads tree. const GOOGLE_PHOTO_DIR = readEnv().paths.placePhotoDir || path.join(__dirname, '../../uploads/photos/google'); -const ERROR_TTL = 5 * 60 * 1000; +// How long a "no photo for this place" answer stays remembered. Nothing about it +// changes until a photo appears upstream, so it is worth keeping: without it every +// trip open replays the whole provider fan-out, one lookup per place. A day is +// still short enough that a newly configured API key takes effect overnight. +// Entries are dropped outright when the place goes away (removeIfUnreferenced / +// sweepOrphans). +const MISSING_TTL = 24 * 60 * 60 * 1000; +// A provider call that failed says nothing about the place — only that the API was +// unreachable or unhappy just now. It is remembered just long enough to stop a +// screenful of markers from hammering an API that is down, and only in memory, so +// a restart retries right away. +const FAILURE_TTL = 5 * 60 * 1000; +const recentFailures = new Map(); +// Sweep expired failures once the map grows past this. Whatever survives the sweep +// is younger than FAILURE_TTL and expires on its own shortly after. +const FAILURE_SWEEP_AT = 500; // Marker photos are displayed tiny — cap stored images so an oversized source // (e.g. a Wikimedia Commons full-res original) can't bloat the cache. Matches @@ -72,15 +87,36 @@ export function get(placeId: string): CachedPhoto | null { } export function getErrored(placeId: string): boolean { + const failedAt = recentFailures.get(placeId); + if (failedAt !== undefined) { + if (Date.now() - failedAt < FAILURE_TTL) return true; + recentFailures.delete(placeId); + } + const row = db .prepare('SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL') .get(placeId) as { error_at: number } | undefined; if (!row) return false; - return Date.now() - row.error_at < ERROR_TTL; + return Date.now() - row.error_at < MISSING_TTL; } -export function markError(placeId: string): void { +// Remember that a lookup came back empty. 'no-photo' is the lasting answer — no +// provider has an image for this place — and is persisted; 'provider-error' is a +// failed attempt and is only held in memory for a few minutes. +export function markError(placeId: string, kind: 'no-photo' | 'provider-error' = 'no-photo'): void { + if (kind === 'provider-error') { + if (recentFailures.size >= FAILURE_SWEEP_AT) { + const cutoff = Date.now() - FAILURE_TTL; + for (const [id, at] of recentFailures) { + if (at <= cutoff) recentFailures.delete(id); + } + } + recentFailures.set(placeId, Date.now()); + return; + } + + recentFailures.delete(placeId); knownOnDisk.delete(placeId); db.prepare( 'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)', @@ -111,6 +147,7 @@ export async function put(placeId: string, bytes: Buffer, attribution: string | await fsPromises.rename(tmp, fp); knownOnDisk.add(placeId); + recentFailures.delete(placeId); db.prepare( 'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)', diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index 1540b30cd..fe937aa8b 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -265,17 +265,45 @@ describe('Maps happy paths (mocked service)', () => { it('MAPS-004 — getPlacePhoto error with status returns that status', async () => { const { user } = createUser(testDb); vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce( - Object.assign(new Error('Photo not found'), { status: 404 }) + Object.assign(new Error('Photo provider unavailable'), { status: 503 }) ); const res = await request(app) .get('/api/maps/place-photo/some-place-id?lat=48.8&lng=2.3') .set('Cookie', authCookie(user.id)); - expect(res.status).toBe(404); + expect(res.status).toBe(503); expect(res.body).toHaveProperty('error'); }); + // A photo-less place must answer 200 { photoUrl: null }: one 404 per place turns + // a normal itinerary render into a 404 storm that fail2ban/CrowdSec bans (#1727). + it('MAPS-004b — a place with no photo answers 200 with photoUrl null', async () => { + const { user } = createUser(testDb); + vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({ photoUrl: null, attribution: null }); + + const res = await request(app) + .get('/api/maps/place-photo/node%3A5255005321?lat=36.76&lng=-3.84&name=Nerja') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ photoUrl: null, attribution: null }); + }); + + // Places keep the bytes URL in image_url, so an evicted cache entry means one + // request per place on the next trip render — the second half of the same ban + // vector. Nothing to serve is an empty 204, not a 404. + it('MAPS-004c — the bytes proxy answers 204 for a photo that is not cached', async () => { + const { user } = createUser(testDb); + + const res = await request(app) + .get('/api/maps/place-photo/ChIJnot-cached/bytes') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(204); + expect(res.text).toBeFalsy(); + }); + it('MAPS-005 — reverseGeocode error returns null values', async () => { const { user } = createUser(testDb); vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed')); diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index a3d9ec18c..c269ae2c0 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -210,12 +210,20 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { }); it('maps a 4xx service error', async () => { - const photo = vi.fn().mockRejectedValue(withError(404, 'No photo available')); + const photo = vi.fn().mockRejectedValue(withError(429, 'Rate limited')); expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ - status: 404, body: { error: 'No photo available' }, + status: 429, body: { error: 'Rate limited' }, }); }); + // A place without a photo is an empty result, not a 404 — one 404 per photo-less + // place gets the user banned by any 404-rate IPS in front of TREK (#1727). + it('passes a photo-less place through as a 200 with photoUrl null', async () => { + const photo = vi.fn().mockResolvedValue({ photoUrl: null, attribution: null }); + const res = await makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'node:123', '1', '2'); + expect(res).toEqual({ photoUrl: null, attribution: null }); + }); + it('logs and maps a 5xx service error', async () => { const photo = vi.fn().mockRejectedValue(withError(502, 'Upstream failed')); expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({ @@ -244,17 +252,22 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { json: vi.fn(), set: vi.fn(), type: vi.fn(), + end: vi.fn(), }; - return res as unknown as Response & { status: ReturnType; json: ReturnType; set: ReturnType; type: ReturnType }; + return res as unknown as Response & { status: ReturnType; json: ReturnType; set: ReturnType; type: ReturnType; end: ReturnType }; } beforeEach(() => createReadStream.mockReset()); - it('404 when the photo is not cached', () => { + // Places persist this URL in image_url, so an evicted cache entry means one + // request per place on a trip render. 404 for each of them is the ban vector + // from #1727 — an uncached photo answers 204 with an empty body instead. + it('204 without a body when the photo is not cached', () => { const res = makeRes(); makeController({ photoBytesPath: () => null }).placePhotoBytes('p1', res); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' }); + expect(res.status).toHaveBeenCalledWith(204); + expect(res.end).toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); expect(createReadStream).not.toHaveBeenCalled(); }); @@ -269,18 +282,22 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { expect(stream.pipe).toHaveBeenCalledWith(res); }); - it('falls back to 404 when the read stream errors', () => { + it('falls back to an empty 204 when the read stream errors', () => { let onError: () => void = () => {}; const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() }; createReadStream.mockReturnValue(stream); const res = makeRes(); makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res); onError(); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' }); + expect(res.status).toHaveBeenCalledWith(204); + expect(res.end).toHaveBeenCalled(); + // The hit path already asked for a month of immutable caching — that header + // must not survive onto the empty answer, or the photo stays hidden once it + // is back in the cache. + expect(res.set).toHaveBeenLastCalledWith('Cache-Control', 'no-store'); }); - it('does not re-send a 404 when the stream errors after headers were flushed', () => { + it('does not re-send a 204 when the stream errors after headers were flushed', () => { let onError: () => void = () => {}; const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() }; createReadStream.mockReturnValue(stream); @@ -289,7 +306,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res); onError(); expect(res.status).not.toHaveBeenCalled(); - expect(res.json).not.toHaveBeenCalled(); + expect(res.end).not.toHaveBeenCalled(); }); }); diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 276357159..0e12f639d 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -8,9 +8,12 @@ */ import { parseOpeningHours, + normalizeOpeningPeriods, + normalizeSpecialDays, buildOsmDetails, getMapsKey, googleFtidFromMapsUrl, + isGooglePlaceId, buildUserAgent, resolveOverpassEndpoints, resolveOverpassTimeoutMs, @@ -25,6 +28,7 @@ const { mockCheckSsrf, mockCacheGet, mockCacheGetErrored, + mockCacheMarkError, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight, @@ -34,6 +38,7 @@ const { mockCheckSsrf: vi.fn(async () => ({ allowed: true })), mockCacheGet: vi.fn(() => null as any), mockCacheGetErrored: vi.fn(() => false), + mockCacheMarkError: vi.fn(), mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, filePath: `/tmp/${placeId}.jpg`, @@ -83,7 +88,7 @@ vi.mock('../../../src/services/placePhotoCache', () => ({ get: (placeId: string) => mockCacheGet(placeId), getErrored: (placeId: string) => mockCacheGetErrored(placeId), put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution), - markError: vi.fn(), + markError: (placeId: string, kind?: string) => mockCacheMarkError(placeId, kind), getInFlight: (placeId: string) => mockCacheGetInFlight(placeId), setInFlight: (placeId: string, p: Promise) => mockCacheSetInFlight(placeId, p), serveFilePath: vi.fn(() => null), @@ -100,6 +105,7 @@ afterEach(() => { mockCacheGet.mockReturnValue(null); mockCacheGetErrored.mockReset(); mockCacheGetErrored.mockReturnValue(false); + mockCacheMarkError.mockReset(); mockCachePut.mockReset(); mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, @@ -157,6 +163,84 @@ describe('parseOpeningHours', () => { const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(100); }); + + it('MAPS-007b: emits machine-readable periods in Google day numbering (Sunday = 0)', () => { + const result = parseOpeningHours('Mo 09:00-18:00; Su 10:00-14:00'); + expect(result.periods).toEqual([ + { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, + { open: { day: 0, hour: 10, minute: 0 }, close: { day: 0, hour: 14, minute: 0 } }, + ]); + }); + + it('MAPS-007c: a period past midnight closes on the following day', () => { + const result = parseOpeningHours('Sa 20:00-02:00'); + expect(result.periods).toEqual([ + { open: { day: 6, hour: 20, minute: 0 }, close: { day: 0, hour: 2, minute: 0 } }, + ]); + }); + + it('MAPS-007d: the OSM 24:00 spelling becomes midnight of the next day', () => { + // Google's clock has no hour 24, so "00:00-24:00" is a full day, not a rejected range. + const result = parseOpeningHours('Mo 00:00-24:00'); + expect(result.periods).toEqual([ + { open: { day: 1, hour: 0, minute: 0 }, close: { day: 2, hour: 0, minute: 0 } }, + ]); + }); + + it('MAPS-007e: unusable clock values produce no period', () => { + expect(parseOpeningHours('Mo 09:75-18:00').periods).toEqual([]); + expect(parseOpeningHours('Mo 09:00-24:30').periods).toEqual([]); + expect(parseOpeningHours('Mo closed').periods).toEqual([]); + }); +}); + +// ── normalizeOpeningPeriods / normalizeSpecialDays ──────────────────────────── + +describe('normalizeOpeningPeriods', () => { + it('MAPS-007f: keeps well-formed periods and fills the zeroes proto3 JSON omits', () => { + expect( + normalizeOpeningPeriods([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17 } }]), + ).toEqual([{ open: { day: 3, hour: 9, minute: 30 }, close: { day: 3, hour: 17, minute: 0 } }]); + // Sunday midnight arrives as an empty object. + expect(normalizeOpeningPeriods([{ open: {} }])).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]); + }); + + it('MAPS-007g: a period without a close survives as the round-the-clock marker', () => { + expect(normalizeOpeningPeriods([{ open: { day: 0, hour: 0, minute: 0 } }])).toEqual([ + { open: { day: 0, hour: 0, minute: 0 }, close: null }, + ]); + }); + + it('MAPS-007h: drops out-of-range points and periods with an unusable close', () => { + expect(normalizeOpeningPeriods([{ open: { day: 7, hour: 9, minute: 0 } }])).toBeNull(); + expect(normalizeOpeningPeriods([{ open: { day: 1, hour: 24, minute: 0 } }])).toBeNull(); + // A broken close must not be mistaken for "never closes". + expect( + normalizeOpeningPeriods([{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 9, minute: 90 } }]), + ).toBeNull(); + }); + + it('MAPS-007i: returns null when there is nothing to normalise', () => { + expect(normalizeOpeningPeriods(undefined)).toBeNull(); + expect(normalizeOpeningPeriods([])).toBeNull(); + }); +}); + +describe('normalizeSpecialDays', () => { + it('MAPS-007j: turns Google date parts into ISO dates without duplicates', () => { + expect( + normalizeSpecialDays([ + { date: { year: 2026, month: 12, day: 25 } }, + { date: { year: 2026, month: 1, day: 1 } }, + { date: { year: 2026, month: 12, day: 25 } }, + ]), + ).toEqual(['2026-12-25', '2026-01-01']); + }); + + it('MAPS-007k: skips incomplete or impossible dates and returns null when none are left', () => { + expect(normalizeSpecialDays([{}, { date: { year: 2026, month: 13, day: 1 } }, { date: { year: 2026, day: 5 } }])).toBeNull(); + expect(normalizeSpecialDays(undefined)).toBeNull(); + }); }); // ── buildOsmDetails ─────────────────────────────────────────────────────────── @@ -202,6 +286,19 @@ describe('buildOsmDetails', () => { expect(buildOsmDetails({}, 'node', '1').source).toBe('openstreetmap'); }); + it('MAPS-012b: carries the structured periods next to the weekday lines', () => { + const result = buildOsmDetails({ opening_hours: 'Mo-Tu 09:00-18:00' }, 'node', '1'); + expect(result.opening_periods).toEqual([ + { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, + { open: { day: 2, hour: 9, minute: 0 }, close: { day: 2, hour: 18, minute: 0 } }, + ]); + }); + + it('MAPS-012c: opening_periods is null when the day lines carry no usable times', () => { + expect(buildOsmDetails({}, 'node', '1').opening_periods).toBeNull(); + expect(buildOsmDetails({ opening_hours: 'Mo closed' }, 'node', '1').opening_periods).toBeNull(); + }); + it('MAPS-014b: opening_hours is null when all days have unknown times (all "?")', () => { // "closed" does not match the day+time pattern so all days remain "?" const result = buildOsmDetails({ opening_hours: 'closed' }, 'node', '1'); @@ -1488,6 +1585,71 @@ describe('getPlaceDetails (fetch stubbed)', () => { expect((result.place as any).open_now).toBe(false); }); + it('MAPS-041f2: hands the structured opening periods and special days to the client', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJPeriods', + regularOpeningHours: { + // Localised display text — the client shows it but must not parse it. + weekdayDescriptions: ['月曜日: 9:00~18:00'], + openNow: true, + periods: [{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }], + specialDays: [{ date: { year: 2026, month: 12, day: 25 } }], + }, + }), + }), + ); + const { getPlaceDetails } = await import('../../../src/services/mapsService'); + const place = (await getPlaceDetails(1, 'ChIJPeriods')).place as any; + expect(place.opening_periods).toEqual([ + { open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 18, minute: 0 } }, + ]); + expect(place.opening_special_days).toEqual(['2026-12-25']); + expect(place.opening_hours).toEqual(['月曜日: 9:00~18:00']); + }); + + it('MAPS-041f3: opening_periods is null when Google sends no periods', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJNoPeriods', + regularOpeningHours: { weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'], openNow: false }, + }), + }), + ); + const { getPlaceDetails } = await import('../../../src/services/mapsService'); + const place = (await getPlaceDetails(1, 'ChIJNoPeriods')).place as any; + expect(place.opening_periods).toBeNull(); + expect(place.opening_special_days).toBeNull(); + }); + + it('MAPS-041f4: getPlaceDetailsExpanded carries the periods too', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss + mockDbGet.mockReturnValueOnce(undefined); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: 'ChIJExpandedPeriods', + // A place that never closes: one period, no close point. + regularOpeningHours: { openNow: true, periods: [{ open: { day: 0, hour: 0, minute: 0 } }] }, + }), + }), + ); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + const place = (await getPlaceDetailsExpanded(1, 'ChIJExpandedPeriods')).place as any; + expect(place.opening_periods).toEqual([{ open: { day: 0, hour: 0, minute: 0 }, close: null }]); + }); + it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); // expanded=1 cache miss @@ -1509,6 +1671,33 @@ describe('getPlaceDetails (fetch stubbed)', () => { const result = await getPlaceDetailsExpanded(1, 'ChIJMany'); expect((result.place as any).reviews).toHaveLength(5); }); + + // The client sends whatever id the place carries, and the expanded lookup used to + // forward all of them — including OSM ids — to Google, which bills the 400 + // INVALID_ARGUMENT it answers with (#1727). + it('MAPS-041h: getPlaceDetailsExpanded serves an OSM id from Overpass instead of Google', async () => { + mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ elements: [{ tags: { website: 'https://nerja.example' } }] }), + }); + vi.stubGlobal('fetch', fetchMock); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + const result = await getPlaceDetailsExpanded(1, 'node:5255005321'); + expect((result.place as any).source).toBe('openstreetmap'); + expect((result.place as any).website).toBe('https://nerja.example'); + expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com'))) + .toBe(false); + }); + + it('MAPS-041i: getPlaceDetailsExpanded answers a coordinate pseudo-id with no place at all', async () => { + mockDbGet.mockReturnValue({ maps_api_key: 'gkey' }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + await expect(getPlaceDetailsExpanded(1, 'coords:48.8,2.3')).resolves.toEqual({ place: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); // ── getPlacePhoto (fetch stubbed) ──────────────────────────────────────────── @@ -1539,7 +1728,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { expect(mockCachePut).toHaveBeenCalledOnce(); }); - it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => { + it('MAPS-043: resolves with photoUrl null when Wikimedia returns nothing and no API key', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ @@ -1548,7 +1737,10 @@ describe('getPlacePhoto (fetch stubbed)', () => { }), ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); - await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); }); it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => { @@ -1567,27 +1759,42 @@ describe('getPlacePhoto (fetch stubbed)', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('MAPS-043c: throws 404 from error cache without making a network request', async () => { + it('MAPS-043c: serves the negative cache with photoUrl null and no network request', async () => { mockCacheGetErrored.mockReturnValue(true); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const errorId = `coords:error-cache-${Date.now()}`; - await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(999, errorId, 0, 0)).resolves.toEqual({ photoUrl: null, attribution: null }); expect(fetchMock).not.toHaveBeenCalled(); }); - it('MAPS-043d: throws 404 when lat/lng are NaN and no API key', async () => { + it('MAPS-043d: resolves with photoUrl null when lat/lng are NaN and no API key', async () => { const { getPlacePhoto } = await import('../../../src/services/mapsService'); const nanId = `coords:nan-test-${Date.now()}`; - await expect(getPlacePhoto(999, nanId, NaN, NaN)).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(999, nanId, NaN, NaN)).resolves.toEqual({ photoUrl: null, attribution: null }); }); - it('MAPS-043e: falls through and throws 404 when Wikimedia fetch throws', async () => { + it('MAPS-043e: falls through to photoUrl null when the Wikimedia fetch throws', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network fail'))); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const throwId = `coords:throw-test-${Date.now()}`; - await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + }); + + it('MAPS-043f: an in-flight lookup that found nothing resolves the waiter with photoUrl null', async () => { + mockCacheGetInFlight.mockReturnValue(Promise.resolve(null) as any); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + await expect(getPlacePhoto(999, 'ChIJInFlight', 48.8, 2.3)).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); }); it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => { @@ -1616,7 +1823,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { expect(mockCachePut).toHaveBeenCalledOnce(); }); - it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => { + it('MAPS-044b: resolves with photoUrl null when Google details fetch returns non-ok', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); vi.stubGlobal( 'fetch', @@ -1628,10 +1835,50 @@ describe('getPlacePhoto (fetch stubbed)', () => { ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const errId = `ChIJErr-${Date.now()}`; - await expect(getPlacePhoto(1, errId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(1, errId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); + // A rejected provider call says nothing about the place — remember it as a + // failed attempt (minutes), not as "this place has no photo" (a day). + expect(mockCacheMarkError).toHaveBeenCalledWith(errId, 'provider-error'); }); - it('MAPS-044c: throws 404 when Google place has no photos', async () => { + it('MAPS-044b2: remembers a place both providers came up empty for as a lasting miss', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + vi.stubGlobal( + 'fetch', + vi + .fn() + // Google answers, the place simply has no photos. + .mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify({ photos: [] }) }) + // Wikimedia has no article near the coordinates either. + .mockResolvedValueOnce({ ok: true, json: async () => ({ query: { pages: {} } }) }), + ); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const emptyId = `ChIJEmpty-${Date.now()}`; + await expect(getPlacePhoto(1, emptyId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); + expect(mockCacheMarkError).toHaveBeenCalledWith(emptyId, 'no-photo'); + }); + + it('MAPS-044b3: a failed Wikimedia image download counts as a failed attempt', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } } }), + }) + .mockResolvedValueOnce({ ok: false, status: 503, arrayBuffer: async () => new ArrayBuffer(0) }), + ); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const downId = `coords:down-${Date.now()}`; + await expect(getPlacePhoto(999, downId, 48.8, 2.3, 'Place')).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + expect(mockCacheMarkError).toHaveBeenCalledWith(downId, 'provider-error'); + }); + + it('MAPS-044c: resolves with photoUrl null when the Google place has no photos', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); vi.stubGlobal( 'fetch', @@ -1642,10 +1889,10 @@ describe('getPlacePhoto (fetch stubbed)', () => { ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const noPhotoId = `ChIJNone-${Date.now()}`; - await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); }); - it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => { + it('MAPS-044d: resolves with photoUrl null when the media endpoint returns non-ok status', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi .fn() @@ -1664,7 +1911,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const noUriId = `ChIJXYZ-${Date.now()}`; - await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); + await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null }); }); it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => { @@ -1748,6 +1995,58 @@ describe('getPlacePhoto (fetch stubbed)', () => { expect(result.attribution).toBe('Wikipedia'); expect(mockCachePut).toHaveBeenCalledOnce(); }); + + // OSM ids are what the client sends whenever a place has no google_place_id. + // Google rejects them with a billable 400 INVALID_ARGUMENT, so they must go + // straight to the Wikimedia fallback like coords: ids already do. + it.each(['node:5255005321', 'way:84527326', 'relation:345407'])( + 'MAPS-044h: skips Google for the OSM id %s even when an API key is configured', + async (osmId) => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/osm.jpg' } } } } }), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(90), + }); + vi.stubGlobal('fetch', fetchMock); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const result = await getPlacePhoto(1, osmId, 36.7617, -3.8448, 'Cueva de Nerja'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(osmId)}/bytes`); + const requested = fetchMock.mock.calls.map((call) => String(call[0])); + expect(requested.some((url) => url.includes('places.googleapis.com'))).toBe(false); + }, + ); + + it('MAPS-044i: an OSM id with no Wikimedia hit resolves with photoUrl null instead of a 404', async () => { + mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ query: { pages: {} } }) }); + vi.stubGlobal('fetch', fetchMock); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + await expect(getPlacePhoto(1, 'node:2481346642', 36.76, -3.84, 'Nerja')).resolves.toEqual({ + photoUrl: null, + attribution: null, + }); + expect(fetchMock.mock.calls.map((call) => String(call[0])).some((url) => url.includes('places.googleapis.com'))) + .toBe(false); + }); +}); + +describe('isGooglePlaceId', () => { + it('MAPS-045: accepts Google place ids and rejects the ids Google cannot resolve', () => { + expect(isGooglePlaceId('ChIJLU7jZClu5kcR4PcOOO6p3I0')).toBe(true); + expect(isGooglePlaceId('coords:48.8,2.3')).toBe(false); + expect(isGooglePlaceId('node:5255005321')).toBe(false); + expect(isGooglePlaceId('way:84527326')).toBe(false); + expect(isGooglePlaceId('relation:345407')).toBe(false); + expect(isGooglePlaceId('https://lh3.googleusercontent.com/photo.jpg')).toBe(false); + // The collection views send the bare coordinate pair when a place has no ids. + expect(isGooglePlaceId('36.7617499,-3.8448432')).toBe(false); + }); }); describe('googleFtidFromMapsUrl', () => { diff --git a/server/tests/unit/services/placePhotoCache.test.ts b/server/tests/unit/services/placePhotoCache.test.ts index 5d3b699d8..196859f40 100644 --- a/server/tests/unit/services/placePhotoCache.test.ts +++ b/server/tests/unit/services/placePhotoCache.test.ts @@ -1,6 +1,7 @@ /** - * Unit tests for placePhotoCache — PPC-001 through PPC-010. - * Covers the downscale guard in put(), removeIfUnreferenced(), and sweepOrphans(). + * Unit tests for placePhotoCache — PPC-001 through PPC-012. + * Covers the downscale guard in put(), removeIfUnreferenced(), sweepOrphans(), + * and the two negative-cache windows. * Uses a real in-memory SQLite DB and a throwaway temp upload dir * (TREK_PLACE_PHOTO_DIR) so the real uploads tree is never touched. */ @@ -156,3 +157,58 @@ describe('placePhotoCache.sweepOrphans()', () => { expect(fs.existsSync(filePathFor('ref-a'))).toBe(true); }); }); + +describe('placePhotoCache negative cache', () => { + function ageError(placeId: string, ms: number): void { + testDb + .prepare('UPDATE google_place_photo_meta SET error_at = ? WHERE place_id = ?') + .run(Date.now() - ms, placeId); + } + + it('PPC-009: a place with no photo stays remembered well past the old five-minute window', () => { + cache.markError('photo-less'); + + ageError('photo-less', 30 * 60 * 1000); + expect(cache.getErrored('photo-less')).toBe(true); + + ageError('photo-less', 5 * 60 * 60 * 1000); + expect(cache.getErrored('photo-less')).toBe(true); + }); + + it('PPC-010: the miss expires once it is a day old', () => { + cache.markError('stale-miss'); + ageError('stale-miss', 24 * 60 * 60 * 1000); + + expect(cache.getErrored('stale-miss')).toBe(false); + }); + + // A failed provider call says nothing about the place, so it must not inherit the + // long window a real "this place has no photo" answer gets. + it('PPC-011: a failed provider call is forgotten after minutes and never persisted', () => { + vi.useFakeTimers(); + try { + cache.markError('flaky', 'provider-error'); + expect(cache.getErrored('flaky')).toBe(true); + // Nothing on disk — a restart retries instead of inheriting someone's outage. + expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('flaky')).toBeUndefined(); + + vi.advanceTimersByTime(5 * 60 * 1000); + expect(cache.getErrored('flaky')).toBe(false); + + // The long window belongs to the other case: same age, still remembered. + cache.markError('photo-less-too'); + ageError('photo-less-too', 5 * 60 * 1000); + expect(cache.getErrored('photo-less-too')).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('PPC-012: a cached photo clears an earlier failed attempt', async () => { + cache.markError('recovered', 'provider-error'); + expect(cache.getErrored('recovered')).toBe(true); + + await cache.put('recovered', await makeJpeg(40, 40), null); + expect(cache.getErrored('recovered')).toBe(false); + }); +});