Files
TREK/server/tests/unit/nest/maps.search-merge.test.ts
T
Maurice 7acf7f5655 feat(admin): a daily limit on Google API calls
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582)
2026-10-01 12:15:38 +02:00

311 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';
import { noGoogleQuota } from '../../helpers/google-quota';
// 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, 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-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');
});
});