Files
TREK/server/tests/unit/services/mapsService.test.ts
T
Maurice 50600b74cd fix(maps): stop the photo endpoint answering 404 for photo-less places (#1727)
Opening one trip fires a 404 per place without a photo, 30 to 60 of them from
one IP in seconds. CrowdSec and fail2ban read that as scanning; the reporter
had three users banned within five minutes. A place having no photo is not an
error and no longer answers as one.

OSM places were also sent to Google as node:/way:/relation: ids, which can
only return 400 INVALID_ARGUMENT — a billed round-trip and about 1.3s per
place. getPlacePhoto already guarded coords: this way; the OSM prefixes now
get the same treatment, as does getPlaceDetailsExpanded, which had no guard.

The negative cache separates a place having no photo from a failed provider
call instead of expiring both after five minutes and replaying the storm.
2026-07-29 15:14:21 +02:00

2163 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for mapsService — MAPS-001 through MAPS-080.
* Covers parseOpeningHours, buildOsmDetails, getMapsKey, reverseGeocode,
* resolveGoogleMapsUrl (coordinate extraction + short URL / SSRF),
* searchNominatim, fetchOverpassDetails, fetchWikimediaPhoto, searchPlaces,
* getPlaceDetails, and getPlacePhoto (all branches including cache logic).
* fetch is stubbed; DB and ssrfGuard are mocked.
*/
import {
parseOpeningHours,
normalizeOpeningPeriods,
normalizeSpecialDays,
buildOsmDetails,
getMapsKey,
googleFtidFromMapsUrl,
isGooglePlaceId,
buildUserAgent,
resolveOverpassEndpoints,
resolveOverpassTimeoutMs,
searchOverpassPois,
} from '../../../src/services/mapsService';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
const {
mockDbGet,
mockDbRun,
mockCheckSsrf,
mockCacheGet,
mockCacheGetErrored,
mockCacheMarkError,
mockCachePut,
mockCacheGetInFlight,
mockCacheSetInFlight,
} = vi.hoisted(() => ({
mockDbGet: vi.fn(() => undefined as any),
mockDbRun: vi.fn(),
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`,
attribution,
})),
mockCacheGetInFlight: vi.fn(() => undefined),
mockCacheSetInFlight: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: {
prepare: () => ({ get: mockDbGet, all: vi.fn(() => []), run: mockDbRun }),
},
}));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
return {
checkSsrf: mockCheckSsrf,
SsrfBlockedError,
// Mirror the real per-hop helper closely enough for unit tests: run the
// (mocked) SSRF check, then fetch through the (stubbed) global fetch. The
// fetch stubs in these tests already return the final resolved response.
safeFetchFollow: vi.fn(async (url: string, init?: any) => {
const ssrf = await mockCheckSsrf(url);
if (!ssrf.allowed) throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
return (globalThis.fetch as any)(url, init);
}),
};
});
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
}));
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: '0'.repeat(64),
}));
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: (placeId: string, kind?: string) => mockCacheMarkError(placeId, kind),
getInFlight: (placeId: string) => mockCacheGetInFlight(placeId),
setInFlight: (placeId: string, p: Promise<any>) => mockCacheSetInFlight(placeId, p),
serveFilePath: vi.fn(() => null),
}));
afterEach(() => {
vi.unstubAllGlobals();
mockDbGet.mockReset();
mockDbGet.mockReturnValue(undefined);
mockDbRun.mockReset();
mockCheckSsrf.mockReset();
mockCheckSsrf.mockResolvedValue({ allowed: true });
mockCacheGet.mockReset();
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`,
filePath: `/tmp/${placeId}.jpg`,
attribution,
}));
mockCacheGetInFlight.mockReset();
mockCacheGetInFlight.mockReturnValue(undefined);
mockCacheSetInFlight.mockReset();
});
// ── parseOpeningHours ─────────────────────────────────────────────────────────
describe('parseOpeningHours', () => {
it('MAPS-001: returns 7 weekday descriptions and openNow', () => {
const result = parseOpeningHours('Mo-Fr 09:00-18:00');
expect(result.weekdayDescriptions).toHaveLength(7);
expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00');
expect(typeof result.openNow === 'boolean' || result.openNow === null).toBe(true);
});
it('MAPS-002: marks unknown days with ?', () => {
const result = parseOpeningHours('Mo 10:00-12:00');
expect(result.weekdayDescriptions[1]).toContain('?');
});
it('MAPS-003: handles multiple segments separated by semicolons', () => {
const result = parseOpeningHours('Mo-Fr 09:00-18:00; Sa 10:00-14:00');
expect(result.weekdayDescriptions[5]).toContain('Saturday: 10:00-14:00');
expect(result.weekdayDescriptions[0]).toContain('Monday: 09:00-18:00');
});
it('MAPS-004: handles 24/7 string gracefully (no crash)', () => {
const result = parseOpeningHours('24/7');
expect(result.weekdayDescriptions).toHaveLength(7);
});
it('MAPS-005: returns openNow null for unparseable format', () => {
const result = parseOpeningHours('invalid-hours-string');
expect(result.openNow).toBeNull();
});
it('MAPS-006: handles comma-separated days', () => {
const result = parseOpeningHours('Mo,We,Fr 08:00-17:00');
expect(result.weekdayDescriptions[0]).toContain('Monday: 08:00-17:00');
expect(result.weekdayDescriptions[2]).toContain('Wednesday: 08:00-17:00');
expect(result.weekdayDescriptions[4]).toContain('Friday: 08:00-17:00');
expect(result.weekdayDescriptions[1]).toContain('?');
});
it('MAPS-007 (ReDoS): opening hours regex on adversarial input < 100ms', () => {
const adversarial = 'Mo' + ',Mo'.repeat(500) + ' closed';
const start = Date.now();
parseOpeningHours(adversarial);
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 ───────────────────────────────────────────────────────────
describe('buildOsmDetails', () => {
it('MAPS-008: returns website from tags', () => {
const result = buildOsmDetails({ website: 'https://example.com' }, 'way', '123');
expect(result.website).toBe('https://example.com');
});
it('MAPS-009: prefers contact:website over website', () => {
const result = buildOsmDetails(
{ 'contact:website': 'https://contact.example.com', website: 'https://other.com' },
'node',
'1',
);
expect(result.website).toBe('https://contact.example.com');
});
it('MAPS-010: returns null website when no tag', () => {
const result = buildOsmDetails({}, 'node', '1');
expect(result.website).toBeNull();
});
it('MAPS-011: builds correct osm_url', () => {
const result = buildOsmDetails({}, 'way', '99999');
expect(result.osm_url).toBe('https://www.openstreetmap.org/way/99999');
});
it('MAPS-012: includes parsed opening_hours when valid', () => {
const result = buildOsmDetails({ opening_hours: 'Mo-Fr 09:00-18:00' }, 'node', '1');
expect(result.opening_hours).not.toBeNull();
expect(Array.isArray(result.opening_hours)).toBe(true);
});
it('MAPS-013: opening_hours is null when tag is missing', () => {
const result = buildOsmDetails({}, 'node', '1');
expect(result.opening_hours).toBeNull();
expect(result.open_now).toBeNull();
});
it('MAPS-014: source is always openstreetmap', () => {
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');
expect(result.opening_hours).toBeNull();
expect(result.open_now).toBeNull();
});
});
// ── getMapsKey ────────────────────────────────────────────────────────────────
describe('getMapsKey', () => {
it('MAPS-015: returns user key when user has one', () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'user-api-key' });
expect(getMapsKey(1)).toBe('user-api-key');
});
it('MAPS-016: falls back to admin key when user has none', () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: null });
mockDbGet.mockReturnValueOnce({ maps_api_key: 'admin-api-key' });
expect(getMapsKey(1)).toBe('admin-api-key');
});
it('MAPS-017: returns null when neither user nor admin has a key', () => {
mockDbGet.mockReturnValue(undefined);
expect(getMapsKey(1)).toBeNull();
});
});
// ── reverseGeocode ────────────────────────────────────────────────────────────
describe('reverseGeocode (fetch stubbed)', () => {
it('MAPS-018: returns name and address from nominatim response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
name: 'Eiffel Tower',
display_name: 'Eiffel Tower, Paris, France',
address: {},
}),
}),
);
const { reverseGeocode } = await import('../../../src/services/mapsService');
const result = await reverseGeocode('48.8584', '2.2945');
expect(result.name).toBe('Eiffel Tower');
expect(result.address).toBe('Eiffel Tower, Paris, France');
});
it('MAPS-019: returns nulls when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const { reverseGeocode } = await import('../../../src/services/mapsService');
const result = await reverseGeocode('0', '0');
expect(result.name).toBeNull();
expect(result.address).toBeNull();
});
it('MAPS-019b: falls back to address.tourism when name is absent', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
display_name: 'Some Museum, Paris',
address: { tourism: 'Some Museum' },
}),
}),
);
const { reverseGeocode } = await import('../../../src/services/mapsService');
const result = await reverseGeocode('48.85', '2.35');
expect(result.name).toBe('Some Museum');
});
it('MAPS-019c: falls back to address.amenity when name and tourism are absent', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
display_name: 'A Cafe, Paris',
address: { amenity: 'A Cafe' },
}),
}),
);
const { reverseGeocode } = await import('../../../src/services/mapsService');
const result = await reverseGeocode('48.85', '2.35');
expect(result.name).toBe('A Cafe');
});
it('MAPS-019d: falls back to address.road when no higher-priority field exists', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
display_name: 'Rue de Rivoli, Paris',
address: { road: 'Rue de Rivoli' },
}),
}),
);
const { reverseGeocode } = await import('../../../src/services/mapsService');
const result = await reverseGeocode('48.85', '2.35');
expect(result.name).toBe('Rue de Rivoli');
});
it('MAPS-019e: returns null name when address has no recognized fields', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
display_name: 'Somewhere',
address: {},
}),
}),
);
const { reverseGeocode } = await import('../../../src/services/mapsService');
const result = await reverseGeocode('0', '0');
expect(result.name).toBeNull();
expect(result.address).toBe('Somewhere');
});
});
// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction
const nominatimStub = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ display_name: 'Paris, France', name: null, address: {} }),
});
// ── resolveGoogleMapsUrl coordinate extraction ────────────────────────────────
describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
it('MAPS-020: extracts lat/lng from @lat,lng pattern', async () => {
vi.stubGlobal('fetch', nominatimStub);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8566,2.3522,15z');
expect(result.lat).toBeCloseTo(48.8566, 3);
expect(result.lng).toBeCloseTo(2.3522, 3);
});
it('MAPS-021: extracts lat/lng from !3d!4d data pattern', async () => {
vi.stubGlobal('fetch', nominatimStub);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl(
'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945',
);
expect(result.lat).toBeCloseTo(48.8584, 3);
expect(result.lng).toBeCloseTo(2.2945, 3);
});
it('MAPS-022: extracts lat/lng from ?q=lat,lng pattern', async () => {
vi.stubGlobal('fetch', nominatimStub);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8566,2.3522');
expect(result.lat).toBeCloseTo(48.8566, 3);
expect(result.lng).toBeCloseTo(2.3522, 3);
});
it('MAPS-023: extracts place name from /place/ path', async () => {
vi.stubGlobal('fetch', nominatimStub);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/place/Eiffel+Tower/@48.8584,2.2945,15z');
expect(result.name).toBe('Eiffel Tower');
});
it('MAPS-CID-001: resolves a cid= URL by following the redirect to a coordinate URL', async () => {
// cid URLs (what get_place_details returns, and Google "Share" links) carry no
// inline coords; the redirect target carries the !3d!4d data param.
const fetchMock = vi.fn(async (u: string) => {
if (u.includes('nominatim')) {
return { ok: true, json: async () => ({ display_name: 'Paris, France', name: 'Eiffel Tower', address: {} }) };
}
return { url: 'https://www.google.com/maps/place/Eiffel+Tower/data=!3d48.8584!4d2.2945', text: async () => '' };
});
vi.stubGlobal('fetch', fetchMock);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://maps.google.com/?cid=1234567890');
expect(result.lat).toBeCloseTo(48.8584, 3);
expect(result.lng).toBeCloseTo(2.2945, 3);
});
it('MAPS-CID-002: falls back to parsing coordinates from the page body', async () => {
const fetchMock = vi.fn(async (u: string) => {
if (u.includes('nominatim')) {
return { ok: true, json: async () => ({ display_name: 'NYC, USA', name: null, address: {} }) };
}
if (u.includes('cid=')) {
// Redirect target has no inline coords.
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => '' };
}
// Body fetch of the resolved URL embeds coords in the map data.
return { url: 'https://www.google.com/maps/place/Somewhere', text: async () => 'x!3d40.6892!4d-74.0445y' };
});
vi.stubGlobal('fetch', fetchMock);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
expect(result.lat).toBeCloseTo(40.6892, 3);
expect(result.lng).toBeCloseTo(-74.0445, 3);
});
it('MAPS-024 (ReDoS): /@(-?\\d+\\.?\\d*),(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
const adversarial = '/@' + '1'.repeat(10000) + '.';
const start = Date.now();
adversarial.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
expect(Date.now() - start).toBeLessThan(500);
});
it('MAPS-025 (ReDoS): /!3d(-?\\d+\\.?\\d*)!4d/ on adversarial input < 500ms', () => {
const adversarial = '!3d' + '1'.repeat(10000) + '.';
const start = Date.now();
adversarial.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
expect(Date.now() - start).toBeLessThan(500);
});
it('MAPS-026 (ReDoS): /[?&]q=(-?\\d+\\.?\\d*)/ on adversarial input < 500ms', () => {
const adversarial = '?q=' + '1'.repeat(10000) + '.';
const start = Date.now();
adversarial.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
expect(Date.now() - start).toBeLessThan(500);
});
it('MAPS-027 (ReDoS): /<[^>]+>/ HTML strip on adversarial input < 100ms', () => {
const adversarial = '<' + 'a'.repeat(10000);
const start = Date.now();
adversarial.replace(/<[^>]+>/g, '');
expect(Date.now() - start).toBeLessThan(100);
});
it('MAPS-028: throws when no coordinates found in URL', async () => {
vi.stubGlobal('fetch', nominatimStub);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
await expect(resolveGoogleMapsUrl('https://www.google.com/maps')).rejects.toThrow();
});
it('MAPS-028b: throws 403 when short URL is blocked by SSRF check', async () => {
mockCheckSsrf.mockResolvedValueOnce({ allowed: false });
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
await expect(resolveGoogleMapsUrl('https://goo.gl/maps/abc123')).rejects.toMatchObject({ status: 403 });
});
it('MAPS-028c: follows redirect for short goo.gl URL and extracts coordinates', async () => {
const redirectFetch = vi
.fn()
// First call: the redirect (goo.gl), returns resolved URL in .url
.mockResolvedValueOnce({
url: 'https://www.google.com/maps/@48.8566,2.3522,15z',
})
// Second call: the Nominatim reverse geocode
.mockResolvedValueOnce({
ok: true,
json: async () => ({ display_name: 'Paris, France', name: 'Paris', address: {} }),
});
vi.stubGlobal('fetch', redirectFetch);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
const result = await resolveGoogleMapsUrl('https://goo.gl/maps/abc123');
expect(result.lat).toBeCloseTo(48.8566, 3);
expect(result.lng).toBeCloseTo(2.3522, 3);
});
it('MAPS-028d: falls back to nominatim address fields when no placeName in URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
display_name: 'Louvre Museum, Paris',
name: null,
address: { tourism: 'Louvre Museum' },
}),
});
vi.stubGlobal('fetch', fetchMock);
const { resolveGoogleMapsUrl } = await import('../../../src/services/mapsService');
// URL with coordinates but no /place/ path segment
const result = await resolveGoogleMapsUrl('https://www.google.com/maps/@48.8606,2.3376,15z');
expect(result.name).toBe('Louvre Museum');
});
});
// ── searchNominatim (fetch-dependent) ────────────────────────────────────────
describe('searchNominatim (fetch stubbed)', () => {
it('MAPS-029: returns mapped nominatim results on success', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{ osm_type: 'way', osm_id: '1', lat: '48.8', lon: '2.3', name: 'Paris', display_name: 'Paris, France' },
],
}),
);
const { searchNominatim } = await import('../../../src/services/mapsService');
const results = await searchNominatim('Paris');
expect(results).toHaveLength(1);
expect((results[0] as any).address).toBe('Paris, France');
expect((results[0] as any).source).toBe('openstreetmap');
});
it('MAPS-030: throws on fetch failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const { searchNominatim } = await import('../../../src/services/mapsService');
await expect(searchNominatim('fail')).rejects.toThrow();
});
it('MAPS-030b: throws when nominatim response is not ok', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: async () => '',
}),
);
const { searchNominatim } = await import('../../../src/services/mapsService');
await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error');
});
it('MAPS-030c: falls back to display_name split when name is absent', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ osm_type: 'node', osm_id: '2', lat: '51.5', lon: '-0.1', display_name: 'London, UK' }],
}),
);
const { searchNominatim } = await import('../../../src/services/mapsService');
const results = await searchNominatim('London');
expect((results[0] as any).name).toBe('London');
});
});
// ── fetchOverpassDetails (fetch stubbed) ─────────────────────────────────────
describe('fetchOverpassDetails (fetch stubbed)', () => {
it('MAPS-031: returns element tags on success', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ elements: [{ tags: { name: 'Eiffel Tower', website: 'https://eiffel.com' } }] }),
}),
);
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
const result = await fetchOverpassDetails('way', '12345');
expect(result).toBeDefined();
expect((result as any).tags.name).toBe('Eiffel Tower');
});
it('MAPS-032: returns null for unknown osmType', async () => {
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
const result = await fetchOverpassDetails('unknown', '12345');
expect(result).toBeNull();
});
it('MAPS-033: returns null when fetch throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
const result = await fetchOverpassDetails('node', '99999');
expect(result).toBeNull();
});
it('MAPS-034: returns null when response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
const result = await fetchOverpassDetails('node', '99999');
expect(result).toBeNull();
});
it('MAPS-034b: returns null when elements array is empty', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ elements: [] }),
}),
);
const { fetchOverpassDetails } = await import('../../../src/services/mapsService');
const result = await fetchOverpassDetails('node', '1');
expect(result).toBeNull();
});
});
// ── searchOverpassPois localized names (#1655) ───────────────────────────────
describe('searchOverpassPois localized names (#1655)', () => {
// Elephant and Obelisk in Rome: OSM ships the native name plus localized tags.
const tags = {
name: 'Obelisco della Minerva',
'name:en': 'Elephant and Obelisk',
'name:de': 'Minerva-Obelisk',
int_name: 'Elephant Obelisk',
tourism: 'attraction',
};
const stubOverpass = (elementTags: Record<string, string>) =>
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ elements: [{ type: 'node', id: 1, lat: 41.9, lon: 12.48, tags: elementTags }] }),
}),
);
// Distinct bboxes per case so the module-level POI_CACHE (keyed by lang+bbox)
// never serves one case's names to another.
const bbox = (n: number) => ({ south: 41 + n / 100, west: 12, north: 42 + n / 100, east: 13 });
it('prefers name:<lang> for the user language over the native name', async () => {
stubOverpass(tags);
const { searchOverpassPois } = await import('../../../src/services/mapsService');
const { pois } = await searchOverpassPois('sights', bbox(1), 'en-US');
expect(pois[0].name).toBe('Elephant and Obelisk');
});
it('localizes to a non-English language too', async () => {
stubOverpass(tags);
const { searchOverpassPois } = await import('../../../src/services/mapsService');
const { pois } = await searchOverpassPois('sights', bbox(2), 'de-DE');
expect(pois[0].name).toBe('Minerva-Obelisk');
});
it('falls back to int_name when the language tag is absent', async () => {
stubOverpass({ name: tags.name, int_name: tags.int_name, tourism: 'attraction' });
const { searchOverpassPois } = await import('../../../src/services/mapsService');
const { pois } = await searchOverpassPois('sights', bbox(3), 'fr-FR');
expect(pois[0].name).toBe('Elephant Obelisk');
});
it('falls back to the native name when no localized tag exists', async () => {
stubOverpass({ name: tags.name, tourism: 'attraction' });
const { searchOverpassPois } = await import('../../../src/services/mapsService');
const { pois } = await searchOverpassPois('sights', bbox(4), 'fr-FR');
expect(pois[0].name).toBe('Obelisco della Minerva');
});
});
// ── fetchWikimediaPhoto (fetch stubbed) ───────────────────────────────────────
describe('fetchWikimediaPhoto (fetch stubbed)', () => {
it('MAPS-035: returns photo from Wikipedia article image (strategy 1)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://example.com/thumb.jpg' } } } },
}),
}),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Eiffel Tower');
expect(result).toBeDefined();
expect(result!.photoUrl).toBe('https://example.com/thumb.jpg');
expect(result!.attribution).toBe('Wikipedia');
});
it('MAPS-036: falls through to geosearch when Wikipedia has no thumbnail', async () => {
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
const commonsResponse = {
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [
{ url: 'https://commons.org/img.jpg', mime: 'image/jpeg', extmetadata: { Artist: { value: 'Alice' } } },
],
},
},
},
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse));
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
expect(result).toBeDefined();
expect(result!.photoUrl).toBe('https://commons.org/img.jpg');
expect(result!.attribution).toBe('Alice');
});
it('MAPS-036b: geosearch prefers the scaled thumburl over the full-res original', async () => {
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
const commonsResponse = {
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [
{
url: 'https://commons.org/original-16mb.jpg',
thumburl: 'https://commons.org/thumb-400.jpg',
mime: 'image/jpeg',
extmetadata: { Artist: { value: 'Alice' } },
},
],
},
},
},
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiResponse).mockResolvedValueOnce(commonsResponse));
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
expect(result).toBeDefined();
expect(result!.photoUrl).toBe('https://commons.org/thumb-400.jpg');
expect(result!.attribution).toBe('Alice');
});
it('MAPS-037: returns null when both strategies find nothing', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ query: { pages: {} } }),
}),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result).toBeNull();
});
it('MAPS-037b: skips strategy 1 entirely when name is undefined', async () => {
// Only one fetch call is made (the Commons geosearch), not two
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ query: { pages: {} } }),
});
vi.stubGlobal('fetch', fetchMock);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
await fetchWikimediaPhoto(48.8, 2.3);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('MAPS-037c: falls through to geosearch when Wikipedia fetch throws', async () => {
const commonsResponse = {
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [{ url: 'https://commons.org/fallback.jpg', mime: 'image/png', extmetadata: {} }],
},
},
},
}),
};
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValueOnce(new Error('Wikipedia network error')).mockResolvedValueOnce(commonsResponse),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
expect(result).toBeDefined();
expect(result!.photoUrl).toBe('https://commons.org/fallback.jpg');
// no Artist in extmetadata -> attribution null
expect(result!.attribution).toBeNull();
});
it('MAPS-037d: falls through to geosearch when Wikipedia response is not ok', async () => {
const wikiNotOk = { ok: false };
const commonsResponse = {
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [
{
url: 'https://commons.org/photo.jpg',
mime: 'image/jpeg',
extmetadata: { Artist: { value: '<b>Bob</b>' } },
},
],
},
},
},
}),
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(wikiNotOk).mockResolvedValueOnce(commonsResponse));
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
expect(result).toBeDefined();
// HTML tags stripped from attribution
expect(result!.attribution).toBe('Bob');
});
it('MAPS-037e: returns null when Commons geosearch returns not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result).toBeNull();
});
it('MAPS-037f: returns null when Commons geosearch returns no query.pages', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ query: {} }),
}),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result).toBeNull();
});
it('MAPS-037g: returns null when Commons fetch throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Commons network error')));
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result).toBeNull();
});
it('MAPS-037h: skips Commons page entries with non-photo MIME type (SVG)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [{ url: 'https://commons.org/diagram.svg', mime: 'image/svg+xml' }],
},
},
},
}),
}),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result).toBeNull();
});
it('MAPS-037i: accepts PNG mime type as valid photo', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [
{
url: 'https://commons.org/photo.png',
mime: 'image/png',
extmetadata: { Artist: { value: 'Carol' } },
},
],
},
},
},
}),
}),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result!.photoUrl).toBe('https://commons.org/photo.png');
expect(result!.attribution).toBe('Carol');
});
it('MAPS-037j: returns null attribution when Artist extmetadata is absent', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
query: {
pages: {
'1': {
imageinfo: [{ url: 'https://commons.org/noattr.jpg', mime: 'image/jpeg', extmetadata: {} }],
},
},
},
}),
}),
);
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3);
expect(result!.attribution).toBeNull();
});
});
// ── searchPlaces (fetch stubbed) ─────────────────────────────────────────────
describe('searchPlaces (fetch stubbed)', () => {
it('MAPS-038: uses Nominatim when user has no API key', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
],
}),
);
const { searchPlaces } = await import('../../../src/services/mapsService');
const result = await searchPlaces(999, 'Paris');
expect(result.source).toBe('openstreetmap');
expect(Array.isArray(result.places)).toBe(true);
});
it('MAPS-039: uses Google when user has an API key', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
places: [
{
id: 'gid1',
displayName: { text: 'Eiffel Tower' },
formattedAddress: 'Paris',
location: { latitude: 48.8, longitude: 2.3 },
// Real search API returns a cid-style URL with no ftid → google_ftid stays null.
googleMapsUri: 'https://maps.google.com/?cid=10403719659250533155',
},
],
}),
}),
);
const { searchPlaces } = await import('../../../src/services/mapsService');
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).toBeNull();
});
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: async () => ({ error: { message: 'API key invalid' } }),
}),
);
const { searchPlaces } = await import('../../../src/services/mapsService');
await expect(searchPlaces(1, 'anything')).rejects.toMatchObject({
message: 'API key invalid',
status: 403,
});
});
it('MAPS-039c: throws with generic message when Google error has no message', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: {} }),
}),
);
const { searchPlaces } = await import('../../../src/services/mapsService');
await expect(searchPlaces(1, 'anything')).rejects.toMatchObject({
message: 'Google Places API error',
status: 500,
});
});
it('MAPS-039d: returns empty places array when Google returns no results', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ places: [] }),
}),
);
const { searchPlaces } = await import('../../../src/services/mapsService');
const result = await searchPlaces(1, 'very obscure place');
expect(result.source).toBe('google');
expect(result.places).toHaveLength(0);
});
it('MAPS-039e: handles Google result with optional fields absent', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
// id only, no displayName, formattedAddress, location, etc.
places: [{ id: 'gid-sparse' }],
}),
}),
);
const { searchPlaces } = await import('../../../src/services/mapsService');
const result = await searchPlaces(1, 'sparse');
const place = result.places[0] as any;
expect(place.google_place_id).toBe('gid-sparse');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe('');
expect(place.address).toBe('');
expect(place.lat).toBeNull();
expect(place.lng).toBeNull();
expect(place.rating).toBeNull();
expect(place.website).toBeNull();
expect(place.phone).toBeNull();
});
});
// ── autocompletePlaces (fetch stubbed) ──────────────────────────────────────
describe('autocompletePlaces (fetch stubbed)', () => {
it('MAPS-081: uses Nominatim when user has no API key', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{
osm_type: 'node',
osm_id: '1',
lat: '48.8',
lon: '2.3',
display_name: 'Paris, Île-de-France, France',
name: 'Paris',
},
],
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(999, 'Paris');
expect(result.source).toBe('nominatim');
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].mainText).toBe('Paris');
expect(result.suggestions[0].placeId).toBe('node:1');
});
it('MAPS-082: uses Google when user has an API key', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
suggestions: [
{
placePrediction: {
placeId: 'ChIJ1234',
structuredFormat: {
mainText: { text: 'Eiffel Tower' },
secondaryText: { text: 'Paris, France' },
},
},
},
],
}),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(1, 'Eiffel');
expect(result.source).toBe('google');
expect(result.suggestions).toHaveLength(1);
expect(result.suggestions[0].placeId).toBe('ChIJ1234');
expect(result.suggestions[0].mainText).toBe('Eiffel Tower');
expect(result.suggestions[0].secondaryText).toBe('Paris, France');
});
it('MAPS-083: throws with Google error status when API returns non-ok', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: async () => ({ error: { message: 'API key invalid' } }),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({
message: 'API key invalid',
status: 403,
});
});
it('MAPS-084: throws generic message when Google error has no message', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: {} }),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({
message: 'Google Places Autocomplete error',
status: 500,
});
});
it('MAPS-085: returns empty suggestions when Google returns no results', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ suggestions: [] }),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(1, 'very obscure place');
expect(result.source).toBe('google');
expect(result.suggestions).toHaveLength(0);
});
it('MAPS-086: filters out suggestions without placePrediction', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
suggestions: [
{ placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } },
{ queryPrediction: { text: 'some query' } },
{ placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } },
],
}),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(1, 'test');
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions[0].placeId).toBe('A');
expect(result.suggestions[1].placeId).toBe('B');
});
it('MAPS-087: limits results to 5 suggestions', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
const manySuggestions = Array.from({ length: 10 }, (_, i) => ({
placePrediction: {
placeId: `id-${i}`,
structuredFormat: { mainText: { text: `Place ${i}` } },
},
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ suggestions: manySuggestions }),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(1, 'test');
expect(result.suggestions).toHaveLength(5);
});
it('MAPS-088: includes locationBias in Google request when provided', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ suggestions: [] }),
});
vi.stubGlobal('fetch', fetchMock);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
await autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
expect(fetchMock).toHaveBeenCalledOnce();
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.locationBias).toEqual({
rectangle: {
low: { latitude: 48.5, longitude: 2.0 },
high: { latitude: 49.0, longitude: 2.8 },
},
});
});
it('MAPS-089: omits locationBias from Google request when not provided', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ suggestions: [] }),
});
vi.stubGlobal('fetch', fetchMock);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
await autocompletePlaces(1, 'test', 'en');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.locationBias).toBeUndefined();
});
it('MAPS-090: handles missing structuredFormat fields gracefully', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
suggestions: [{ placePrediction: { placeId: 'sparse-id' } }],
}),
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(1, 'sparse');
expect(result.suggestions[0].placeId).toBe('sparse-id');
expect(result.suggestions[0].mainText).toBe('');
expect(result.suggestions[0].secondaryText).toBe('');
});
it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(999, 'fail');
expect(result.source).toBe('nominatim');
expect(result.suggestions).toHaveLength(0);
});
it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{
osm_type: 'way',
osm_id: '42',
lat: '51.5',
lon: '-0.1',
display_name: 'Big Ben, Westminster, London, UK',
name: 'Big Ben',
},
],
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(999, 'Big Ben');
expect(result.suggestions[0].mainText).toBe('Big Ben');
expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK');
});
it('MAPS-093: Nominatim fallback filters out results with empty osm_id', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
{ osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' },
{ osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' },
],
}),
);
const { autocompletePlaces } = await import('../../../src/services/mapsService');
const result = await autocompletePlaces(999, 'test');
expect(result.suggestions).toHaveLength(2);
expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']);
});
});
// ── getPlaceDetails (fetch stubbed) ─────────────────────────────────────────
describe('getPlaceDetails (fetch stubbed)', () => {
it('MAPS-040: handles OSM placeId (way:id) via Overpass', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ elements: [{ tags: { website: 'https://eiffel.com' } }] }),
}),
);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'way:12345');
expect(result.place).toBeDefined();
expect((result.place as any).source).toBe('openstreetmap');
expect((result.place as any).website).toBe('https://eiffel.com');
});
it('MAPS-040b: handles OSM placeId when Overpass returns no tags (element missing)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ elements: [] }),
}),
);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'node:99999');
expect((result.place as any).source).toBe('openstreetmap');
expect((result.place as any).website).toBeNull();
});
it('MAPS-041: throws 400 when Google placeId given but no API key', async () => {
const { getPlaceDetails } = await import('../../../src/services/mapsService');
await expect(getPlaceDetails(999, 'ChIJNotAnOsmId')).rejects.toMatchObject({ status: 400 });
});
it('MAPS-041b: returns full Google place details on happy path', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 'ChIJ123',
displayName: { text: 'Eiffel Tower' },
formattedAddress: 'Champ de Mars, 5 Av. Anatole France, 75007 Paris',
location: { latitude: 48.8584, longitude: 2.2945 },
rating: 4.7,
userRatingCount: 200000,
websiteUri: 'https://www.toureiffel.paris',
nationalPhoneNumber: '+33 892 70 12 39',
regularOpeningHours: {
weekdayDescriptions: ['Monday: 9:00 AM – 12:00 AM'],
openNow: true,
},
// 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: [
{
authorAttribution: { displayName: 'John', photoUri: 'https://photo.url' },
rating: 5,
text: { text: 'Amazing!' },
relativePublishTimeDescription: '2 weeks ago',
},
],
photos: [{ name: 'places/ChIJ123/photos/photo1', authorAttributions: [{ displayName: 'Jane' }] }],
}),
}),
);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'ChIJ123');
const place = result.place as any;
expect(place.google_place_id).toBe('ChIJ123');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe('Eiffel Tower');
expect(place.rating).toBe(4.7);
expect(place.rating_count).toBe(200000);
expect(place.open_now).toBe(true);
expect(place.source).toBe('google');
// Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those
expect(place.reviews).toHaveLength(0);
expect(place.summary).toBeNull();
});
it('MAPS-041b2: normalises non-standard TREK language codes for Google (br→pt-BR, gr→el)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' }, location: { latitude: 0, longitude: 0 } }),
});
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', fetchMock);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
await getPlaceDetails(1, 'ChIJ-br', 'br');
expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=pt-BR');
await getPlaceDetails(1, 'ChIJ-gr', 'gr');
expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=el');
// A code that is already valid passes through unchanged.
await getPlaceDetails(1, 'ChIJ-de', 'de');
expect(String(fetchMock.mock.calls[2][0])).toContain('languageCode=de');
});
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 404,
json: async () => ({ error: { message: 'Place not found' } }),
}),
);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
await expect(getPlaceDetails(1, 'ChIJMissing')).rejects.toMatchObject({
message: 'Place not found',
status: 404,
});
});
it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
// expanded=1 cache miss → return undefined
mockDbGet.mockReturnValueOnce(undefined);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 'ChIJ456',
reviews: [
// All optional fields absent
{},
],
}),
}),
);
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
const result = await getPlaceDetailsExpanded(1, 'ChIJ456');
const review = (result.place as any).reviews[0];
expect(review.author).toBeNull();
expect(review.rating).toBeNull();
expect(review.text).toBeNull();
expect(review.time).toBeNull();
expect(review.photo).toBeNull();
});
it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => {
const fetchMock = vi
.fn()
// First call: Overpass (returns element with tags but no coords)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }),
})
// Second call: Nominatim /lookup
.mockResolvedValueOnce({
ok: true,
json: async () => [
{
osm_type: 'way',
osm_id: '5',
lat: '48.85',
lon: '2.29',
display_name: 'Eiffel Tower, Paris, France',
name: 'Eiffel Tower',
},
],
});
vi.stubGlobal('fetch', fetchMock);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'way:5');
const place = result.place as any;
expect(place.name).toBe('Eiffel Tower');
expect(place.address).toBe('Eiffel Tower, Paris, France');
expect(place.lat).toBeCloseTo(48.85);
expect(place.lng).toBeCloseTo(2.29);
expect(place.source).toBe('openstreetmap');
// Overpass first, then Nominatim — two total fetch calls
expect(fetchMock).toHaveBeenCalledTimes(2);
const overpassUrl = fetchMock.mock.calls[0][0] as string;
const nominatimUrl = fetchMock.mock.calls[1][0] as string;
expect(overpassUrl).toContain('overpass');
expect(nominatimUrl).toContain('nominatim');
});
it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 'ChIJ789',
regularOpeningHours: {
weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'],
// openNow intentionally absent
},
}),
}),
);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'ChIJ789');
expect((result.place as any).open_now).toBeNull();
});
it('MAPS-041f: open_now is false when regularOpeningHours.openNow is false', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 'ChIJClosed',
regularOpeningHours: {
weekdayDescriptions: ['Monday: 9:00 AM – 5:00 PM'],
openNow: false,
},
}),
}),
);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
const result = await getPlaceDetails(1, 'ChIJClosed');
// false is preserved (not coerced to null) via the ?? null operator
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
mockDbGet.mockReturnValueOnce(undefined);
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
authorAttribution: { displayName: `User${i}` },
rating: 4,
text: { text: 'Good' },
relativePublishTimeDescription: '1 day ago',
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
}),
);
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
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) ────────────────────────────────────────────
describe('getPlacePhoto (fetch stubbed)', () => {
it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
// First call: Wikimedia Commons API
.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
}),
})
// Second call: fetch Wikimedia image bytes
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(100),
}),
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const placeId = 'coords:48.8,2.3';
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
expect(mockCachePut).toHaveBeenCalledOnce();
});
it('MAPS-043: resolves with photoUrl null when Wikimedia returns nothing and no API key', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ query: { pages: {} } }),
}),
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
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 () => {
const placeId = `coords:cache-test-${Date.now()}`;
const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
mockCacheGet.mockReturnValue({
photoUrl: cachedUrl,
filePath: `/tmp/${placeId}.jpg`,
attribution: null,
});
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test');
expect(result.photoUrl).toBe(cachedUrl);
expect(fetchMock).not.toHaveBeenCalled();
});
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)).resolves.toEqual({ photoUrl: null, attribution: null });
expect(fetchMock).not.toHaveBeenCalled();
});
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)).resolves.toEqual({ photoUrl: null, attribution: null });
});
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')).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 () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
const fetchMock = vi
.fn()
// First call: get place details (with photos)
.mockResolvedValueOnce({
ok: true,
text: async () =>
JSON.stringify({
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
}),
})
// Second call: fetch image bytes
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(200),
});
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const uniqueId = `ChIJABC-${Date.now()}`;
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
expect(result.attribution).toBe('Photographer');
expect(mockCachePut).toHaveBeenCalledOnce();
});
it('MAPS-044b: resolves with photoUrl null when Google details fetch returns non-ok', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
text: async () => JSON.stringify({ error: { message: 'Forbidden' } }),
}),
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const errId = `ChIJErr-${Date.now()}`;
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-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',
vi.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify({ photos: [] }),
}),
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const noPhotoId = `ChIJNone-${Date.now()}`;
await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).resolves.toEqual({ photoUrl: null, attribution: null });
});
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()
.mockResolvedValueOnce({
ok: true,
text: async () =>
JSON.stringify({
photos: [{ name: 'places/ChIJXYZ/photos/photo1', authorAttributions: [] }],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
arrayBuffer: async () => new ArrayBuffer(0),
});
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)).resolves.toEqual({ photoUrl: null, attribution: null });
});
it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
text: async () =>
JSON.stringify({
photos: [{ name: 'places/ChIJNoAttr/photos/photo1', authorAttributions: [] }],
}),
})
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(150),
});
vi.stubGlobal('fetch', fetchMock);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const noAttrId = `ChIJNoAttr-${Date.now()}`;
const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3);
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`);
expect(result.attribution).toBeNull();
});
it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
}),
})
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(120),
}),
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const uniqueId = `coords:44f-test-${Date.now()}`;
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
expect(mockCachePut).toHaveBeenCalledOnce();
});
it('MAPS-044g: falls back to Wikipedia/OSM for a Google place_id when the Google photo call fails', async () => {
// A key is present and the placeId is a Google id, but Google rejects the
// photo request (e.g. 403). The lookup must still return an image via the
// coordinate-based Wikipedia fallback instead of giving up with a 404 —
// matching what right-click (coords:) places already do.
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal(
'fetch',
vi
.fn()
// 1) Google photo details → 403
.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => JSON.stringify({ error: { message: 'PERMISSION_DENIED' } }),
})
// 2) Wikipedia pageimages → thumbnail
.mockResolvedValueOnce({
ok: true,
json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/guinness.jpg' } } } } }),
})
// 3) image bytes
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(200),
}),
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const placeId = `ChIJFallback-${Date.now()}`;
const result = await getPlacePhoto(1, placeId, 53.34, -6.28, 'Guinness Storehouse');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
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', () => {
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();
});
});
// ── buildUserAgent (instance-specific UA, #1309) ──────────────────────────────
describe('buildUserAgent', () => {
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
it('MAPS-094: returns the bare base UA when no instance URL is configured', () => {
expect(buildUserAgent(undefined)).toBe(base);
expect(buildUserAgent('')).toBe(base);
});
it('MAPS-095: appends a configured https instance URL so the deployment is identifiable', () => {
expect(buildUserAgent('https://trek.example.org')).toBe(`${base}; https://trek.example.org`);
});
it('MAPS-096: drops the http://localhost fallback — it is not a unique identifier', () => {
expect(buildUserAgent('http://localhost:3001')).toBe(base);
});
});
// ── resolveOverpassEndpoints (OVERPASS_URL override, #1309) ────────────────────
describe('resolveOverpassEndpoints', () => {
it('MAPS-097: falls back to the public mirrors when OVERPASS_URL is unset/empty', () => {
expect(resolveOverpassEndpoints(undefined).length).toBeGreaterThan(1);
expect(resolveOverpassEndpoints('').length).toBeGreaterThan(1);
expect(resolveOverpassEndpoints(undefined)[0]).toContain('overpass-api.de');
});
it('MAPS-098: a single custom endpoint REPLACES the public mirrors (locked-down egress)', () => {
expect(resolveOverpassEndpoints('https://overpass.internal/api/interpreter')).toEqual([
'https://overpass.internal/api/interpreter',
]);
});
it('MAPS-099: parses a comma-separated list and trims whitespace', () => {
expect(resolveOverpassEndpoints(' https://a.test/api , http://b.test/api ')).toEqual([
'https://a.test/api',
'http://b.test/api',
]);
});
it('MAPS-100: drops non-http(s) / malformed entries, keeping the valid ones', () => {
expect(resolveOverpassEndpoints('https://ok.test/api, ftp://no.test, not a url')).toEqual(['https://ok.test/api']);
});
it('MAPS-101: falls back to the defaults when every custom entry is invalid', () => {
expect(resolveOverpassEndpoints('not a url, ftp://no.test').length).toBeGreaterThan(1);
});
});
// ── resolveOverpassTimeoutMs (OVERPASS_TIMEOUT_MS override, #1309) ─────────────
describe('resolveOverpassTimeoutMs', () => {
it('MAPS-104: falls back to the 12s default for unset / empty / non-numeric values', () => {
expect(resolveOverpassTimeoutMs(undefined)).toBe(12000);
expect(resolveOverpassTimeoutMs('')).toBe(12000);
expect(resolveOverpassTimeoutMs('abc')).toBe(12000);
});
it('MAPS-105: honours a positive numeric override', () => {
expect(resolveOverpassTimeoutMs('30000')).toBe(30000);
});
it('MAPS-106: rejects 0, negative and Infinity — a non-positive cap would 502 every search', () => {
expect(resolveOverpassTimeoutMs('0')).toBe(12000);
expect(resolveOverpassTimeoutMs('-5')).toBe(12000);
expect(resolveOverpassTimeoutMs('Infinity')).toBe(12000);
});
});
// ── searchOverpassPois error path (all endpoints down, #1309) ──────────────────
describe('searchOverpassPois all-endpoints-down', () => {
const bbox = { south: -41.2, west: 146.31, north: -41.16, east: 146.37 };
it('MAPS-102: surfaces a 502 with a clear message when every Overpass endpoint fails', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
await expect(searchOverpassPois('restaurant', bbox)).rejects.toMatchObject({
status: 502,
message: 'Could not reach any Overpass endpoint',
});
errSpy.mockRestore();
});
it('MAPS-103: logs each endpoint failure so an operator can diagnose blocked egress', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')));
await expect(searchOverpassPois('bar', bbox)).rejects.toThrow();
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('[Overpass] all'));
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('ECONNREFUSED'));
errSpy.mockRestore();
});
});