Files
gods-eye-view/src/tooling/nominatimSearchRoute.test.mjs
T
manjunath 578ebdc030 merge main f13c60c into feat/voice-outlines
reconcile voice outlines with main f13c60c, retaining German/Japanese,
Operator HUD retirement and Context/Radio cleanup, named-building
containment, courtyard-hole, non-Latin identity, attribution, cancellation
and antimeridian repairs

keep outline callouts above the open voice preview when an above-panel
slot fits without colliding with another callout,
and clip the compact dock location label on narrow screens
2026-10-30 19:02:47 +05:30

458 lines
15 KiB
JavaScript

// THE LAST-RESORT PLACE SEARCH ROUTE — what it is allowed to send upstream.
//
// The public instance permits one request per second. One request per second
// and an unbounded queue are incompatible: a burst of searches would keep the
// upstream busy long after everyone who asked has given up. These cases pin the
// queue bound, the shared answer for identical searches, the cancellation path,
// and the chain order that only reaches here when nothing else answered.
//
// Run with: npm test
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
fetchNominatimSearch,
geocodeProxy,
NOMINATIM_MAX_PENDING,
} from '../../server/providers/regional/place.js';
import { fetchRegionalJson } from '../../server/providers/regional/http.js';
import {
createNominatimGate,
resolveNominatimSettings,
PUBLIC_NOMINATIM_SEARCH,
setSharedNominatimGate,
} from '../../server/providers/regional/nominatimGate.js';
import { localProviderPlugins } from '../../server/providers/local.js';
import { standaloneConfig } from '../../server/standalone/vite.config.js';
import { defaultSourceRoot } from '../../server/providers/common/source-root.js';
import { createPlaceSearch, createGoogleGeocoder } from '../search/index.js';
import { createPhotonGeocoder } from '../keylessGeocoder.js';
// Each case starts with a fresh shared gate: a pause after one case's
// simulated outage must not decide the next case.
// Pin the public-policy fixture independently of operator endpoint settings.
// Each case's transport fails closed outside its active withUpstream block.
let activeUpstream;
test.beforeEach(() => {
const transport = { fetch: null };
activeUpstream = transport;
setSharedNominatimGate(
createNominatimGate({
settings: resolveNominatimSettings({
NOMINATIM_URL: PUBLIC_NOMINATIM_SEARCH,
}),
fetchImpl: (...args) => {
if (!transport.fetch) throw new Error('No active test upstream');
return transport.fetch(...args);
},
}),
);
});
const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-search-route-'));
test.after(() => fs.rmSync(storageRoot, { recursive: true, force: true }));
const HIT = [
{
lat: '43.1731',
lon: '-79.0384',
display_name: 'Niagara Falls, Ontario, Canada',
name: 'Niagara Falls',
addresstype: 'city',
boundingbox: ['43.05', '43.15', '-79.15', '-79.0'],
},
];
/** Mount the geocode route the way the dev server does. */
let clientCounter = 0;
function mountGeocode(install = null) {
// A fresh client address per mount: the per-client rate limiter is module
// state shared by every case in this file, and one case's burst must not
// decide another case's outcome.
clientCounter += 1;
if (!install)
install = (server) =>
geocodeProxy({
storageDir: path.join(storageRoot, `case-${clientCounter}`),
}).configureServer(server);
const remoteAddress = `10.0.0.${clientCounter % 250}`;
const routes = new Map();
install({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
return async function request(path) {
const requested = new URL(path, 'http://localhost');
const handler = routes.get('/api/geocode');
if (!handler) throw new Error('no /api/geocode route');
// connect strips the mount prefix before the handler sees the request.
const rest =
requested.pathname.slice('/api/geocode'.length) + requested.search;
const req = {
method: 'GET',
url: rest[0] === '/' ? rest : `/${rest}`,
headers: {},
socket: { remoteAddress },
on() {},
};
return await new Promise((resolve, reject) => {
let status = 200;
let headers = {};
const res = {
writableEnded: false,
on() {},
writeHead(code, sent) {
status = code;
headers = sent || {};
return res;
},
end(body = '') {
res.writableEnded = true;
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({ status, headers, body: parsed });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
};
}
/** Answer the upstream from a stub, counting and optionally stalling calls. */
async function withUpstream(respond, run) {
const previous = globalThis.fetch;
const transport = activeUpstream;
const previousUpstream = transport.fetch;
const calls = [];
globalThis.fetch = transport.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return await respond(String(url), options, calls.length);
};
try {
return await run(calls);
} finally {
transport.fetch = previousUpstream;
globalThis.fetch = previous;
}
}
const jsonResponse = (value) =>
new Response(JSON.stringify(value), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('the real composition binds durable storage before dev or preview installation, independent of CWD', async () => {
const cwd = process.cwd();
const expected = path.join(defaultSourceRoot, '.gev-cache', 'nominatim');
try {
for (const suffix of ['one', 'two']) {
const dir = path.join(storageRoot, `cwd-${suffix}`);
fs.mkdirSync(dir);
process.chdir(dir);
const { NOMINATIM_CACHE_DIR } = await import(
`../../server/providers/regional/place.js?cwd-${suffix}`
);
assert.equal(NOMINATIM_CACHE_DIR, expected);
assert.equal(fs.existsSync(path.join(dir, '.gev-cache')), false);
}
} finally {
process.chdir(cwd);
}
const bound = [];
setSharedNominatimGate({
persistUsage: (file) => bound.push(file),
});
const storage = path.join(storageRoot, 'composition');
const plugin = localProviderPlugins({ nominatimStorageDir: storage }).find(
(entry) => entry.name === 'geocode-proxy',
);
assert.ok(plugin, 'the application must compose the place-search route');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
assert.deepEqual(bound, [path.join(storage, 'state.json')]);
const standaloneStorage = path.join(storageRoot, 'standalone');
const config = standaloneConfig(
{ command: 'serve', mode: 'test' },
{ nominatimStorageDir: standaloneStorage },
);
assert.ok(config.plugins.some((entry) => entry.name === 'geocode-proxy'));
assert.deepEqual(bound, [
path.join(storage, 'state.json'),
path.join(standaloneStorage, 'state.json'),
]);
for (const value of [null, '', 'relative'])
assert.throws(
() => localProviderPlugins({ nominatimStorageDir: value }),
/absolute directory/,
);
assert.equal(
bound.length,
2,
'invalid bindings never construct policy state',
);
});
test('a search reaches the upstream identified, and the answer is cached', async () => {
const request = mountGeocode();
await withUpstream(
async () => jsonResponse(HIT),
async (calls) => {
const query = `niagara-${Date.now()}`;
const first = await request(
`/api/geocode?q=${query}&bounds=${encodeURIComponent('-20,170|-10,-171')}`,
);
assert.equal(first.status, 200);
assert.equal(first.body.status, 'OK');
assert.deepEqual(first.body.results[0].geometry.location, {
lat: 43.1731,
lng: -79.0384,
});
assert.equal(calls.length, 1);
assert.equal(
new URL(calls[0].url).searchParams.has('viewbox'),
false,
'a wrapped hint must not be coarsened into a Greenwich viewbox',
);
const headers = calls[0].options.headers;
assert.match(
String(headers['User-Agent']),
/gods-eye-view\/\d/,
'the policy asks for a User-Agent identifying the application',
);
assert.ok(headers.Referer, 'a Referer is sent alongside it');
assert.equal(
calls[0].options.redirect,
'error',
'a fixed API endpoint may not be redirected elsewhere',
);
// Dropping the invalid hint also shares the unbiased cache entry.
const repeat = await request(`/api/geocode?q=${query}`);
assert.equal(repeat.status, 200);
assert.equal(
calls.length,
1,
'the second identical search is served from cache',
);
},
);
});
test('identical searches in flight share one upstream call', async () => {
const request = mountGeocode();
const query = `coalesce-${Date.now()}`;
await withUpstream(
async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
return jsonResponse(HIT);
},
async (calls) => {
const answers = await Promise.all([
request(`/api/geocode?q=${query}`),
request(`/api/geocode?q=${query}`),
request(`/api/geocode?q=${query}`),
]);
for (const answer of answers) assert.equal(answer.status, 200);
assert.equal(calls.length, 1, 'one upstream call serves all three');
},
);
});
test('a burst past the queue bound is refused rather than queued for minutes', async () => {
const request = mountGeocode();
const stamp = Date.now();
await withUpstream(
async () => jsonResponse([]),
async (calls) => {
const answers = await Promise.all(
Array.from({ length: 20 }, (_unused, index) =>
request(`/api/geocode?q=burst-${stamp}-${index}`),
),
);
const refused = answers.filter((answer) => answer.status === 429);
assert.ok(
refused.length > 0,
'a burst this size must be refused somewhere',
);
for (const answer of refused)
assert.equal(answer.headers['Retry-After'], '5');
assert.ok(
calls.length <= NOMINATIM_MAX_PENDING + 1,
`the upstream saw ${calls.length} of 20 searches; the queue bound is ${NOMINATIM_MAX_PENDING}`,
);
assert.equal(
answers.length,
20,
'every caller is answered, none is left hanging',
);
},
);
});
test('a query that is missing or too long never reaches the upstream', async () => {
const request = mountGeocode();
await withUpstream(
async () => jsonResponse(HIT),
async (calls) => {
for (const path of [
'/api/geocode',
'/api/geocode?q=',
'/api/geocode?q=%20%20',
`/api/geocode?q=${'x'.repeat(201)}`,
]) {
const answer = await request(path);
assert.equal(answer.status, 400, path);
}
assert.deepEqual(calls, []);
},
);
});
test('an upstream failure is a service answer, not a crash', async () => {
const request = mountGeocode();
await withUpstream(
async () => new Response('upstream down', { status: 502 }),
async () => {
const answer = await request(`/api/geocode?q=down-${Date.now()}`);
assert.equal(answer.status, 503);
assert.equal(answer.headers['Cache-Control'], 'no-store');
assert.match(String(answer.body.error), /temporarily unavailable/);
},
);
});
test('a search with no hit is a clean empty answer', async () => {
const request = mountGeocode();
await withUpstream(
async () => jsonResponse([]),
async () => {
const answer = await request(`/api/geocode?q=nowhere-${Date.now()}`);
assert.equal(answer.status, 200);
assert.equal(answer.body.status, 'ZERO_RESULTS');
assert.deepEqual(answer.body.results, []);
},
);
});
test('the chain reaches this route only when Google and Photon do not answer', async () => {
const asked = [];
const request = mountGeocode();
await withUpstream(
async (url) => {
if (url.includes('photon.komoot.io')) {
asked.push('photon');
return new Response('photon is down', { status: 503 });
}
asked.push('nominatim');
return jsonResponse(HIT);
},
async () => {
const search = createPlaceSearch({
providers: [
createGoogleGeocoder({
request() {
asked.push('google');
// An unconfigured provider contributes no verdict.
return null;
},
}),
createPhotonGeocoder({
fetchImpl: (...args) => globalThis.fetch(...args),
}),
createGoogleGeocoder({
request(query) {
return request(
`/api/geocode?q=${encodeURIComponent(query)}`,
).then(
(answer) =>
new Response(JSON.stringify(answer.body), {
status: answer.status,
}),
);
},
}),
],
});
const outcome = await search.geocode(`chain-${Date.now()}`);
assert.deepEqual(
asked,
['google', 'photon', 'nominatim'],
`Google first, then Photon, then this route (saw ${asked.join(' → ')})`,
);
assert.equal(outcome.place.lat, 43.1731);
assert.equal(outcome.fallbackUsed, true);
},
);
});
test('a search whose caller gave up is dropped before it costs a slot', async () => {
const abandoned = new AbortController();
abandoned.abort();
await withUpstream(
async () => jsonResponse(HIT),
async (calls) => {
await assert.rejects(
fetchNominatimSearch(`cancelled-${Date.now()}`, null, {
signal: abandoned.signal,
}),
(error) => error.code === 'NOMINATIM_ABANDONED',
);
assert.deepEqual(calls, [], 'a search nobody is waiting for is not sent');
},
);
});
test('the upstream deadline covers the body, not just the headers', async () => {
// A response whose headers arrive and whose body then never does. Before the
// deadline covered the body, the abort timer was already cleared by the time
// the first byte was due, so this read had no deadline at all.
const stalled = () => {
let cancelled = false;
const stream = new ReadableStream({
pull() {
return new Promise(() => {});
},
cancel() {
cancelled = true;
},
});
return {
response: new Response(stream, { status: 200 }),
wasCancelled: () => cancelled,
};
};
const previous = globalThis.fetch;
const { response, wasCancelled } = stalled();
globalThis.fetch = async (_url, options) => {
// The same signal must reach the body read, or aborting stops nothing.
options.signal.addEventListener('abort', () => {
void response.body?.cancel().catch(() => {});
});
return response;
};
try {
const started = Date.now();
await assert.rejects(
fetchRegionalJson('https://example.test/stalls', { timeoutMs: 300 }),
'a stalled body must fail rather than hang',
);
const elapsed = Date.now() - started;
assert.ok(elapsed < 5000, `gave up after ${elapsed} ms`);
assert.ok(wasCancelled(), 'the abort reached the body read');
} finally {
globalThis.fetch = previous;
}
});