diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index d8d90859..9e85393e 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -165,7 +165,11 @@ export default function PlaceInspector({ const openingHours = googleDetails?.opening_hours || null const openNow = googleDetails?.open_now ?? null - const googleMapsUrl = getGoogleMapsUrlForPlace(place, googleDetails?.google_maps_url) + // Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google. + const googleMapsUrl = getGoogleMapsUrlForPlace( + place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null, + googleDetails?.google_maps_url, + ) const selectedDay = days?.find(d => d.id === selectedDayId) const weekdayIndex = getWeekdayIndex(selectedDay?.date) diff --git a/client/src/components/Planner/placeGoogleMaps.test.ts b/client/src/components/Planner/placeGoogleMaps.test.ts new file mode 100644 index 00000000..11f0ae4e --- /dev/null +++ b/client/src/components/Planner/placeGoogleMaps.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { getGoogleMapsUrlForPlace } from './placeGoogleMaps' + +const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, google_place_id: null, google_ftid: null } as any + +describe('getGoogleMapsUrlForPlace', () => { + it('FE-PLACE-GMAPS-001: uses a valid ftid for a precise /place link', () => { + const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0x47e66e2964e34e2d:0x8ddca9ee380ef7e0' }) + expect(url).toBe('https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x47e66e2964e34e2d:0x8ddca9ee380ef7e0') + }) + + it('FE-PLACE-GMAPS-002: falls back to query_place_id when there is no ftid', () => { + const url = getGoogleMapsUrlForPlace({ ...base, google_place_id: 'ChIJ123' }) + expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123') + }) + + it('FE-PLACE-GMAPS-003: ignores a malformed/hostile ftid and falls through to the place id', () => { + const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0xAB&q=evil', google_place_id: 'ChIJ123' }) + expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123') + }) + + it('FE-PLACE-GMAPS-004: uses the details URL when there is no ftid or place id', () => { + const url = getGoogleMapsUrlForPlace(base, 'https://maps.google.com/?cid=123') + expect(url).toBe('https://maps.google.com/?cid=123') + }) + + it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => { + const url = getGoogleMapsUrlForPlace(base) + expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945') + }) + + it('FE-PLACE-GMAPS-006: returns null for no place or no location', () => { + expect(getGoogleMapsUrlForPlace(null)).toBeNull() + expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull() + }) +}) diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 5e3ef780..070d1c4a 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -90,6 +90,11 @@ function toApiLang(lang: string | undefined, fallback = 'en'): string { const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i; +// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL's ?ftid= param. +// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN) +// with no ftid, so this returns null for most API responses — the precise query_place_id link is +// used instead. It does recover an ftid from a /place/?...&ftid= URL, e.g. a pasted share link +// resolved by resolveGoogleMapsUrl or a Google MyMaps list import. export function googleFtidFromMapsUrl(url?: string | null): string | null { if (!url) return null; try { diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index addb6a25..1ae65e34 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -73,6 +73,7 @@ import { parseOpeningHours, buildOsmDetails, getMapsKey, + googleFtidFromMapsUrl, } from '../../../src/services/mapsService'; afterEach(() => { @@ -756,7 +757,8 @@ describe('searchPlaces (fetch stubbed)', () => { displayName: { text: 'Eiffel Tower' }, formattedAddress: 'Paris', location: { latitude: 48.8, longitude: 2.3 }, - googleMapsUri: 'https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x882bf179e806d471:0x8591dde29c821a93', + // Real search API returns a cid-style URL with no ftid → google_ftid stays null. + googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', }], }), })); @@ -764,7 +766,7 @@ describe('searchPlaces (fetch stubbed)', () => { const result = await searchPlaces(1, 'Eiffel Tower'); expect(result.source).toBe('google'); expect((result.places[0] as any).google_place_id).toBe('gid1'); - expect((result.places[0] as any).google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + expect((result.places[0] as any).google_ftid).toBeNull(); }); it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => { @@ -1090,7 +1092,9 @@ describe('getPlaceDetails (fetch stubbed)', () => { weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'], openNow: true, }, - googleMapsUri: 'https://maps.google.com/?cid=123&ftid=0x882bf179e806d471:0x8591dde29c821a93', + // The Places API returns a cid-style URL with no ftid, so google_ftid stays null + // and the precise query_place_id link is used on the client instead. + googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155', editorialSummary: { text: 'Iconic iron tower.' }, reviews: [ { @@ -1107,7 +1111,7 @@ describe('getPlaceDetails (fetch stubbed)', () => { const result = await getPlaceDetails(1, 'ChIJ123'); const place = result.place as any; expect(place.google_place_id).toBe('ChIJ123'); - expect(place.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93'); + expect(place.google_ftid).toBeNull(); expect(place.name).toBe('Eiffel Tower'); expect(place.rating).toBe(4.7); expect(place.rating_count).toBe(200000); @@ -1476,3 +1480,19 @@ describe('getPlacePhoto (fetch stubbed)', () => { expect(mockCachePut).toHaveBeenCalledOnce(); }); }); + +describe('googleFtidFromMapsUrl', () => { + it('MAPS-FTID-001: extracts a valid ftid from a /place/?ftid= URL (resolved share link)', () => { + expect(googleFtidFromMapsUrl('https://www.google.com/maps/place/?q=X&ftid=0x882bf179e806d471:0x8591dde29c821a93')) + .toBe('0x882bf179e806d471:0x8591dde29c821a93'); + }); + it('MAPS-FTID-002: returns null for a cid-style URL (the usual Places API shape)', () => { + expect(googleFtidFromMapsUrl('https://maps.google.com/?cid=10403719659250533155')).toBeNull(); + }); + it('MAPS-FTID-003: rejects malformed / hostile ftid values', () => { + expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=not-an-ftid')).toBeNull(); + expect(googleFtidFromMapsUrl('https://maps.google.com/?ftid=0xAB%26q%3Devil%3Cscript%3E')).toBeNull(); + expect(googleFtidFromMapsUrl('not a url')).toBeNull(); + expect(googleFtidFromMapsUrl(null)).toBeNull(); + }); +});