Files
TREK/server/tests/unit/nest/maps.search-merge.test.ts
Maurice accad52ed5 fix(maps): place suggestions name their town and country in the language of the search
The TREK Places service takes an optional lang now and puts localities into
it, and names its OpenStreetMap layer rows in it with the settlement they are
in. TREK never sent one, so a Japan trip in an English UI kept reading
"福岡市博多区" and the layer rows had no place at all, only the local name.

Search, autocomplete, nearby and the details of an index place now ask in the
reader's language (TREK codes through toApiLang, anything that is no language
tag left off). A layer row with a locality reads "Sumida, Japan" like an index
row, the country named from its coordinate; without one it keeps the local
name as before. The offline area download stays without a language. REST and
MCP share the service methods, so both get it.

Refs #2592
2026-10-12 20:53:08 +02:00

330 lines
15 KiB
TypeScript

/**
* MAPS-SEARCH-001..008 — the two sources answering one explicit search.
*
* mergeSearchResults is tested on its own next door; this is the wiring around
* it, which had no test at all. What the wiring decides is not cosmetic: which
* list goes in first (the whole point of the feature is that the landmark lands
* at position two rather than eleven), and what `source` says — the client
* switches on it and the search log writes it into the corpus a future index
* gets scored against.
*/
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, beforeEach } from 'vitest';
const { mockSearch, mockNominatim } = vi.hoisted(() => ({
mockSearch: vi.fn(async (_query: string, _opts?: Record<string, unknown>): Promise<unknown> => []),
mockNominatim: vi.fn(
async (_endpoint: string, _params: URLSearchParams, _opts?: { lane?: string }): Promise<unknown> => ({
ok: true,
json: async () => [],
}),
),
}));
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
trekPlacesSearch: mockSearch,
}));
vi.mock('../../../src/nest/geo/nominatim.client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/geo/nominatim.client')>()),
nominatimFetch: mockNominatim,
}));
// keyedProvider/resolveMapsKey (maps.service.ts) go through instance-api-keys.ts
// on every call now. No per-user key is configured anywhere here; the instance
// rows a case needs come from make()'s `rows`, read through AppSettingsRepository.
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
// The index switch is an environment variable now, not an admin row: it decides
// whether a search leaves the instance at all, so it is pinned by the operator
// rather than flippable from a browser. `setTrekPlaces()` below drives it.
const trekPlaces = vi.hoisted(() => ({ on: true }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return {
...actual,
readEnv: () => {
const env = actual.readEnv();
return { ...env, maps: { ...env.maps, trekPlacesEnabled: trekPlaces.on } };
},
};
});
/** An index row, far enough from the OSM one below not to be deduped. */
const indexHit = (name: string, lat = 35.31, lng = 139.53) => ({
gers: name.toLowerCase().replace(/\W/g, ''),
name,
lat,
lng,
address: { freeform: null, locality: 'Kamakura', postcode: null, region: null, country: 'JP' },
contact: { website: null, phone: null, email: null, socials: null },
source: 'overture',
hours: null,
});
const osmRow = (name: string, lat = 35.32, lng = 139.55) => ({
osm_type: 'way',
osm_id: 1234 + Math.round(lat * 1000),
lat: String(lat),
lon: String(lng),
name,
display_name: `${name}, Kamakura, Japan`,
});
const osmAnswer = (rows: unknown[]) => ({ ok: true, json: async () => rows });
/**
* Keyed on the statement so the kill switch can be driven without also handing
* `'false'` to the API-key resolver, which would send the fallback at Google.
*
* `rows` are the app_settings the instance holds, by key: a Google key, the
* provider choice, the Google-only switch. Anything else reads as absent.
*/
function make(enabled = true, rows: Record<string, string> = {}) {
trekPlaces.on = enabled;
const appSettings = {
getValue: async (key: string) => (rows[key] !== undefined ? rows[key] : null),
} as unknown as AppSettingsRepository;
return buildMapsService({} as PlacePhotoCacheService, appSettings, noUsers, {} as never, {} as never, noGoogleQuota);
}
const googleAnswer = (name: string) => ({
ok: true,
json: async () => ({
places: [
{
id: 'g1',
displayName: { text: name },
formattedAddress: 'Tokyo',
location: { latitude: 35.68, longitude: 139.77 },
},
],
}),
});
beforeEach(() => {
mockSearch.mockReset();
mockSearch.mockResolvedValue([]);
mockNominatim.mockReset();
mockNominatim.mockResolvedValue(osmAnswer([]));
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('MapsService.searchPlaces — the merged path', () => {
it('MAPS-SEARCH-001: both sources answer, index first, and says so', async () => {
mockSearch.mockResolvedValue([indexHit('Shop by the gate')]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('長谷寺')]));
const out = await make().searchPlaces(1, 'Hase-dera');
expect(out.source).toBe('trek-places+openstreetmap');
expect(out.places).toHaveLength(2);
// The whole point of the feature: the landmark OpenStreetMap knows lands at
// position two, not at position eleven behind ten shops.
expect(out.places[0].name).toBe('Shop by the gate');
expect(out.places[1].name).toBe('長谷寺');
});
it('MAPS-SEARCH-014: the index is asked in the language of the search, like OpenStreetMap is (#2592)', async () => {
mockSearch.mockResolvedValue([indexHit('Kirby Cafe Hakata')]);
await make().searchPlaces(1, 'kirby cafe', 'de');
expect(mockSearch).toHaveBeenCalledWith('kirby cafe', expect.objectContaining({ lang: 'de' }));
});
it('MAPS-SEARCH-002: an index answer with nothing from OSM is named as index only', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockResolvedValue(osmAnswer([]));
const out = await make().searchPlaces(1, "L'Osteria");
// `source` is not decoration: the search log writes it into the corpus a
// candidate index is scored against later, so a wrong label poisons the
// measurement rather than just the display.
expect(out.source).toBe('trek-places');
expect(out.places).toHaveLength(1);
});
it('MAPS-SEARCH-003: OpenStreetMap failing costs its half, not the search', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockRejectedValue(new Error('nominatim down'));
const out = await make().searchPlaces(1, "L'Osteria");
expect(out.source).toBe('trek-places');
expect(out.places).toHaveLength(1);
expect(console.warn).toHaveBeenCalledWith('OpenStreetMap search failed, index only:', 'nominatim down');
});
it('MAPS-SEARCH-004: the index failing keeps the answer OpenStreetMap already gave', async () => {
// The index turning a query down is ordinary traffic, not an outage: a
// common single word without coordinates is refused upstream as too
// expensive. Discarding the OSM results here and asking Nominatim again
// cost a second throttled round trip for a list that was already in hand.
mockSearch.mockRejectedValue(new Error('places api down'));
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Museum')]));
const out = await make().searchPlaces(1, 'museum');
expect(out.places).toHaveLength(1);
expect(out.places[0].name).toBe('Museum');
expect(console.warn).toHaveBeenCalledWith('TREK Places search failed, falling back:', 'places api down');
// One question, asked once.
expect(mockNominatim).toHaveBeenCalledTimes(1);
});
it('MAPS-SEARCH-009: names the sources that answered, not the ones that were asked', async () => {
// Either side can come back empty in ordinary use: the index turns down a
// common single word without coordinates, and OpenStreetMap can be down. The
// search log writes this field into the corpus a candidate index is scored
// against later, so a list that is entirely OpenStreetMap must not be
// recorded as though the index had a hand in it.
mockSearch.mockRejectedValue(new Error('refused'));
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Museum')]));
expect((await make().searchPlaces(1, 'museum')).source).toBe('openstreetmap');
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockResolvedValue(osmAnswer([]));
expect((await make().searchPlaces(1, "L'Osteria")).source).toBe('trek-places');
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse', 36.5, 12.2)]));
expect((await make().searchPlaces(1, "L'Osteria")).source).toBe('trek-places+openstreetmap');
});
it('MAPS-SEARCH-005: both empty asks nobody a second time', async () => {
mockSearch.mockResolvedValue([]);
mockNominatim.mockResolvedValue(osmAnswer([]));
const out = await make().searchPlaces(1, 'qwertzuiop');
expect(out.places).toEqual([]);
expect(out.source).toBe('openstreetmap');
// The first call carried a viewbox with bounded=0, which orders results
// without changing which exist — so a second call can only return the same
// empty list a throttle interval later.
expect(mockNominatim).toHaveBeenCalledTimes(1);
});
it('MAPS-SEARCH-006: ten results at most, alternating so neither source is buried', async () => {
mockSearch.mockResolvedValue(Array.from({ length: 12 }, (_, i) => indexHit(`Index ${i}`, 35.31 + i / 100)));
mockNominatim.mockResolvedValue(
osmAnswer(Array.from({ length: 12 }, (_, i) => osmRow(`Osm ${i}`, 36.5 + i / 100))),
);
const out = await make().searchPlaces(1, 'anything');
expect(out.places).toHaveLength(10);
expect(out.places[0].name).toBe('Index 0');
expect(out.places[1].name).toBe('Osm 0');
expect(out.places[2].name).toBe('Index 1');
});
it('MAPS-SEARCH-011: the Google-only switch sends the search to Google and asks neither the index nor OpenStreetMap', async () => {
mockSearch.mockResolvedValue([indexHit('Weigh station, Ritzville')]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Some station')]));
const fetchMock = vi.fn().mockResolvedValue(googleAnswer('Tokyo Station'));
vi.stubGlobal('fetch', fetchMock);
const out = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).searchPlaces(1, 'Tokyo Station');
expect(mockSearch).not.toHaveBeenCalled();
expect(mockNominatim).not.toHaveBeenCalled();
expect(out.source).toBe('google');
expect(out.places[0].name).toBe('Tokyo Station');
expect(String(fetchMock.mock.calls[0][0])).toContain('places:searchText');
vi.unstubAllGlobals();
});
it('MAPS-SEARCH-012: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse')]));
// No key at all: the index and OpenStreetMap answer, as before.
const keyless = await make(true, { places_google_only: 'true' }).searchPlaces(1, "L'Osteria");
expect(keyless.source).toBe('trek-places+openstreetmap');
// A key, but the admin picked OpenStreetMap as the provider: Google holds
// no slot, so there is nothing for the switch to hand the search to.
mockSearch.mockClear();
const osmOnly = await make(true, {
maps_api_key: 'key',
places_google_only: 'true',
places_provider: 'openstreetmap',
}).searchPlaces(1, "L'Osteria");
expect(mockSearch).toHaveBeenCalled();
expect(osmOnly.source).toBe('trek-places+openstreetmap');
// A key and the switch off: the index still answers first.
mockSearch.mockClear();
const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).searchPlaces(1, "L'Osteria");
expect(mockSearch).toHaveBeenCalled();
expect(off.source).toBe('trek-places+openstreetmap');
});
it('MAPS-SEARCH-013: one search sent to Google on purpose goes there alone, and only where Google holds the slot', async () => {
mockSearch.mockResolvedValue([indexHit('Weigh station, Ritzville')]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Some station')]));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(googleAnswer('Tokyo Station')));
const asked = await make(true, { maps_api_key: 'key' }).searchPlaces(1, 'Tokyo Station', undefined, undefined, {
googleOnly: true,
});
expect(mockSearch).not.toHaveBeenCalled();
expect(asked.source).toBe('google');
// The same request on an install without a key is an ordinary search: the
// link that sends it is never shown there, and a client that sends it
// anyway gets what everybody else gets.
mockSearch.mockClear();
const keyless = await make(true, {}).searchPlaces(1, 'Tokyo Station', undefined, undefined, { googleOnly: true });
expect(mockSearch).toHaveBeenCalled();
expect(keyless.source).toBe('trek-places+openstreetmap');
vi.unstubAllGlobals();
});
it('MAPS-SEARCH-007: with the index switched off the query never leaves for it', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse')]));
const out = await make(false).searchPlaces(1, "L'Osteria");
expect(mockSearch).not.toHaveBeenCalled();
expect(out.source).toBe('openstreetmap');
});
it('MAPS-SEARCH-010: the OpenStreetMap half runs on a deadline of its own', async () => {
// The pair costs the slower one. The index gives up after 3.5 s; without a
// deadline the Nominatim side rode undici's 300 s default, so a Nominatim
// that accepted the connection and sat on it held the whole answer past the
// browser's 8 s and threw the index's results away with it. The client
// builds the signal after its throttle wait, so this only has to fit the
// answer itself, and it has to stay under the browser's budget.
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
await make().searchPlaces(1, "L'Osteria");
const opts = mockNominatim.mock.calls[0][2] as { lane?: string; timeoutMs?: number };
expect(opts.lane).toBe('interactive');
expect(opts.timeoutMs).toBeGreaterThan(0);
expect(opts.timeoutMs).toBeLessThan(8000);
});
it('MAPS-SEARCH-008: the location bias reaches both sources', async () => {
mockSearch.mockResolvedValue([indexHit("L'Osteria")]);
await make().searchPlaces(1, "L'Osteria", 'de', { lat: 54.0879, lng: 12.1408, radius: 5000 });
expect(mockSearch).toHaveBeenCalledWith("L'Osteria", { lat: 54.0879, lng: 12.1408, limit: 10, lang: 'de' });
// Without the viewbox, "Hase-dera" answers with the temple in Nara rather
// than the one the user is standing beside in Kamakura.
const params = mockNominatim.mock.calls[0][1] as URLSearchParams;
expect(params.get('viewbox')).toBeTruthy();
expect(params.get('bounded')).toBe('0');
});
});