Files
TREK/server/tests/unit/nest/photon.client.test.ts
T
Maurice 1e5314d847 maps, routing, i18n: settle what the checks found
- The lead is Photon's own row first; a row of its name inside the lead
  only stands in where Photon's row went as the same place. Rows without a
  line below the name are never folded as reading the same.
- A miss of Photon where it is the geocoder no longer asks it a second time
  on the way to an empty answer.
- The transport words of the identity match use a Unicode word boundary
  (straße, estación) and match 駅 anywhere.
- Opening an OSM place reads the coordinate it was placed at explicitly.
- The ferry spans of a Valhalla leg move to valhallaFerries.ts, and a day
  cut into pieces is asked with one options object.
- "Limit" on the allowance tiles is "Daily limit", which no locale left
  as an English copy.
- Tests follow the new behaviour: no Nominatim behind Photon, zoom 13,
  and the expectations of the merge and edit-distance cases.
2026-10-30 16:36:22 +01:00

261 lines
12 KiB
TypeScript

/**
* PHOTON-001..019: when Photon is asked, what it is asked, and every way it
* hands the question back to Nominatim. The network is a stubbed global fetch,
* the installation identity a fake in the slot the service-identity module fills.
*/
import { deriveServices } from '../../../src/app-config/boot-derive';
import { PhotonClient } from '../../../src/nest/maps/providers/photon.client';
import { serviceIdentitySlot } from '../../../src/nest/service-identity/service-identity.interface';
import { oneByOne } from '../../helpers/one-by-one';
import { FakeServiceIdentity, gateFeature, photonAnswer } from '../../helpers/photon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let identity: FakeServiceIdentity;
let fetchMock: ReturnType<typeof vi.fn>;
const client = (env: Record<string, string> = {}) => new PhotonClient(deriveServices(env));
/** The URL and headers of the n-th request that went out. */
function request(n = 0): { url: URL; headers: Record<string, string> } {
const [url, init] = fetchMock.mock.calls[n] as [URL, RequestInit & { headers: Record<string, string> }];
return { url: new URL(String(url)), headers: init.headers };
}
beforeEach(() => {
identity = new FakeServiceIdentity();
serviceIdentitySlot.install(identity);
fetchMock = vi.fn(async () => photonAnswer([gateFeature()]));
vi.stubGlobal('fetch', fetchMock);
vi.stubEnv('NOMINATIM_URL', '');
});
afterEach(() => {
serviceIdentitySlot.release(identity);
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.useRealTimers();
});
describe('which installations ask Photon', () => {
it('PHOTON-001: one with a key for the TREK services, at photon.liketrek.com with the key', async () => {
const found = await client().search('Brandenburger Tor', { lang: 'de' });
expect(found).toHaveLength(1);
const { url, headers } = request();
expect(`${url.origin}${url.pathname}`).toBe('https://photon.liketrek.com/api');
expect(headers.Authorization).toBe('Bearer tk1.kid.payload.signature');
expect(headers['User-Agent']).toBe('TREK/test');
expect(identity.observed).toEqual([{ host: 'photon.liketrek.com', status: 200 }]);
});
it('PHOTON-002: none without a key, since Photon has no shared pool', async () => {
identity.bearer = null;
expect(await client().search('Brandenburger Tor')).toBeNull();
expect(await client().reverse(52.5, 13.4)).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('PHOTON-003: none with the TREK services off', async () => {
expect(await client({ TREK_SERVICES: 'off' }).search('Brandenburger Tor')).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('PHOTON-004: none beside a Nominatim the operator runs, which keeps answering', async () => {
vi.stubEnv('NOMINATIM_URL', 'http://nominatim:8080');
expect(await client().search('Brandenburger Tor')).toBeNull();
expect(await client().reverse(52.5, 13.4)).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('PHOTON-005: one pointed at another copy by TREK_PHOTON_URL', async () => {
await client({ TREK_PHOTON_URL: 'https://geo.example.org/photon/' }).search('Rostock');
const { url } = request();
expect(`${url.origin}${url.pathname}`).toBe('https://geo.example.org/photon/api');
});
});
describe('what Photon is asked', () => {
it('PHOTON-006: a search carries the query, the limit, the language and the bias', async () => {
await client().search('Hase-dera', { lang: 'br', bias: { lat: 35.31, lng: 139.53 } });
const { searchParams } = request().url;
expect(Object.fromEntries(searchParams)).toEqual({
q: 'Hase-dera',
limit: '10',
lang: 'pt',
lat: '35.31',
lon: '139.53',
zoom: '13',
});
});
it('PHOTON-007: a search without a bias carries none, and one without a language the names on the spot', async () => {
await client().search('Rostock');
const { searchParams } = request().url;
expect(searchParams.has('lat')).toBe(false);
expect(searchParams.get('lang')).toBe('default');
});
it('PHOTON-008: a reverse lookup asks /reverse for the nearest feature', async () => {
await client().reverse(52.5163, 13.3777, 'gr');
const { url } = request();
expect(url.pathname).toBe('/reverse');
expect(Object.fromEntries(url.searchParams)).toEqual({ lat: '52.5163', lon: '13.3777', limit: '1', lang: 'el' });
});
it('PHOTON-009: a point that is not one is not sent', async () => {
expect(await client().reverse(Number.NaN, 13.4)).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('when Nominatim has to answer instead', () => {
it.each([401, 403, 429, 500, 503])('PHOTON-010: an answer of %i', async (status) => {
fetchMock.mockResolvedValueOnce(photonAnswer([], status));
expect(await client().search('Rostock')).toBeNull();
expect(identity.observed).toEqual([{ host: 'photon.liketrek.com', status }]);
});
it('PHOTON-011: no answer at all, or none in time', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
expect(await client().search('Rostock')).toBeNull();
fetchMock.mockRejectedValueOnce(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
expect(await client().search('Rostock')).toBeNull();
});
it("PHOTON-012: an answer that is not Photon's, or one far too long", async () => {
fetchMock.mockResolvedValueOnce(new Response('{"results":[]}', { status: 200 }));
expect(await client().search('Rostock')).toBeNull();
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 200 }));
expect(await client().search('Rostock')).toBeNull();
fetchMock.mockResolvedValueOnce(photonAnswer([gateFeature({ name: 'x'.repeat(600_000) })]));
expect(await client().search('Rostock')).toBeNull();
});
it('PHOTON-013: an empty answer is an answer, not a miss', async () => {
fetchMock.mockResolvedValueOnce(photonAnswer([]));
expect(await client().search('Qwxzv')).toEqual([]);
});
});
describe('the pause after misses in a row', () => {
it('PHOTON-014: four misses stop the asking for a minute, then one try goes out, and an answer resets it', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T10:00:00Z'));
const photon = client();
fetchMock.mockImplementation(async () => photonAnswer([], 503));
expect(await oneByOne([1, 2, 3, 4], () => photon.search('Rostock'))).toEqual([null, null, null, null]);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(await photon.search('Rostock')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(4);
vi.setSystemTime(new Date('2026-10-29T10:01:01Z'));
expect(await photon.search('Rostock')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(5);
expect(await photon.search('Rostock')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(5);
vi.setSystemTime(new Date('2026-10-29T10:02:02Z'));
fetchMock.mockImplementation(async () => photonAnswer([gateFeature()]));
expect(await photon.search('Rostock')).toHaveLength(1);
fetchMock.mockImplementation(async () => photonAnswer([], 503));
expect(await oneByOne([1, 2, 3], () => photon.search('Rostock'))).toEqual([null, null, null]);
expect(fetchMock).toHaveBeenCalledTimes(9);
});
});
describe('a spent allowance and a short deadline', () => {
it('PHOTON-015: a refusal with Retry-After pauses for that long, an hour at most', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T10:00:00Z'));
const photon = client();
const spent = (retryAfter: string) =>
new Response('{}', { status: 429, headers: { 'content-type': 'application/json', 'retry-after': retryAfter } });
fetchMock.mockResolvedValueOnce(spent('30'));
expect(await photon.search('Rostock')).toBeNull();
expect(await photon.reverse(52.5, 13.4)).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date('2026-10-29T10:00:31Z'));
fetchMock.mockResolvedValueOnce(spent('50400'));
expect(await photon.search('Rostock')).toBeNull();
vi.setSystemTime(new Date('2026-10-29T10:59:00Z'));
expect(await photon.search('Rostock')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2);
vi.setSystemTime(new Date('2026-10-29T11:00:32Z'));
expect(await photon.search('Rostock')).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("PHOTON-016: a reverse lookup leaves Nominatim at least half of the caller's deadline", async () => {
const timeout = vi.spyOn(AbortSignal, 'timeout');
await client().reverse(52.5, 13.4, 'de', 3000);
await client().reverse(52.5, 13.4, 'de', 8000);
await client().reverse(52.5, 13.4, 'de');
expect(timeout.mock.calls.map(([ms]) => ms)).toEqual([1500, 2500, 2500]);
timeout.mockRestore();
});
});
describe('the suggestions asked per keystroke', () => {
it('PHOTON-017: their misses and a spent allowance pause the suggestions alone, never the search', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T10:00:00Z'));
const photon = client();
fetchMock.mockImplementation(async () => photonAnswer([], 503));
expect(await oneByOne([1, 2, 3, 4], () => photon.suggest('Ros'))).toEqual([null, null, null, null]);
expect(await photon.suggest('Rost')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(4);
fetchMock.mockImplementation(async () => photonAnswer([gateFeature()]));
expect(await photon.search('Rostock')).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(5);
vi.setSystemTime(new Date('2026-10-29T10:01:01Z'));
fetchMock.mockResolvedValueOnce(
new Response('{}', { status: 429, headers: { 'content-type': 'application/json', 'retry-after': '600' } }),
);
expect(await photon.suggest('Rost')).toBeNull();
expect(await photon.suggest('Rosto')).toBeNull();
expect(await photon.search('Rostock')).toHaveLength(1);
expect(await photon.reverse(52.5, 13.4)).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(8);
});
it('PHOTON-018: a pause of the search holds them back too', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-29T10:00:00Z'));
const photon = client();
fetchMock.mockImplementation(async () => photonAnswer([], 503));
await oneByOne([1, 2, 3, 4], () => photon.search('Rostock'));
expect(await photon.suggest('Ros')).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it('PHOTON-019: they wait less than a search, and one called off by its caller is no miss', async () => {
const timeout = vi.spyOn(AbortSignal, 'timeout');
const photon = client();
expect(await photon.suggest('Ros', { lang: 'de', bias: { lat: 54.1, lng: 12.1 } })).toHaveLength(1);
expect(timeout.mock.calls.map(([ms]) => ms)).toEqual([1500]);
expect(request().url.searchParams.get('lat')).toBe('54.1');
timeout.mockRestore();
fetchMock.mockImplementation(
(_url: URL, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
}),
);
const dropped = Array.from({ length: 5 }, () => {
const controller = new AbortController();
const asked = photon.suggest('Rost', { signal: controller.signal });
controller.abort();
return asked;
});
expect(await Promise.all(dropped)).toEqual([null, null, null, null, null]);
fetchMock.mockImplementation(async () => photonAnswer([gateFeature()]));
expect(await photon.suggest('Rostock')).toHaveLength(1);
});
});