Files
TREK/server/tests/unit/nest/maps.search-merge.test.ts
T
Maurice a55d4e786f maps: lead with the place a name stands for, and leave Nominatim to installs without the services
The suggestions and the full search now share one merge. Photon's hits are
ranked with places that have a Wikidata link before their unlinked
namesakes, and its context bias is narrower (zoom 13). The list opens on a
lead where Photon's answer has one: a country or region always; a town,
island or sight when it is called what was typed (or a near typo of it),
lies within 1000 km of where the traveller is looking and no place of that
name ranks above it. Rows of the lead's name inside it, the same element
under two ids and rows that read the same are shown once. A three-letter
code in capitals is asked for as the airport of the bundled list.

Where Photon is the geocoder it answers alone: an empty answer and a miss
are the answer for search and imports, the reverse lookup asks again
within 50 km, a Google place is matched inside a box through Photon, and
an opened OpenStreetMap place is placed by Overpass and named by Photon.
Nominatim stays the search of installations without the TREK services or
with an operator's own Nominatim.

Measured on 5000 places from Wikidata: the right place first while typing
48 % before, 65 % now; full search 53 % to 68 %; no repeated rows.
2026-10-30 16:15:59 +01:00

336 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,
sources: 'index,osm',
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');
});
});