mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
159 lines
7.6 KiB
TypeScript
159 lines
7.6 KiB
TypeScript
/**
|
|
* MAPS-GEOCODE-001..009 — turning one string into one coordinate.
|
|
*
|
|
* The lookup behind booking-import: a confirmation mail names a hotel, a
|
|
* station, an airport, and this is what puts it on the map. It runs up to
|
|
* thirty times in a single import, so both halves matter — whether the query
|
|
* leaves the instance at all (the index is asked first, and only while the
|
|
* admin leaves it on), and that a failure on either side ends as a coordinate
|
|
* or a null rather than a half-answer the importer would store.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
const { mockSearch, mockNominatim } = vi.hoisted(() => ({
|
|
mockSearch: vi.fn(async (_query: string, _opts?: { limit?: number }): 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 { buildMapsService } from '../../helpers/maps-service';
|
|
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
|
import { noGoogleQuota } from '../../helpers/google-quota';
|
|
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
|
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
|
|
// keyedProvider/resolveMapsKey (maps.service.ts) go through instance-api-keys.ts
|
|
// on every call now — none of these cases configure a key, so the stubs just
|
|
// answer "unset" the way the fake database.get(() => undefined) already did.
|
|
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
|
|
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
|
|
|
const QUERY = 'Hotel Adlon Kempinski, Unter den Linden 77, Berlin';
|
|
|
|
const indexHit = (lat: unknown, lng: unknown) => [
|
|
{ gers: 'abc-123', name: 'Hotel Adlon Kempinski', lat, lng, source: 'overture' },
|
|
];
|
|
|
|
const OSM_ROW = {
|
|
osm_type: 'way',
|
|
osm_id: 24838110,
|
|
lat: '52.5162746',
|
|
lon: '13.3796780',
|
|
name: 'Hotel Adlon Kempinski',
|
|
display_name: 'Hotel Adlon Kempinski, Unter den Linden, Mitte, Berlin, Deutschland',
|
|
};
|
|
|
|
const osmAnswer = (rows: unknown[]) => ({ ok: true, json: async () => rows });
|
|
|
|
const ORIGINAL_TREK_PLACES = process.env.TREK_PLACES_ENABLED;
|
|
|
|
afterEach(() => {
|
|
if (ORIGINAL_TREK_PLACES === undefined) delete process.env.TREK_PLACES_ENABLED;
|
|
else process.env.TREK_PLACES_ENABLED = ORIGINAL_TREK_PLACES;
|
|
});
|
|
|
|
// The index is an operator switch, not an admin row, so switching it off means
|
|
// moving the env rather than stubbing a settings lookup. The service reads the
|
|
// flag per call, which is why the restore above belongs in afterEach.
|
|
function make(enabled = true) {
|
|
if (enabled) delete process.env.TREK_PLACES_ENABLED;
|
|
else process.env.TREK_PLACES_ENABLED = 'false';
|
|
return buildMapsService({} as PlacePhotoCacheService, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockSearch.mockReset();
|
|
mockSearch.mockResolvedValue([]);
|
|
mockNominatim.mockReset();
|
|
mockNominatim.mockResolvedValue(osmAnswer([OSM_ROW]));
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
});
|
|
|
|
describe('MapsService.geocodeQuery', () => {
|
|
it('MAPS-GEOCODE-001: an index hit answers on its own, without a second lookup', async () => {
|
|
mockSearch.mockResolvedValue(indexHit(52.5162746, 13.379678));
|
|
expect(await make().geocodeQuery(QUERY)).toEqual({ lat: 52.5162746, lng: 13.379678 });
|
|
// One result is all the caller can use; asking for ten would only cost the
|
|
// index work.
|
|
expect(mockSearch).toHaveBeenCalledWith(QUERY, { limit: 1 });
|
|
expect(mockNominatim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('MAPS-GEOCODE-002: a zero coordinate is a place, not a missing value', async () => {
|
|
// Pontianak sits on the equator. A truthiness check would drop it and send
|
|
// the query on to Nominatim for no reason.
|
|
mockSearch.mockResolvedValue(indexHit(0, 109.3333));
|
|
expect(await make().geocodeQuery('Tugu Khatulistiwa, Pontianak')).toEqual({ lat: 0, lng: 109.3333 });
|
|
expect(mockNominatim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('MAPS-GEOCODE-003: a hit with one unusable coordinate falls through instead of being returned', async () => {
|
|
// Half a coordinate lands the booking in the Gulf of Guinea. Both halves
|
|
// are driven, so neither finiteness check can be dropped unnoticed.
|
|
mockSearch.mockResolvedValue(indexHit(48.8583701, Number.NaN));
|
|
expect(await make().geocodeQuery(QUERY)).toEqual({ lat: 52.5162746, lng: 13.379678 });
|
|
mockSearch.mockResolvedValue(indexHit(undefined, 2.2944813));
|
|
expect(await make().geocodeQuery(QUERY)).toEqual({ lat: 52.5162746, lng: 13.379678 });
|
|
expect(mockNominatim).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('MAPS-GEOCODE-004: an index that found nothing falls through to OpenStreetMap', async () => {
|
|
mockSearch.mockResolvedValue([]);
|
|
expect(await make().geocodeQuery(QUERY)).toEqual({ lat: 52.5162746, lng: 13.379678 });
|
|
expect(mockNominatim).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('MAPS-GEOCODE-005: an index failure is a warning and a fallback, not a lost booking', async () => {
|
|
mockSearch.mockRejectedValue(new Error('places api down'));
|
|
expect(await make().geocodeQuery(QUERY)).toEqual({ lat: 52.5162746, lng: 13.379678 });
|
|
expect(console.warn).toHaveBeenCalledWith('TREK Places geocode failed, falling back:', 'places api down');
|
|
});
|
|
|
|
it('MAPS-GEOCODE-006: the query never leaves for the index while the operator has it off', async () => {
|
|
// The index has an answer, and a different one: the OSM coordinate coming
|
|
// back is what proves the gate held rather than the call merely being made.
|
|
mockSearch.mockResolvedValue(indexHit(1.2345, 2.3456));
|
|
expect(await make(false).geocodeQuery(QUERY)).toEqual({ lat: 52.5162746, lng: 13.379678 });
|
|
expect(mockSearch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('MAPS-GEOCODE-007: the fallback goes out on the background lane', async () => {
|
|
// Thirty of these run inside one import. On the interactive lane they take
|
|
// every slot and somebody typing in the place search waits behind them.
|
|
await make().geocodeQuery(QUERY);
|
|
const [endpoint, params, opts] = mockNominatim.mock.calls[0];
|
|
expect(endpoint).toBe('search');
|
|
expect(params.get('q')).toBe(QUERY);
|
|
expect(opts?.lane).toBe('background');
|
|
});
|
|
|
|
it('MAPS-GEOCODE-008: a Nominatim row with an unparseable coordinate is null, not NaN', async () => {
|
|
// searchNominatim turns the failed parse into null, and half a coordinate
|
|
// is no coordinate — asserted per side, so neither null check is free.
|
|
mockNominatim.mockResolvedValue(osmAnswer([{ ...OSM_ROW, lon: 'unknown' }]));
|
|
expect(await make().geocodeQuery(QUERY)).toBeNull();
|
|
mockNominatim.mockResolvedValue(osmAnswer([{ ...OSM_ROW, lat: 'unknown' }]));
|
|
expect(await make().geocodeQuery(QUERY)).toBeNull();
|
|
});
|
|
|
|
it('MAPS-GEOCODE-009: neither source knowing the place is a null, not a guess', async () => {
|
|
mockNominatim.mockResolvedValue(osmAnswer([]));
|
|
expect(await make().geocodeQuery('Zzz Hotel, Nowhere')).toBeNull();
|
|
});
|
|
});
|