mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
e12ae950df
Since 4.3.0 the TREK index and OpenStreetMap answer every search first and Google is only asked when they find nothing, so a list with the wrong place on it never reached Google, whatever key the instance had, and the provider choice only decided who fills the empty case. Two ways to change that, both only where Google holds the key slot. Under a result list that did not come from Google, one quiet line sends the same query to Google Places alone (`provider: 'google'` on the search contract, the MCP search tool takes it too). And beside the key, an admin switch "Search with Google only" hands every search and every suggestion to Google and asks nothing of the index or OpenStreetMap; off by default, read by the search after the keyed provider so an install without a key, or one on Amap or OpenStreetMap, is untouched. The panel says so beside the switch. Both shells share the one rule for when the line is offered (placeSource), and the wiki names both switches.
310 lines
14 KiB
TypeScript
310 lines
14 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 { 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,
|
|
}));
|
|
|
|
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
|
|
|
import { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
|
|
|
// 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 database = {
|
|
get: vi.fn((sql: string, key?: unknown) =>
|
|
typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined,
|
|
),
|
|
} as unknown as DatabaseService;
|
|
return new MapsService(database, {} as PlacePhotoCacheService);
|
|
}
|
|
|
|
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-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 });
|
|
// 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');
|
|
});
|
|
});
|