mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
/**
|
|
* MAPS-AUTO-001..013: the suggestion list behind the place search box.
|
|
*
|
|
* This is the path the index was built for. Nominatim's usage policy names
|
|
* autocomplete as unacceptable use in its own words, whatever the rate, and the
|
|
* whole TREK fleet shares one User-Agent there — so before the index, every
|
|
* install was one abusive neighbour away from being blocked. What matters here
|
|
* is that an index answer is shaped the way the details lookup can read back
|
|
* (`gers:` ids), and that nothing about a slow or missing index leaves somebody
|
|
* typing into a box that never answers.
|
|
*/
|
|
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 } = vi.hoisted(() => ({
|
|
mockSearch: vi.fn(
|
|
async (_query: string, _opts?: { lat?: number; lng?: number; limit?: number }): Promise<unknown> => [],
|
|
),
|
|
}));
|
|
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
|
trekPlacesSearch: mockSearch,
|
|
}));
|
|
|
|
// 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 } };
|
|
},
|
|
};
|
|
});
|
|
|
|
const INPUT = 'Café Kröpel';
|
|
|
|
const hit = (over: Record<string, unknown> = {}) => ({
|
|
gers: 'abc-123',
|
|
name: 'Café Kröpeliner',
|
|
lat: 54.0879,
|
|
lng: 12.1408,
|
|
address: { locality: 'Rostock', country: 'DE' },
|
|
...over,
|
|
});
|
|
|
|
/**
|
|
* A row from the service's OpenStreetMap layer. A different shape from the
|
|
* index rows above, which is the whole point of the two tests below: no `gers`,
|
|
* no address block, and an id in the service's own `osm:<type>/<id>` form.
|
|
*/
|
|
const osmHit = (over: Record<string, unknown> = {}) => ({
|
|
id: 'osm:node/9712313',
|
|
osm_type: 'node',
|
|
osm_id: 9712313,
|
|
name: 'Tokio Hauptbahnhof',
|
|
local_name: '東京駅丸の内駅舎',
|
|
lat: 35.6811816,
|
|
lng: 139.76598265,
|
|
category: 'sehenswuerdigkeit',
|
|
source: 'openstreetmap',
|
|
...over,
|
|
});
|
|
|
|
/**
|
|
* `enabled` drives the index switch, an environment variable; `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.
|
|
*
|
|
* Keyed on the setting rather than answering everything the same way: the
|
|
* same `getValue` also resolves the Google key (`maps_api_key`), and a blanket
|
|
* answer would hand `'false'` to the key resolver and send the fallback at
|
|
* Google for real.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/** Google's autocomplete envelope, which is not the shape its text search answers in. */
|
|
const googleSuggestions = (name: string) => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
suggestions: [
|
|
{
|
|
placePrediction: {
|
|
placeId: 'g1',
|
|
structuredFormat: { mainText: { text: name }, secondaryText: { text: 'Chiyoda' } },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockSearch.mockReset();
|
|
mockSearch.mockResolvedValue([]);
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
describe('MapsService.autocompletePlaces', () => {
|
|
it('MAPS-AUTO-001: index hits answer the box, tagged so the details lookup can read them back', async () => {
|
|
mockSearch.mockResolvedValue([hit(), hit({ gers: 'def-456', name: 'Café Central' })]);
|
|
|
|
const result = await make().autocompletePlaces(1, INPUT);
|
|
|
|
expect(result.source).toBe('trek-places');
|
|
expect(result.suggestions).toHaveLength(2);
|
|
// The `gers:` prefix is the contract with getPlaceDetails, which branches on
|
|
// it. A suggestion without it lands in the generic colon branch and asks
|
|
// Overpass for an OSM element that does not exist, so the place comes back
|
|
// empty after the user has already picked it.
|
|
expect(result.suggestions[0].placeId).toBe('gers:abc-123');
|
|
expect(result.suggestions[1].placeId).toBe('gers:def-456');
|
|
expect(result.suggestions[0].mainText).toBe('Café Kröpeliner');
|
|
expect(result.suggestions[0].secondaryText).toBe('Rostock, DE');
|
|
});
|
|
|
|
it('MAPS-AUTO-002: a place with no locality gets an empty line, not the word "undefined"', async () => {
|
|
mockSearch.mockResolvedValue([hit({ address: null }), hit({ gers: 'x', address: { locality: 'Rostock' } })]);
|
|
|
|
const { suggestions } = await make().autocompletePlaces(1, INPUT);
|
|
|
|
expect(suggestions[0].secondaryText).toBe('');
|
|
// One half present is still one half: no stray comma either.
|
|
expect(suggestions[1].secondaryText).toBe('Rostock');
|
|
});
|
|
|
|
it('MAPS-AUTO-003: a location bias is sent as the centre of the box the client drew', async () => {
|
|
mockSearch.mockResolvedValue([hit()]);
|
|
|
|
await make().autocompletePlaces(1, INPUT, undefined, {
|
|
low: { lat: 54, lng: 12 },
|
|
high: { lat: 54.2, lng: 12.4 },
|
|
});
|
|
|
|
// Eight, not ten: the suggestion list is what a person reads while typing.
|
|
expect(mockSearch).toHaveBeenCalledWith(INPUT, { lat: 54.1, lng: 12.2, limit: 8, sources: 'index,osm' });
|
|
});
|
|
|
|
it('MAPS-AUTO-004: without a bias the index is asked without coordinates rather than with zeroes', async () => {
|
|
mockSearch.mockResolvedValue([hit()]);
|
|
|
|
await make().autocompletePlaces(1, INPUT);
|
|
|
|
// A 0/0 bias is a point in the Atlantic, and the index treats a bias as
|
|
// permission to relax the match — which is how the shops around a landmark
|
|
// start outranking the landmark.
|
|
expect(mockSearch).toHaveBeenCalledWith(INPUT, { lat: undefined, lng: undefined, limit: 8, sources: 'index,osm' });
|
|
});
|
|
|
|
it('MAPS-AUTO-005: an index that found nothing falls through instead of answering empty', async () => {
|
|
mockSearch.mockResolvedValue([]);
|
|
|
|
const result = await make().autocompletePlaces(1, INPUT);
|
|
|
|
expect(mockSearch).toHaveBeenCalledTimes(1);
|
|
// Whatever the fallback answers, it must not claim to be the index.
|
|
expect(result.source).not.toBe('trek-places');
|
|
});
|
|
|
|
it('MAPS-AUTO-006: an index failure is a warning and a fallback, not a dead search box', async () => {
|
|
mockSearch.mockRejectedValue(new Error('places api down'));
|
|
|
|
const result = await make().autocompletePlaces(1, INPUT);
|
|
|
|
expect(console.warn).toHaveBeenCalledWith('TREK Places autocomplete failed, falling back:', 'places api down');
|
|
expect(result.source).not.toBe('trek-places');
|
|
});
|
|
|
|
it('MAPS-AUTO-008: a hit from the OpenStreetMap layer carries an id the details lookup can read', async () => {
|
|
mockSearch.mockResolvedValue([osmHit()]);
|
|
|
|
const { suggestions } = await make().autocompletePlaces(1, INPUT);
|
|
|
|
// `osm:node/9712313` is the service's form; this file resolves `node:123`.
|
|
// Passing the service's form through would hand the client an id nothing
|
|
// downstream recognises, and it would fail after the user had picked it.
|
|
expect(suggestions[0].placeId).toBe('node:9712313');
|
|
expect(suggestions[0].mainText).toBe('Tokio Hauptbahnhof');
|
|
// The layer has no address. The name written on the building is more use
|
|
// under a translated label than an empty second line.
|
|
expect(suggestions[0].secondaryText).toBe('東京駅丸の内駅舎');
|
|
});
|
|
|
|
it('MAPS-AUTO-009: index and layer hits keep their own id form in one list', async () => {
|
|
mockSearch.mockResolvedValue([hit(), osmHit(), hit({ gers: 'def-456' })]);
|
|
|
|
const { suggestions } = await make().autocompletePlaces(1, INPUT);
|
|
|
|
expect(suggestions.map((s) => s.placeId)).toEqual(['gers:abc-123', 'node:9712313', 'gers:def-456']);
|
|
// A local name equal to the label would be a repeated line, not a hint.
|
|
const same = osmHit({ local_name: 'Tokio Hauptbahnhof' });
|
|
mockSearch.mockResolvedValue([same]);
|
|
const second = await make().autocompletePlaces(1, INPUT);
|
|
expect(second.suggestions[0].secondaryText).toBe('');
|
|
});
|
|
|
|
it('MAPS-AUTO-010: each row says which index it came from, because the list is two', async () => {
|
|
mockSearch.mockResolvedValue([hit(), osmHit()]);
|
|
|
|
const { suggestions, source } = await make().autocompletePlaces(1, INPUT);
|
|
|
|
// The name above the list describes the call, and the call asked both. Only
|
|
// the row can say which of the two answered it — without that the reader is
|
|
// told a place from OpenStreetMap came out of the TREK index, which is the
|
|
// one thing the mark beside a suggestion exists to answer.
|
|
expect(source).toBe('trek-places');
|
|
expect(suggestions.map((s) => s.source)).toEqual(['trek-places', 'openstreetmap']);
|
|
});
|
|
|
|
it('MAPS-AUTO-011: a suggestion carries the coordinates the index already gave, so the pick needs no second hop', async () => {
|
|
mockSearch.mockResolvedValue([hit(), osmHit()]);
|
|
|
|
const { suggestions } = await make().autocompletePlaces(1, INPUT);
|
|
|
|
// Without these the client, when the details lookup cannot answer, searches
|
|
// for the label instead — and for a layer row that label is a name plus its
|
|
// local spelling, which is not a query anybody typed.
|
|
expect(suggestions[0]).toMatchObject({ lat: 54.0879, lng: 12.1408 });
|
|
expect(suggestions[1]).toMatchObject({ lat: 35.6811816, lng: 139.76598265 });
|
|
});
|
|
|
|
it('MAPS-AUTO-007: the keystroke never leaves for the index while the admin has it off', async () => {
|
|
mockSearch.mockResolvedValue([hit()]);
|
|
|
|
const result = await make(false).autocompletePlaces(1, INPUT);
|
|
|
|
expect(mockSearch).not.toHaveBeenCalled();
|
|
expect(result.source).not.toBe('trek-places');
|
|
});
|
|
|
|
it('MAPS-AUTO-012: the Google-only switch sends the keystroke to Google and skips the index', async () => {
|
|
// The admin row promises every search AND every suggestion. The search half
|
|
// is pinned next door (MAPS-SEARCH-011); this is the other call site.
|
|
mockSearch.mockResolvedValue([hit()]);
|
|
const fetchMock = vi.fn().mockResolvedValue(googleSuggestions('Tokyo Station'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const result = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
|
|
|
expect(mockSearch).not.toHaveBeenCalled();
|
|
expect(result.source).toBe('google');
|
|
expect(result.suggestions[0]).toMatchObject({ placeId: 'g1', mainText: 'Tokyo Station' });
|
|
expect(String(fetchMock.mock.calls[0][0])).toContain('places:autocomplete');
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('MAPS-AUTO-013: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => {
|
|
mockSearch.mockResolvedValue([hit()]);
|
|
|
|
// No key at all: the index answers, as before.
|
|
const keyless = await make(true, { places_google_only: 'true' }).autocompletePlaces(1, INPUT);
|
|
expect(mockSearch).toHaveBeenCalledTimes(1);
|
|
expect(keyless.source).toBe('trek-places');
|
|
|
|
// A key, but the admin picked OpenStreetMap: Google holds no slot, so
|
|
// there is nothing for the switch to hand the keystroke to.
|
|
mockSearch.mockClear();
|
|
const osmOnly = await make(true, {
|
|
maps_api_key: 'key',
|
|
places_google_only: 'true',
|
|
places_provider: 'openstreetmap',
|
|
}).autocompletePlaces(1, INPUT);
|
|
expect(mockSearch).toHaveBeenCalledTimes(1);
|
|
expect(osmOnly.source).toBe('trek-places');
|
|
|
|
// 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' }).autocompletePlaces(1, INPUT);
|
|
expect(mockSearch).toHaveBeenCalledTimes(1);
|
|
expect(off.source).toBe('trek-places');
|
|
});
|
|
});
|