Files
TREK/server/tests/unit/nest/maps.geocode.test.ts
T
jubnl 6763648c45 refactor(settings): user and instance settings through repositories
SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces
the per-user settings table's raw SQL; SettingsService drops DatabaseService
entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/
resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead
of a raw connection.

An earlier version resolved those two repositories itself from the active
MikroORM request context, so none of its six callers needed to change. That
worked in production and e2e (always inside a real request) but broke
service construction in plain unit tests across every calling domain
(addons, auth x2, maps, transit, unsplash - 185 failures on a full run),
since none of them wrap a request context. Each of those six services now
injects its own AppSettingsRepository/UsersRepository and passes them
through explicitly, matching how every other converted repository in this
migration is consumed; their own SQL is untouched (outside Plan 3a).
2026-09-22 15:08:49 +02:00

159 lines
7.7 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 type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
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';
const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService;
return new MapsService(database, {} as PlacePhotoCacheService, noAppSettings, noUsers);
}
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();
});
});