test(maps): the place pin and nominatim bias cases get suites of their own

This commit is contained in:
Maurice
2026-10-12 21:13:58 +00:00
parent 0711efcb4b
commit 191b02b6de
3 changed files with 239 additions and 129 deletions
@@ -0,0 +1,104 @@
/**
* MAPS-038b, MAPS-081b, MAPS-081c: the Nominatim fallback keeps the location bias (#2620).
*
* With the TREK Places index off and no key, search and autocomplete fall back to
* Nominatim, and the point or box the client sent has to reach it as a viewbox.
* Moved out of maps.service.test.ts as they were, under the describe names they
* had there.
*/
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { noGoogleQuota } from '../../helpers/google-quota';
import { buildMapsService } from '../../helpers/maps-service';
import { describe, it, expect, vi, afterEach } from 'vitest';
// Every case below switches the index off. It is stubbed to "nothing found" as
// well, so a case that reached it anyway would not leave the runner for
// places.liketrek.com.
const { mockNearby, mockSearch } = vi.hoisted(() => ({
mockNearby: vi.fn(async (): Promise<unknown[]> => []),
mockSearch: vi.fn(async (): Promise<unknown[]> => []),
}));
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
trekPlacesNearby: mockNearby,
trekPlacesSearch: mockSearch,
}));
// No key is configured, so neither Google nor Amap answers and the query falls
// through to Nominatim.
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
const svc = buildMapsService(
{} as PlacePhotoCacheService,
noAppSettings,
noUsers,
{} as never,
{} as never,
noGoogleQuota,
);
/** Switch the TREK Places index off for one case, as maps.service.test.ts does. */
let indexSpy: { mockRestore: () => void } | null = null;
function indexOff(): void {
indexSpy = vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false);
}
afterEach(() => {
indexSpy?.mockRestore();
indexSpy = null;
vi.unstubAllGlobals();
});
describe('searchPlaces (fetch stubbed)', () => {
// The same gap on the explicit search: with the index off and no key the
// fallback asked Nominatim again and dropped the bias it had been given (#2620).
it('MAPS-038b: the Nominatim fallback keeps the location bias', async () => {
indexOff();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
await svc.searchPlaces(999, 'Starbucks', 'en', { lat: 35, lng: 135.75 });
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.searchParams.get('viewbox')).toBe('135.25,34.5,136.25,35.5');
expect(url.searchParams.get('bounded')).toBe('0');
});
});
describe('autocompletePlaces (fetch stubbed)', () => {
// Without the index and without a key the suggestions come from Nominatim,
// and the box the client already computed has to reach it (#2620). It ranks,
// it does not restrict: bounded=0, so a far away place still comes back.
it('MAPS-081b: hands the location bias to Nominatim as a viewbox', async () => {
indexOff();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
await svc.autocompletePlaces(999, 'Starbucks', 'en', {
low: { lat: 34.9, lng: 135.6 },
high: { lat: 35.1, lng: 135.9 },
});
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.pathname).toContain('/search');
expect(url.searchParams.get('bounded')).toBe('0');
const [west, south, east, north] = String(url.searchParams.get('viewbox')).split(',').map(Number);
expect((west + east) / 2).toBeCloseTo(135.75);
expect((south + north) / 2).toBeCloseTo(35);
});
it('MAPS-081c: asks Nominatim without a viewbox when no bias was sent', async () => {
indexOff();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
await svc.autocompletePlaces(999, 'Starbucks', 'en');
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.searchParams.has('viewbox')).toBe(false);
});
});
@@ -0,0 +1,135 @@
/**
* MAPS-2635-001..007: a pasted Google place link pins the place, not the camera.
*
* Moved out of maps.service.test.ts as they were. The other resolveGoogleMapsUrl
* cases (coordinate extraction, short links, the SSRF guard, the ReDoS budgets)
* stay there.
*/
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { noGoogleQuota } from '../../helpers/google-quota';
import { buildMapsService } from '../../helpers/maps-service';
import { describe, it, expect, vi, afterEach } from 'vitest';
// Short links and cid pages are followed hop by hop through the SSRF guard. Here
// every hop is let through to the stubbed fetch, which already answers with the
// resolved response.
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {}
return {
SsrfBlockedError,
checkSsrf: vi.fn(async () => ({ allowed: true })),
safeFetchFollow: vi.fn(async (url: string, init?: RequestInit) => globalThis.fetch(url, init)),
};
});
// No key is configured: a Google link is resolved without one.
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
const svc = buildMapsService(
{} as PlacePhotoCacheService,
noAppSettings,
noUsers,
{} as never,
{} as never,
noGoogleQuota,
);
// Nominatim stub used by resolveGoogleMapsUrl after coordinate extraction
const nominatimStub = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ display_name: 'Paris, France', name: null, address: {} }),
});
afterEach(() => {
vi.unstubAllGlobals();
});
// A Google place URL carries two points: @lat,lng is where the camera looks,
// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635).
describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => {
// What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to.
const EIFFEL_SHARE_TARGET =
'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b';
const nominatimAt = (calls: string[]) =>
vi.fn(async (u: string) => {
calls.push(u);
if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET };
return {
ok: true,
json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }),
};
});
it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', nominatimAt(calls));
const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET);
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
expect(result.name).toBe('Tour Eiffel');
expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0');
// The address is looked up at the place too.
const reverse = calls.find((u) => u.includes('nominatim'));
expect(reverse).toContain('lat=48.8583701');
expect(reverse).toContain('lon=2.2944813');
});
it('MAPS-2635-002: the share short link resolves to the place pin', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', nominatimAt(calls));
const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9');
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
it('MAPS-2635-003: only !3d!4d still resolves', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl(
'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813',
);
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
it('MAPS-2635-004: only @ still resolves to the camera point', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m');
expect(result.lat).toBe(48.8579431);
expect(result.lng).toBe(2.292255);
});
it('MAPS-2635-005: only ?q= still resolves', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813');
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445');
expect(result.lat).toBe(48.85);
expect(result.lng).toBe(2.29);
});
it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => {
const fetchMock = vi.fn(async (u: string) => {
if (u.includes('nominatim')) {
return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) };
}
return {
url: 'https://www.google.com/maps/place/Somewhere',
text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z',
};
});
vi.stubGlobal('fetch', fetchMock);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
});
-129
View File
@@ -865,89 +865,6 @@ describe('resolveGoogleMapsUrl coordinate extraction (ReDoS guards)', () => {
});
});
// A Google place URL carries two points: @lat,lng is where the camera looks,
// !3d!4d is the place itself. Pinning the camera lands next to the place (#2635).
describe('resolveGoogleMapsUrl place pin before camera (#2635)', () => {
// What https://maps.app.goo.gl/CdynKSHT16bufnAp9 redirects to.
const EIFFEL_SHARE_TARGET =
'https://www.google.com/maps/place/Tour+Eiffel/@48.8579431,2.292255,812m/data=!3m1!1e3!4m7!3m6!1s0x47e66e2964e34e2d:0x8ddca9ee380ef7e0!8m2!3d48.8583701!4d2.2944813!15sChFtYXBzIGVpZmZlbCB0b3dlcloTIhFtYXBzIGVpZmZlbCB0b3dlcpIBE2hpc3RvcmljYWxfbGFuZG1hcmuaAURDaTlEUVVsUlFVTnZaRU5vZEhsalJqbHZUMnR3VGxkWGRFaFBWM2gyVFd3NWVtTnVTalJNVlU1RVUxWm9ORTlWUlJBQuABAPoBBAgAEDg!16zL20vMDJqODE?entry=tts&g_ep=EgoyMDI2MTAwNy4wIPu8ASoASAFQAw%3D%3D&skid=20b521f5-0f45-4a02-8475-b2a1c2bdcb0b';
const nominatimAt = (calls: string[]) =>
vi.fn(async (u: string) => {
calls.push(u);
if (u.includes('goo.gl')) return { url: EIFFEL_SHARE_TARGET };
return { ok: true, json: async () => ({ display_name: 'Avenue Gustave Eiffel, Paris', name: null, address: {} }) };
});
it('MAPS-2635-001: a place URL with both @ and !3d!4d resolves to the place, not the camera', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', nominatimAt(calls));
const result = await svc.resolveGoogleMapsUrl(EIFFEL_SHARE_TARGET);
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
expect(result.name).toBe('Tour Eiffel');
expect(result.google_ftid).toBe('0x47e66e2964e34e2d:0x8ddca9ee380ef7e0');
// The address is looked up at the place too.
const reverse = calls.find((u) => u.includes('nominatim'));
expect(reverse).toContain('lat=48.8583701');
expect(reverse).toContain('lon=2.2944813');
});
it('MAPS-2635-002: the share short link resolves to the place pin', async () => {
const calls: string[] = [];
vi.stubGlobal('fetch', nominatimAt(calls));
const result = await svc.resolveGoogleMapsUrl('https://maps.app.goo.gl/CdynKSHT16bufnAp9');
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
it('MAPS-2635-003: only !3d!4d still resolves', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl(
'https://www.google.com/maps/place/Tour+Eiffel/data=!4m7!3m6!8m2!3d48.8583701!4d2.2944813',
);
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
it('MAPS-2635-004: only @ still resolves to the camera point', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.8579431,2.292255,812m');
expect(result.lat).toBe(48.8579431);
expect(result.lng).toBe(2.292255);
});
it('MAPS-2635-005: only ?q= still resolves', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?q=48.8583701,2.2944813');
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
it('MAPS-2635-006: @ keeps its precedence over ?q= when no place pin is present', async () => {
vi.stubGlobal('fetch', nominatimStub);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps/@48.85,2.29,15z?q=40.6892,-74.0445');
expect(result.lat).toBe(48.85);
expect(result.lng).toBe(2.29);
});
it('MAPS-2635-007: a page body with both @ and !3d!4d resolves to the place', async () => {
const fetchMock = vi.fn(async (u: string) => {
if (u.includes('nominatim')) {
return { ok: true, json: async () => ({ display_name: 'Paris', name: null, address: {} }) };
}
return {
url: 'https://www.google.com/maps/place/Somewhere',
text: async () => 'x/@48.8579431,2.292255,812m/y!3d48.8583701!4d2.2944813z',
};
});
vi.stubGlobal('fetch', fetchMock);
const result = await svc.resolveGoogleMapsUrl('https://www.google.com/maps?cid=999');
expect(result.lat).toBe(48.8583701);
expect(result.lng).toBe(2.2944813);
});
});
// ── searchNominatim (fetch-dependent) ────────────────────────────────────────
describe('searchNominatim (fetch stubbed)', () => {
@@ -1413,20 +1330,6 @@ describe('searchPlaces (fetch stubbed)', () => {
expect(Array.isArray(result.places)).toBe(true);
});
// The same gap on the explicit search: with the index off and no key the
// fallback asked Nominatim again and dropped the bias it had been given (#2620).
it('MAPS-038b: the Nominatim fallback keeps the location bias', async () => {
indexOff();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
await svc.searchPlaces(999, 'Starbucks', 'en', { lat: 35, lng: 135.75 });
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.searchParams.get('viewbox')).toBe('135.25,34.5,136.25,35.5');
expect(url.searchParams.get('bounded')).toBe('0');
});
// Session tokens: the keystrokes of one search and the details lookup that
// ends it must reach Google under the same token, or every request is billed
// on its own. The body field and the query parameter are what Google's
@@ -1639,38 +1542,6 @@ describe('autocompletePlaces (fetch stubbed)', () => {
expect(result.suggestions[0].placeId).toBe('node:1');
});
// Without the index and without a key the suggestions come from Nominatim,
// and the box the client already computed has to reach it (#2620). It ranks,
// it does not restrict: bounded=0, so a far away place still comes back.
it('MAPS-081b: hands the location bias to Nominatim as a viewbox', async () => {
indexOff();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
await svc.autocompletePlaces(999, 'Starbucks', 'en', {
low: { lat: 34.9, lng: 135.6 },
high: { lat: 35.1, lng: 135.9 },
});
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.pathname).toContain('/search');
expect(url.searchParams.get('bounded')).toBe('0');
const [west, south, east, north] = String(url.searchParams.get('viewbox')).split(',').map(Number);
expect((west + east) / 2).toBeCloseTo(135.75);
expect((south + north) / 2).toBeCloseTo(35);
});
it('MAPS-081c: asks Nominatim without a viewbox when no bias was sent', async () => {
indexOff();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
await svc.autocompletePlaces(999, 'Starbucks', 'en');
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.searchParams.has('viewbox')).toBe(false);
});
it('MAPS-082: uses Google when user has an API key', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' }).mockReturnValueOnce(null);
vi.stubGlobal(