mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
chore(stack): refresh voice core and outlines parents
merge the latest #837 and #842 draft heads keep the stack parent-only so #846 shows geometry changes once
This commit is contained in:
commit
01979bba1e
346 files changed
+16384
-338
No files matched your search
@@ -0,0 +1,614 @@
|
||||
// Review regressions for the Nominatim gate: state that survives restarts and
|
||||
// sweeps, several processes sharing one count, long refusals, stalled bodies,
|
||||
// request-equivalent cache keys, and stricter outline selection.
|
||||
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 { spawn } from 'node:child_process';
|
||||
import {
|
||||
createGateStateStore,
|
||||
createNominatimGate,
|
||||
resolveNominatimSettings,
|
||||
PUBLIC_NOMINATIM_SEARCH,
|
||||
} from '../../server/providers/regional/nominatimGate.js';
|
||||
import { createNominatimCache } from '../../server/providers/regional/nominatimCache.js';
|
||||
import {
|
||||
outlineBias,
|
||||
selectOutlineResult,
|
||||
} from '../../server/providers/regional/outlineSelect.js';
|
||||
import {
|
||||
createNominatimOutlineProvider,
|
||||
createNominatimSearchProvider,
|
||||
geocodeProxy,
|
||||
} from '../../server/providers/regional/place.js';
|
||||
|
||||
const PUBLIC = { endpoint: PUBLIC_NOMINATIM_SEARCH, isPublic: true, dailyCap: 50 };
|
||||
const LOCAL = { ...PUBLIC, isPublic: false };
|
||||
const tempDir = (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
};
|
||||
const quick = { sleep: async () => {} };
|
||||
const json = (value) => Response.json(value);
|
||||
const square = (lon, lat, d) => [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + d, lat],
|
||||
[lon + d, lat + d],
|
||||
[lon, lat + d],
|
||||
[lon, lat],
|
||||
],
|
||||
];
|
||||
|
||||
function mount(options) {
|
||||
let handler;
|
||||
geocodeProxy(options).configureServer({
|
||||
middlewares: { use: (_route, fn) => (handler = fn) },
|
||||
});
|
||||
return (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const res = {
|
||||
writableEnded: false,
|
||||
on() {},
|
||||
writeHead(status) {
|
||||
res.status = status;
|
||||
},
|
||||
end(body) {
|
||||
res.writableEnded = true;
|
||||
resolve({ status: res.status, body: JSON.parse(body) });
|
||||
},
|
||||
};
|
||||
Promise.resolve(
|
||||
handler(
|
||||
{ method: 'GET', url, headers: {}, socket: { remoteAddress: `10.9.${Math.random()}` }, on() {} },
|
||||
res,
|
||||
),
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('the daily count survives cache sweeps and a restart of the real composition', async (t) => {
|
||||
const storageDir = tempDir(t);
|
||||
const fetchImpl = async () => json([]);
|
||||
const first = createNominatimGate({ settings: PUBLIC, fetchImpl, ...quick });
|
||||
const request = mount({ storageDir, gate: first });
|
||||
for (let i = 0; i < 3; i++)
|
||||
assert.equal((await request(`/?q=place-${i}`)).status, 200);
|
||||
assert.equal(first.stats().usedToday, 3);
|
||||
|
||||
// A foreign file in the answers directory and a full sweep leave the
|
||||
// state alone and the stranger untouched.
|
||||
const answers = path.join(storageDir, 'answers');
|
||||
fs.writeFileSync(path.join(answers, 'notes.json'), '{}');
|
||||
await createNominatimCache({ dir: answers, sweepEvery: 1 }).sweep();
|
||||
assert.ok(fs.existsSync(path.join(answers, 'notes.json')));
|
||||
assert.ok(fs.existsSync(path.join(storageDir, 'state.json')));
|
||||
|
||||
// Restart: a new gate composed on the same directory, which sweeps at start.
|
||||
const second = createNominatimGate({ settings: { ...PUBLIC, dailyCap: 4 }, fetchImpl, ...quick });
|
||||
const again = mount({ storageDir, gate: second });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(second.stats().usedToday, 3, 'the count came back');
|
||||
assert.equal((await again('/?q=place-new')).status, 200);
|
||||
const capped = await again('/?q=place-over');
|
||||
assert.equal(capped.status, 429, 'the cap still counts yesterday-restart usage');
|
||||
assert.equal(capped.body.code, 'NOMINATIM_DAILY_CAP');
|
||||
});
|
||||
|
||||
test('a sweep removes expired and corrupt entries, never ones it could not read', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const file = (n) => path.join(dir, `${String(n).repeat(40)}.json`);
|
||||
const live = { key: 'k', payload: null, storedAt: 1, expiresAt: Date.now() + 60_000 };
|
||||
fs.writeFileSync(file(1), JSON.stringify(live));
|
||||
fs.writeFileSync(file(2), JSON.stringify({ ...live, expiresAt: 1 }));
|
||||
fs.writeFileSync(file(3), '{not json');
|
||||
const cache = createNominatimCache({ dir });
|
||||
// Reads failing (a busy disk, a test double) must not empty the cache.
|
||||
const fsp = (await import('node:fs/promises')).default;
|
||||
const readFile = t.mock.method(fsp, 'readFile', async () => {
|
||||
throw Object.assign(new Error('EMFILE'), { code: 'EMFILE' });
|
||||
});
|
||||
await cache.sweep();
|
||||
assert.deepEqual([1, 2, 3].map((n) => fs.existsSync(file(n))), [true, true, true]);
|
||||
readFile.mock.restore();
|
||||
await cache.sweep();
|
||||
assert.deepEqual([1, 2, 3].map((n) => fs.existsSync(file(n))), [true, false, false]);
|
||||
});
|
||||
|
||||
test('processes sharing state reserve public spacing and cap atomically', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const module = new URL('../../server/providers/regional/nominatimGate.js', import.meta.url).href;
|
||||
const run = (file, dailyCap) => {
|
||||
const script = `
|
||||
const { createGateStateStore } = await import(${JSON.stringify(module)});
|
||||
const store = createGateStateStore({ file: ${JSON.stringify(file)} });
|
||||
console.log(JSON.stringify(store.reserve('2026-09-29', {
|
||||
now: 1000,
|
||||
dailyCap: ${dailyCap},
|
||||
minSpacingMs: 1100,
|
||||
latestStartAt: 10000,
|
||||
})));
|
||||
`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script]);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => (stdout += chunk));
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk));
|
||||
child.on('exit', (code) =>
|
||||
code === 0
|
||||
? resolve(JSON.parse(stdout))
|
||||
: reject(new Error(`exit ${code}: ${stderr}`)),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const spacedFile = path.join(dir, 'spaced.json');
|
||||
const reservations = await Promise.all([
|
||||
run(spacedFile, 3),
|
||||
run(spacedFile, 3),
|
||||
run(spacedFile, 3),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
reservations.map((result) => result.status),
|
||||
['reserved', 'reserved', 'reserved'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
reservations.map((result) => result.startAt).sort((a, b) => a - b),
|
||||
[1000, 2100, 3200],
|
||||
);
|
||||
assert.equal(createGateStateStore({ file: spacedFile }).count('2026-09-29'), 3);
|
||||
|
||||
const cappedFile = path.join(dir, 'capped.json');
|
||||
const capped = await Promise.all([run(cappedFile, 1), run(cappedFile, 1)]);
|
||||
assert.deepEqual(
|
||||
capped.map((result) => result.status).sort(),
|
||||
['cap', 'reserved'],
|
||||
);
|
||||
assert.equal(createGateStateStore({ file: cappedFile }).count('2026-09-29'), 1);
|
||||
});
|
||||
|
||||
test('independent public gates sharing state enforce spacing and cap', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const starts = [];
|
||||
const fetchImpl = async () => {
|
||||
starts.push(Date.now());
|
||||
return json([]);
|
||||
};
|
||||
const file = path.join(dir, 'spacing.json');
|
||||
const make = (options = {}) =>
|
||||
createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl,
|
||||
usage: createGateStateStore({ file: options.file || file }),
|
||||
minSpacingMs: options.minSpacingMs || 40,
|
||||
});
|
||||
await Promise.all([
|
||||
createNominatimSearchProvider({ gate: make() })('one', null),
|
||||
createNominatimSearchProvider({ gate: make() })('two', null),
|
||||
]);
|
||||
starts.sort((a, b) => a - b);
|
||||
assert.ok(starts[1] - starts[0] >= 35, `${starts[1] - starts[0]}ms apart`);
|
||||
|
||||
const capFile = path.join(dir, 'cap.json');
|
||||
let sent = 0;
|
||||
const capGate = () =>
|
||||
createNominatimGate({
|
||||
settings: { ...PUBLIC, dailyCap: 1 },
|
||||
fetchImpl: async () => {
|
||||
sent += 1;
|
||||
return json([]);
|
||||
},
|
||||
usage: createGateStateStore({ file: capFile }),
|
||||
minSpacingMs: 1,
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
createNominatimSearchProvider({ gate: capGate() })('three', null),
|
||||
createNominatimSearchProvider({ gate: capGate() })('four', null),
|
||||
]);
|
||||
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(
|
||||
results.find((result) => result.status === 'rejected').reason.code,
|
||||
'NOMINATIM_DAILY_CAP',
|
||||
);
|
||||
assert.equal(sent, 1);
|
||||
});
|
||||
|
||||
test('a persistence failure is reported and never lowers the count', (t) => {
|
||||
const dir = tempDir(t);
|
||||
const blocker = path.join(dir, 'not-a-dir');
|
||||
fs.writeFileSync(blocker, 'x');
|
||||
const errors = [];
|
||||
const store = createGateStateStore({
|
||||
file: path.join(blocker, 'state.json'),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
store.increment('2026-09-29');
|
||||
store.increment('2026-09-29');
|
||||
assert.equal(store.count('2026-09-29'), 2);
|
||||
assert.equal(store.status().persisted, false);
|
||||
assert.ok(store.status().error);
|
||||
assert.equal(errors.length, 1, 'reported once, not per request');
|
||||
});
|
||||
|
||||
test('parseable but invalid public usage state fails closed', async (t) => {
|
||||
const dir = tempDir(t);
|
||||
const file = path.join(dir, 'state.json');
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
day: '2026-09-30',
|
||||
count: '50',
|
||||
pausedUntil: '9999999999999',
|
||||
}),
|
||||
);
|
||||
let sent = 0;
|
||||
const gate = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl: async () => {
|
||||
sent += 1;
|
||||
return json([]);
|
||||
},
|
||||
usage: createGateStateStore({ file, onError: () => {} }),
|
||||
...quick,
|
||||
});
|
||||
await assert.rejects(
|
||||
gate.requestJson((endpoint) => `${endpoint}?q=x`),
|
||||
{ code: 'NOMINATIM_STATE_UNAVAILABLE' },
|
||||
);
|
||||
assert.equal(sent, 0);
|
||||
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ day: '2026-09-30', count: 1, pausedUntil: 0 }),
|
||||
);
|
||||
const migrated = createGateStateStore({ file });
|
||||
assert.equal(
|
||||
migrated.reserve('2026-09-30', {
|
||||
now: 1000,
|
||||
dailyCap: 50,
|
||||
minSpacingMs: 1100,
|
||||
}).status,
|
||||
'reserved',
|
||||
'the original state format without nextStartAt remains supported',
|
||||
);
|
||||
});
|
||||
|
||||
test('a long Retry-After is honoured in full and survives a restart', async (t) => {
|
||||
const file = path.join(tempDir(t), 'state.json');
|
||||
let clock = Date.UTC(2026, 8, 29, 12);
|
||||
const now = () => clock;
|
||||
const refused = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl: async () => new Response('', { status: 429, headers: { 'Retry-After': '86400' } }),
|
||||
usage: createGateStateStore({ file }),
|
||||
now,
|
||||
...quick,
|
||||
});
|
||||
await assert.rejects(createNominatimSearchProvider({ gate: refused })('x', null), {
|
||||
code: 'NOMINATIM_REFUSED',
|
||||
});
|
||||
const calls = [];
|
||||
const restarted = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return json([]);
|
||||
},
|
||||
usage: createGateStateStore({ file }),
|
||||
now,
|
||||
...quick,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate: restarted });
|
||||
clock += 7 * 3_600_000; // past the old six-hour ceiling
|
||||
await assert.rejects(search('y', null), (error) => {
|
||||
assert.equal(error.code, 'NOMINATIM_PAUSED');
|
||||
assert.equal(error.retryAfterMs, 17 * 3_600_000);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls.length, 0);
|
||||
clock += 17 * 3_600_000 + 1000;
|
||||
await search('y', null);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('an operator refusal pauses only the endpoint that refused', async () => {
|
||||
let active = {
|
||||
endpoint: 'https://a.example.test/search',
|
||||
isPublic: false,
|
||||
dailyCap: 50,
|
||||
};
|
||||
const calls = [];
|
||||
const gate = createNominatimGate({
|
||||
settings: () => active,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(new URL(url).host);
|
||||
return calls.length === 1
|
||||
? new Response('', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '60' },
|
||||
})
|
||||
: json([]);
|
||||
},
|
||||
...quick,
|
||||
});
|
||||
const request = () => gate.requestJson((endpoint) => `${endpoint}?q=x`);
|
||||
await assert.rejects(request(), { code: 'NOMINATIM_REFUSED' });
|
||||
active = { ...active, endpoint: 'https://b.example.test/search' };
|
||||
assert.deepEqual(await request(), []);
|
||||
active = { ...active, endpoint: 'https://a.example.test/search' };
|
||||
await assert.rejects(request(), { code: 'NOMINATIM_PAUSED' });
|
||||
assert.deepEqual(calls, ['a.example.test', 'b.example.test']);
|
||||
});
|
||||
|
||||
test('a stalled body times out instead of holding the shared queue', async () => {
|
||||
let cancelled = 0;
|
||||
const stalled = (signal) => {
|
||||
const body = new ReadableStream({
|
||||
pull: () => new Promise(() => {}),
|
||||
cancel: () => {
|
||||
cancelled += 1;
|
||||
},
|
||||
});
|
||||
signal.addEventListener('abort', () => body.cancel().catch(() => {}));
|
||||
return new Response(body, { status: 200 });
|
||||
};
|
||||
let n = 0;
|
||||
const gate = createNominatimGate({
|
||||
settings: { ...PUBLIC, isPublic: false },
|
||||
fetchImpl: async (_url, { signal }) => (++n <= 2 ? stalled(signal) : json([])),
|
||||
timeoutMs: 150,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
const started = Date.now();
|
||||
await assert.rejects(search('stall', null), { code: 'NOMINATIM_TRANSPORT' });
|
||||
assert.ok(Date.now() - started < 3000);
|
||||
assert.ok(cancelled >= 1, 'the body read was cancelled');
|
||||
});
|
||||
|
||||
test('cache keys are request-equivalent: a miss for "Paris???" never answers "Paris"', async () => {
|
||||
const calls = [];
|
||||
const gate = createNominatimGate({
|
||||
settings: LOCAL,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(new URL(url).searchParams.get('q'));
|
||||
return json(calls.length === 1 ? [] : [{ lat: '48.85', lon: '2.35', display_name: 'Paris, France', name: 'Paris', addresstype: 'city' }]);
|
||||
},
|
||||
...quick,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
assert.equal((await search('Paris???', null)).status, 'ZERO_RESULTS');
|
||||
assert.equal((await search('Paris', null)).status, 'OK');
|
||||
assert.deepEqual(calls, ['Paris???', 'Paris']);
|
||||
// Whitespace-only differences are the same upstream request.
|
||||
assert.equal((await search(' Paris ', null)).cached, true);
|
||||
});
|
||||
|
||||
test('a malformed answer is a failure, not a cached "no result"', async () => {
|
||||
let shape = { error: 'Unexpected' };
|
||||
let calls = 0;
|
||||
const gate = createNominatimGate({
|
||||
settings: LOCAL,
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return json(shape);
|
||||
},
|
||||
...quick,
|
||||
});
|
||||
const outline = createNominatimOutlineProvider({ gate });
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
await assert.rejects(outline({ query: 'Zilker Park', kind: 'landmark' }), { code: 'NOMINATIM_MALFORMED' });
|
||||
await assert.rejects(search('Zilker Park', null), { code: 'NOMINATIM_MALFORMED' });
|
||||
shape = [];
|
||||
assert.equal((await outline({ query: 'Zilker Park', kind: 'landmark' })).status, 'ZERO_RESULTS');
|
||||
assert.equal(calls, 3, 'the malformed answers were not cached');
|
||||
});
|
||||
|
||||
test('cache entries are namespaced by endpoint', async () => {
|
||||
const cache = createNominatimCache();
|
||||
const hits = [];
|
||||
const gateFor = (endpoint) =>
|
||||
createNominatimGate({
|
||||
settings: { endpoint, isPublic: false, dailyCap: 50 },
|
||||
fetchImpl: async (url) => {
|
||||
hits.push(new URL(url).host);
|
||||
return json([]);
|
||||
},
|
||||
});
|
||||
await createNominatimSearchProvider({ gate: gateFor('https://a.example.test/search'), cache })('x', null);
|
||||
await createNominatimSearchProvider({ gate: gateFor('https://b.example.test/search'), cache })('x', null);
|
||||
assert.deepEqual(hits, ['a.example.test', 'b.example.test']);
|
||||
});
|
||||
|
||||
test('public-host detection ignores a trailing DNS dot', () => {
|
||||
assert.equal(
|
||||
resolveNominatimSettings({ NOMINATIM_URL: 'https://nominatim.openstreetmap.org./search' }).isPublic,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing coordinates are no bias, never (0, 0)', () => {
|
||||
for (const [lat, lon] of [
|
||||
[null, null],
|
||||
['', ''],
|
||||
[undefined, 2],
|
||||
[48.8, null],
|
||||
])
|
||||
assert.equal(outlineBias('city', lat, lon), null, `${lat},${lon}`);
|
||||
assert.ok(outlineBias('city', 0, 0), 'a real (0, 0) is still a position');
|
||||
});
|
||||
|
||||
const boundary = (extra) => ({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
lat: '1',
|
||||
lon: '1',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.1) },
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('administrative boundaries must be at the asked level', () => {
|
||||
const county = boundary({ name: 'Travis County', addresstype: 'county', place_rank: 12 });
|
||||
const city = boundary({ name: 'Austin', addresstype: 'city', place_rank: 16 });
|
||||
const state = boundary({ name: 'Texas', addresstype: 'state', place_rank: 8 });
|
||||
assert.equal(selectOutlineResult([state], { kind: 'city', query: 'Texas' }).outline, null);
|
||||
assert.equal(selectOutlineResult([city], { kind: 'admin', query: 'Austin' }).outline, null);
|
||||
assert.equal(
|
||||
selectOutlineResult(
|
||||
[boundary({ name: 'Travis', addresstype: 'county', place_rank: 12 })],
|
||||
{ kind: 'city', query: 'Travis' },
|
||||
).outline,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult(
|
||||
[boundary({ name: 'Austin', addresstype: 'city', place_rank: 12 })],
|
||||
{ kind: 'admin', query: 'Austin' },
|
||||
).outline,
|
||||
null,
|
||||
);
|
||||
assert.equal(selectOutlineResult([state], { kind: 'neighborhood', query: 'Texas' }).outline, null);
|
||||
assert.ok(selectOutlineResult([county], { kind: 'admin', query: 'Travis County' }).outline);
|
||||
assert.ok(selectOutlineResult([boundary({ name: 'Somewhere', place_rank: 16 })], { kind: 'city', query: 'Somewhere' }).outline);
|
||||
assert.ok(
|
||||
selectOutlineResult(
|
||||
[boundary({ name: 'Somewhere', addresstype: 'locality', place_rank: 16 })],
|
||||
{ kind: 'city', query: 'Somewhere' },
|
||||
).outline,
|
||||
);
|
||||
assert.equal(
|
||||
selectOutlineResult([boundary({ name: 'Somewhere' })], { kind: 'city', query: 'Somewhere' }).outline,
|
||||
null,
|
||||
'no level evidence at all',
|
||||
);
|
||||
// An unsuitable early boundary does not block a suitable later one.
|
||||
const picked = selectOutlineResult([state, boundary({ name: 'Texas', addresstype: 'city', osm_type: 'relation', osm_id: 9 })], {
|
||||
kind: 'city',
|
||||
query: 'Texas',
|
||||
});
|
||||
assert.equal(picked.outline.osm, 'relation/9');
|
||||
});
|
||||
|
||||
test('admin identity comes only from unambiguous evidence on the selected row', () => {
|
||||
for (const [name, addresstype, adminLevel] of [
|
||||
['Nepal', 'country', 'country'],
|
||||
['Bagmati Province', 'province', 'admin1'],
|
||||
['Province of Bagmati', 'province', 'admin1'],
|
||||
['Texas', 'state', 'admin1'],
|
||||
['Travis County', 'county', 'admin2'],
|
||||
['County of Travis', 'county', 'admin2'],
|
||||
['Kathmandu District', 'state_district', 'admin2'],
|
||||
]) {
|
||||
const { outline } = selectOutlineResult(
|
||||
[boundary({ name, addresstype })],
|
||||
{ kind: 'admin', query: name },
|
||||
);
|
||||
assert.equal(outline.adminArea, name);
|
||||
assert.equal(outline.adminLevel, adminLevel);
|
||||
}
|
||||
|
||||
for (const row of [
|
||||
boundary({ name: 'Northern Region', addresstype: 'region', place_rank: 8 }),
|
||||
boundary({ name: 'Central District', addresstype: 'district', place_rank: 10 }),
|
||||
boundary({ name: 'Mystery Province', place_rank: 8 }),
|
||||
boundary({ name: 'Example State', addresstype: 'county' }),
|
||||
boundary({ name: 'County of Example', addresstype: 'state' }),
|
||||
boundary({ name: 'Province of Example', addresstype: 'county' }),
|
||||
boundary({ name: 'Example County', addresstype: 'state' }),
|
||||
boundary({ name: 'Example Province', addresstype: 'county' }),
|
||||
boundary({ name: 'County of Example Province', addresstype: 'county' }),
|
||||
]) {
|
||||
const { outline } = selectOutlineResult([row], {
|
||||
kind: 'admin',
|
||||
query: row.name,
|
||||
});
|
||||
assert.ok(outline, row.name);
|
||||
assert.equal(outline.adminArea, undefined, row.name);
|
||||
assert.equal(outline.adminLevel, undefined, row.name);
|
||||
}
|
||||
|
||||
const retired = selectOutlineResult(
|
||||
[boundary({ name: 'Bagmati Zone', addresstype: 'state' })],
|
||||
{ kind: 'admin', query: 'Bagmati Zone' },
|
||||
).outline;
|
||||
assert.equal(retired.adminArea, 'Bagmati Zone');
|
||||
assert.equal(retired.adminLevel, undefined);
|
||||
|
||||
const retiredPrefix = selectOutlineResult(
|
||||
[boundary({ name: 'Zone of Bagmati', addresstype: 'state' })],
|
||||
{ kind: 'admin', query: 'Zone of Bagmati' },
|
||||
).outline;
|
||||
assert.equal(retiredPrefix.adminArea, 'Zone of Bagmati');
|
||||
assert.equal(retiredPrefix.adminLevel, undefined);
|
||||
});
|
||||
|
||||
test('admin identity validates the full name before display truncation', () => {
|
||||
const longValidName = `Province of ${'Example '.repeat(24)}`.trim();
|
||||
const valid = selectOutlineResult(
|
||||
[boundary({ name: longValidName, addresstype: 'province' })],
|
||||
{ kind: 'admin', query: longValidName },
|
||||
).outline;
|
||||
assert.equal(valid.adminArea, longValidName.slice(0, 160));
|
||||
assert.equal(valid.adminLevel, 'admin1');
|
||||
|
||||
const longContradictoryName = `${'Very Long Administrative Name '.repeat(7)}County`;
|
||||
assert.ok(longContradictoryName.length > 160);
|
||||
const contradictory = selectOutlineResult(
|
||||
[boundary({ name: longContradictoryName, addresstype: 'state' })],
|
||||
{ kind: 'admin', query: longContradictoryName },
|
||||
).outline;
|
||||
assert.ok(contradictory);
|
||||
assert.equal(contradictory.adminArea, undefined);
|
||||
assert.equal(contradictory.adminLevel, undefined);
|
||||
});
|
||||
|
||||
test('non-administrative outline kinds never manufacture admin identity', () => {
|
||||
const city = selectOutlineResult(
|
||||
[boundary({ name: 'County of Austin', addresstype: 'city', place_rank: 16 })],
|
||||
{ kind: 'city', query: 'County of Austin' },
|
||||
).outline;
|
||||
const park = selectOutlineResult(
|
||||
[
|
||||
{
|
||||
category: 'leisure',
|
||||
type: 'park',
|
||||
name: 'Zilker Park',
|
||||
lat: '1',
|
||||
lon: '1',
|
||||
geojson: { type: 'Polygon', coordinates: square(1, 1, 0.01) },
|
||||
},
|
||||
],
|
||||
{ kind: 'landmark', query: 'Zilker Park' },
|
||||
).outline;
|
||||
for (const outline of [city, park]) {
|
||||
assert.equal(outline.adminArea, undefined);
|
||||
assert.equal(outline.adminLevel, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('building asks need building evidence', () => {
|
||||
const park = { category: 'tourism', type: 'theme_park', name: 'Big Top', lat: '1', lon: '1', geojson: { type: 'Polygon', coordinates: square(1, 1, 0.0001) } };
|
||||
const camp = { ...park, type: 'camp_site' };
|
||||
const campus = { ...park, category: 'amenity', type: 'university' };
|
||||
for (const row of [park, camp, campus])
|
||||
assert.equal(selectOutlineResult([row], { kind: 'building', query: 'Big Top' }).outline, null, row.type);
|
||||
const hugeOffice = { ...park, category: 'office', type: 'government', geojson: { type: 'Polygon', coordinates: square(1, 1, 0.01) } };
|
||||
assert.equal(selectOutlineResult([hugeOffice], { kind: 'building', query: 'Big Top' }).outline, null);
|
||||
const office = { ...hugeOffice, geojson: { type: 'Polygon', coordinates: square(1, 1, 0.001) } };
|
||||
assert.ok(selectOutlineResult([office], { kind: 'building', query: 'Big Top' }).outline);
|
||||
const hall = { ...hugeOffice, category: 'building', type: 'yes' };
|
||||
assert.ok(selectOutlineResult([hall], { kind: 'building', query: 'Big Top' }).outline);
|
||||
});
|
||||
|
||||
test('names match on whole words', () => {
|
||||
const row = (name) => ({ category: 'leisure', type: 'park', name, lat: '1', lon: '1', geojson: { type: 'Polygon', coordinates: square(1, 1, 0.01) } });
|
||||
assert.equal(selectOutlineResult([row('Yorkshire Park')], { kind: 'landmark', query: 'York' }).outline, null);
|
||||
assert.ok(selectOutlineResult([row('York Park')], { kind: 'landmark', query: 'York' }).outline);
|
||||
assert.ok(selectOutlineResult([row('Zilker Metropolitan Park')], { kind: 'landmark', query: 'Zilker Park' }).outline);
|
||||
assert.ok(
|
||||
selectOutlineResult([{ ...row('新宿御苑'), namedetails: { name: '新宿御苑' } }], { kind: 'landmark', query: '新宿御苑' }).outline,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
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 { spawn } from 'node:child_process';
|
||||
import { createGateStateStore } from '../../server/providers/regional/nominatimGate.js';
|
||||
|
||||
const moduleUrl = new URL(
|
||||
'../../server/providers/regional/nominatimGate.js',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
function startModule(script) {
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const lines = [];
|
||||
const waiters = [];
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const publish = (line) => {
|
||||
lines.push(line);
|
||||
for (const waiter of [...waiters]) {
|
||||
if (!line.startsWith(waiter.prefix)) continue;
|
||||
waiters.splice(waiters.indexOf(waiter), 1);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(line);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
const parts = stdout.split('\n');
|
||||
stdout = parts.pop();
|
||||
for (const line of parts) publish(line);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
lines,
|
||||
waitFor(prefix, timeoutMs = 3000) {
|
||||
const existing = lines.find((line) => line.startsWith(prefix));
|
||||
if (existing) return Promise.resolve(existing);
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter = { prefix, resolve, reject, timer: null };
|
||||
waiter.timer = setTimeout(() => {
|
||||
const index = waiters.indexOf(waiter);
|
||||
if (index >= 0) waiters.splice(index, 1);
|
||||
reject(
|
||||
new Error(
|
||||
`timed out waiting for ${prefix}; stderr: ${stderr.trim()}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
waiters.push(waiter);
|
||||
});
|
||||
},
|
||||
exited: new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function holderScript(lockFile) {
|
||||
return `
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
const database = new DatabaseSync(${JSON.stringify(lockFile)});
|
||||
database.exec('BEGIN IMMEDIATE');
|
||||
process.stdout.write('LOCKED\\n');
|
||||
process.stdin.once('data', () => {
|
||||
database.exec('ROLLBACK');
|
||||
database.close();
|
||||
process.stdout.write('RELEASED\\n');
|
||||
});
|
||||
process.stdin.resume();
|
||||
`;
|
||||
}
|
||||
|
||||
function contenderScript(stateFile, lockWaitMs) {
|
||||
return `
|
||||
const { createGateStateStore } = await import(${JSON.stringify(moduleUrl)});
|
||||
const store = createGateStateStore({
|
||||
file: ${JSON.stringify(stateFile)},
|
||||
lockWaitMs: ${lockWaitMs},
|
||||
onError: () => {},
|
||||
});
|
||||
process.stdout.write('ATTEMPTING\\n', () => {
|
||||
const result = store.reserve('2026-10-01', {
|
||||
now: 1000,
|
||||
dailyCap: 50,
|
||||
minSpacingMs: 1100,
|
||||
});
|
||||
process.stdout.write('RESULT:' + JSON.stringify(result) + '\\n');
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
test('a crashed lock owner cannot delete or bypass a successor lock', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-lock-'));
|
||||
const stateFile = path.join(dir, 'state.json');
|
||||
const lockFile = `${stateFile}.lock.sqlite`;
|
||||
const children = [];
|
||||
t.after(() => {
|
||||
for (const process of children) {
|
||||
if (process.exitCode == null && process.signalCode == null)
|
||||
process.kill();
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const crashedOwner = startModule(holderScript(lockFile));
|
||||
children.push(crashedOwner.child);
|
||||
await crashedOwner.waitFor('LOCKED');
|
||||
const originalLock = fs.statSync(lockFile);
|
||||
|
||||
const blockedByCrashedOwner = startModule(contenderScript(stateFile, 0));
|
||||
children.push(blockedByCrashedOwner.child);
|
||||
const blockedResult = JSON.parse(
|
||||
(await blockedByCrashedOwner.waitFor('RESULT:')).slice('RESULT:'.length),
|
||||
);
|
||||
assert.equal(blockedResult.status, 'unavailable');
|
||||
assert.equal((await blockedByCrashedOwner.exited).code, 0);
|
||||
assert.equal(fs.existsSync(stateFile), false);
|
||||
|
||||
crashedOwner.child.kill();
|
||||
await crashedOwner.exited;
|
||||
|
||||
const successorOwner = startModule(holderScript(lockFile));
|
||||
children.push(successorOwner.child);
|
||||
await successorOwner.waitFor('LOCKED');
|
||||
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
|
||||
|
||||
const refused = startModule(contenderScript(stateFile, 0));
|
||||
children.push(refused.child);
|
||||
const refusedResult = JSON.parse(
|
||||
(await refused.waitFor('RESULT:')).slice('RESULT:'.length),
|
||||
);
|
||||
assert.equal(refusedResult.status, 'unavailable');
|
||||
assert.equal((await refused.exited).code, 0);
|
||||
assert.equal(
|
||||
createGateStateStore({ file: stateFile }).count('2026-10-01'),
|
||||
0,
|
||||
'a timed-out contender fails closed without spending a public slot',
|
||||
);
|
||||
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
|
||||
|
||||
successorOwner.child.stdin.end('release\n');
|
||||
await successorOwner.waitFor('RELEASED');
|
||||
assert.equal((await successorOwner.exited).code, 0);
|
||||
const afterRelease = createGateStateStore({ file: stateFile }).reserve(
|
||||
'2026-10-01',
|
||||
{
|
||||
now: 2200,
|
||||
dailyCap: 50,
|
||||
minSpacingMs: 1100,
|
||||
},
|
||||
);
|
||||
assert.equal(afterRelease.status, 'reserved');
|
||||
assert.equal(afterRelease.count, 1);
|
||||
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
|
||||
});
|
||||
|
||||
test('a public pause stays authoritative while another process owns the lock', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-pause-'));
|
||||
const stateFile = path.join(dir, 'state.json');
|
||||
const lockFile = `${stateFile}.lock.sqlite`;
|
||||
const children = [];
|
||||
t.after(() => {
|
||||
for (const process of children) {
|
||||
if (process.exitCode == null && process.signalCode == null)
|
||||
process.kill();
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({
|
||||
day: '2026-10-01',
|
||||
count: 1,
|
||||
pausedUntil: 0,
|
||||
nextStartAt: 0,
|
||||
}),
|
||||
);
|
||||
const owner = startModule(holderScript(lockFile));
|
||||
children.push(owner.child);
|
||||
await owner.waitFor('LOCKED');
|
||||
|
||||
const errors = [];
|
||||
const blockedWriter = createGateStateStore({
|
||||
file: stateFile,
|
||||
lockWaitMs: 0,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
blockedWriter.pauseUntil(9000);
|
||||
assert.equal(errors.length, 1, 'the blocked JSON consolidation is reported');
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(stateFile, 'utf8')).pausedUntil,
|
||||
0,
|
||||
'the lock owner prevents an unsafe JSON overwrite',
|
||||
);
|
||||
|
||||
owner.child.stdin.end('release\n');
|
||||
await owner.waitFor('RELEASED');
|
||||
assert.equal((await owner.exited).code, 0);
|
||||
|
||||
const successor = createGateStateStore({ file: stateFile });
|
||||
assert.deepEqual(
|
||||
successor.reserve('2026-10-01', {
|
||||
now: 1000,
|
||||
dailyCap: 50,
|
||||
minSpacingMs: 1100,
|
||||
}),
|
||||
{ status: 'paused', retryAfterMs: 8000 },
|
||||
'a fresh process observes the pending refusal pause',
|
||||
);
|
||||
const resumed = successor.reserve('2026-10-01', {
|
||||
now: 9001,
|
||||
dailyCap: 50,
|
||||
minSpacingMs: 1100,
|
||||
});
|
||||
assert.equal(resumed.status, 'reserved');
|
||||
assert.equal(resumed.count, 2);
|
||||
assert.equal(
|
||||
fs.readdirSync(dir).some((name) => name.startsWith('state.json.pause.')),
|
||||
false,
|
||||
'the next successful transaction consolidates and removes the marker',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,880 @@
|
||||
// THE GUARDED NOMINATIM GATE AND OUTLINE ROUTE.
|
||||
//
|
||||
// Search and voice outlines share one gate to the public instance: 1.1 s
|
||||
// spacing, a short queue, a daily cap per install, a pause when the server
|
||||
// says stop, one retry only after a transport failure, and caches that never
|
||||
// record a failure as "no such place". Outline answers are accepted only when
|
||||
// the result's class suits the ask.
|
||||
//
|
||||
// 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 {
|
||||
createNominatimGate,
|
||||
createUsageStore,
|
||||
parseRetryAfter,
|
||||
resolveNominatimSettings,
|
||||
PUBLIC_NOMINATIM_SEARCH,
|
||||
} from '../../server/providers/regional/nominatimGate.js';
|
||||
import {
|
||||
createNominatimCache,
|
||||
normalizePlaceName,
|
||||
NOMINATIM_CACHE_TTL_MS,
|
||||
} from '../../server/providers/regional/nominatimCache.js';
|
||||
import {
|
||||
acceptsOutlineClass,
|
||||
capOutlineVertices,
|
||||
outlineBias,
|
||||
selectOutlineResult,
|
||||
} from '../../server/providers/regional/outlineSelect.js';
|
||||
import {
|
||||
createNominatimOutlineProvider,
|
||||
createNominatimSearchProvider,
|
||||
geocodeProxy,
|
||||
} from '../../server/providers/regional/place.js';
|
||||
|
||||
const PUBLIC = {
|
||||
endpoint: PUBLIC_NOMINATIM_SEARCH,
|
||||
isPublic: true,
|
||||
dailyCap: 50,
|
||||
};
|
||||
const OPERATOR = {
|
||||
endpoint: 'https://geo.example.test/search',
|
||||
isPublic: false,
|
||||
dailyCap: 50,
|
||||
};
|
||||
|
||||
const square = (lon, lat, d = 0.001) => [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + d, lat],
|
||||
[lon + d, lat + d],
|
||||
[lon, lat + d],
|
||||
[lon, lat],
|
||||
],
|
||||
];
|
||||
|
||||
const row = (overrides) => ({
|
||||
lat: '30.27',
|
||||
lon: '-97.74',
|
||||
display_name: 'Somewhere',
|
||||
osm_type: 'relation',
|
||||
osm_id: 1,
|
||||
geojson: { type: 'Polygon', coordinates: square(-97.74, 30.27) },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const json = (value, init = {}) =>
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
|
||||
/** A fake clock whose sleeps advance time instantly and are recorded. */
|
||||
function clock(start = Date.UTC(2026, 8, 28, 12)) {
|
||||
let t = start;
|
||||
const sleeps = [];
|
||||
return {
|
||||
now: () => t,
|
||||
advance: (ms) => {
|
||||
t += ms;
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
t += ms;
|
||||
},
|
||||
sleeps,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFetch(respond) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push(String(url));
|
||||
return await respond(String(url), options, calls.length);
|
||||
};
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
function tempDir(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function publicGate(t, options = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-gate-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return createNominatimGate({
|
||||
...options,
|
||||
settings: options.settings || PUBLIC,
|
||||
usage:
|
||||
options.usage || createUsageStore({ file: path.join(dir, 'state.json') }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- settings
|
||||
|
||||
test('endpoint settings: unset is public, empty disables, operator URLs are not public', () => {
|
||||
assert.deepEqual(resolveNominatimSettings({}), {
|
||||
endpoint: PUBLIC_NOMINATIM_SEARCH,
|
||||
isPublic: true,
|
||||
dailyCap: 50,
|
||||
});
|
||||
for (const off of ['', ' ', 'off', 'none', 'disabled'])
|
||||
assert.equal(
|
||||
resolveNominatimSettings({ NOMINATIM_URL: off }).endpoint,
|
||||
null,
|
||||
);
|
||||
const own = resolveNominatimSettings({
|
||||
NOMINATIM_URL: 'https://geo.example.test',
|
||||
});
|
||||
assert.equal(own.endpoint, 'https://geo.example.test/search');
|
||||
assert.equal(own.isPublic, false);
|
||||
assert.equal(
|
||||
resolveNominatimSettings({
|
||||
NOMINATIM_URL: 'http://localhost:8080/search',
|
||||
}).endpoint,
|
||||
'http://localhost:8080/search',
|
||||
);
|
||||
// A mistyped value disables rather than silently falling back to public.
|
||||
for (const bad of [
|
||||
'not a url',
|
||||
'ftp://x.test',
|
||||
'https://u:[email protected]',
|
||||
'https://x.test/search?q=1',
|
||||
])
|
||||
assert.equal(
|
||||
resolveNominatimSettings({ NOMINATIM_URL: bad }).endpoint,
|
||||
null,
|
||||
bad,
|
||||
);
|
||||
assert.equal(
|
||||
resolveNominatimSettings({
|
||||
NOMINATIM_URL: 'https://nominatim.openstreetmap.org/search',
|
||||
}).isPublic,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
resolveNominatimSettings({ NOMINATIM_DAILY_CAP: '7' }).dailyCap,
|
||||
7,
|
||||
);
|
||||
assert.equal(
|
||||
resolveNominatimSettings({ NOMINATIM_DAILY_CAP: '0' }).dailyCap,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
resolveNominatimSettings({ NOMINATIM_DAILY_CAP: 'x' }).dailyCap,
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
test('Retry-After reads seconds and HTTP dates', () => {
|
||||
const now = Date.UTC(2026, 0, 1);
|
||||
assert.equal(parseRetryAfter('0', now), 0);
|
||||
assert.equal(parseRetryAfter('120', now), 120_000);
|
||||
for (const httpDate of [
|
||||
'Thu, 01 Jan 2026 00:00:30 GMT',
|
||||
'Thursday, 01-Jan-26 00:00:30 GMT',
|
||||
'Thu Jan 1 00:00:30 2026',
|
||||
])
|
||||
assert.equal(parseRetryAfter(httpDate, now), 30_000, httpDate);
|
||||
assert.equal(parseRetryAfter(null, now), null);
|
||||
assert.equal(parseRetryAfter('soon', now), null);
|
||||
for (const malformed of [
|
||||
'-1',
|
||||
' ',
|
||||
'1.5',
|
||||
'1e3',
|
||||
'9007199254741',
|
||||
'999999999999999999999999',
|
||||
'2099-01-01',
|
||||
'October 3, 2026',
|
||||
'2026-10-03T00:00:00Z',
|
||||
'Fri, 01 Jan 2026 00:00:30 GMT',
|
||||
'Thu, 31 Feb 2026 00:00:30 GMT',
|
||||
])
|
||||
assert.equal(parseRetryAfter(malformed, now), null, malformed);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the gate
|
||||
|
||||
test('search and outlines share one pacer: requests start at least 1.1 s apart', async (t) => {
|
||||
const time = clock();
|
||||
const { calls, fetchImpl } = stubFetch(async (url) =>
|
||||
json(
|
||||
url.includes('polygon_geojson')
|
||||
? [
|
||||
row({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
addresstype: 'city',
|
||||
name: 'Austin',
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const gate = publicGate(t, { fetchImpl, ...time });
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
const outline = createNominatimOutlineProvider({ gate });
|
||||
await Promise.all([
|
||||
search('austin', null),
|
||||
outline({ query: 'Austin', kind: 'city' }),
|
||||
search('dallas', null),
|
||||
]);
|
||||
assert.equal(calls.length, 3);
|
||||
// The first starts at once; each later one waits out the full spacing.
|
||||
assert.deepEqual(time.sleeps, [1100, 1100]);
|
||||
assert.equal(gate.stats().usedToday, 3, 'one daily count for both kinds');
|
||||
});
|
||||
|
||||
test('the daily cap stops public requests and is shared by search and outlines', async (t) => {
|
||||
const time = clock();
|
||||
const { calls, fetchImpl } = stubFetch(async () => json([]));
|
||||
const gate = publicGate(t, {
|
||||
settings: { ...PUBLIC, dailyCap: 2 },
|
||||
fetchImpl,
|
||||
...time,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
const outline = createNominatimOutlineProvider({ gate });
|
||||
await search('one', null);
|
||||
await outline({ query: 'two', kind: 'landmark' });
|
||||
await assert.rejects(search('three', null), { code: 'NOMINATIM_DAILY_CAP' });
|
||||
await assert.rejects(outline({ query: 'four', kind: 'city' }), {
|
||||
code: 'NOMINATIM_DAILY_CAP',
|
||||
});
|
||||
assert.equal(calls.length, 2);
|
||||
// Cached answers still work after the cap.
|
||||
assert.equal((await search('one', null)).cached, true);
|
||||
// A new UTC day restores the allowance.
|
||||
time.advance(86_400_000);
|
||||
await search('five', null);
|
||||
assert.equal(calls.length, 3);
|
||||
});
|
||||
|
||||
test('an operator endpoint is not subject to public cap, spacing, or durable state', async (t) => {
|
||||
const time = clock();
|
||||
const { calls, fetchImpl } = stubFetch(async () => json([]));
|
||||
const blocker = path.join(tempDir(t), 'not-a-directory');
|
||||
fs.writeFileSync(blocker, 'x');
|
||||
const gate = createNominatimGate({
|
||||
settings: { ...OPERATOR, dailyCap: 1 },
|
||||
fetchImpl,
|
||||
usage: createUsageStore({ file: path.join(blocker, 'state.json') }),
|
||||
...time,
|
||||
});
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
await Promise.all([search('a', null), search('b', null), search('c', null)]);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.ok(
|
||||
calls.every((url) => url.startsWith('https://geo.example.test/search?')),
|
||||
);
|
||||
assert.deepEqual(time.sleeps, []);
|
||||
});
|
||||
|
||||
test('public access fails closed when durable state is missing or unwritable', async (t) => {
|
||||
const { calls, fetchImpl } = stubFetch(async () => json([]));
|
||||
const withoutState = createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl,
|
||||
usage: createUsageStore({ onError: () => {} }),
|
||||
});
|
||||
await assert.rejects(
|
||||
createNominatimSearchProvider({ gate: withoutState })('missing', null),
|
||||
{ code: 'NOMINATIM_STATE_UNAVAILABLE' },
|
||||
);
|
||||
|
||||
const blocker = path.join(tempDir(t), 'not-a-directory');
|
||||
fs.writeFileSync(blocker, 'x');
|
||||
const invalidFile = path.join(blocker, 'state.json');
|
||||
const makeBrokenGate = () =>
|
||||
createNominatimGate({
|
||||
settings: PUBLIC,
|
||||
fetchImpl,
|
||||
usage: createUsageStore({ file: invalidFile, onError: () => {} }),
|
||||
});
|
||||
await assert.rejects(
|
||||
createNominatimSearchProvider({ gate: makeBrokenGate() })('broken', null),
|
||||
{ code: 'NOMINATIM_STATE_UNAVAILABLE' },
|
||||
);
|
||||
await assert.rejects(
|
||||
createNominatimSearchProvider({ gate: makeBrokenGate() })('restart', null),
|
||||
{ code: 'NOMINATIM_STATE_UNAVAILABLE' },
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('a disabled endpoint sends nothing', async () => {
|
||||
const { calls, fetchImpl } = stubFetch(async () => json([]));
|
||||
const gate = createNominatimGate({
|
||||
settings: { endpoint: null, isPublic: false, dailyCap: 50 },
|
||||
fetchImpl,
|
||||
});
|
||||
await assert.rejects(createNominatimSearchProvider({ gate })('x', null), {
|
||||
code: 'NOMINATIM_DISABLED',
|
||||
});
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('429 with Retry-After pauses the gate and stops queued requests', async (t) => {
|
||||
const time = clock();
|
||||
const { calls, fetchImpl } = stubFetch(async (_url, _o, n) =>
|
||||
n === 1
|
||||
? new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
})
|
||||
: json([]),
|
||||
);
|
||||
const gate = publicGate(t, { fetchImpl, ...time });
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
const [first, queued] = await Promise.allSettled([
|
||||
search('first', null),
|
||||
search('queued', null),
|
||||
]);
|
||||
assert.equal(first.reason.code, 'NOMINATIM_REFUSED');
|
||||
assert.equal(first.reason.retryAfterMs, 120_000);
|
||||
assert.equal(
|
||||
queued.reason.code,
|
||||
'NOMINATIM_PAUSED',
|
||||
'the queued request is not sent',
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
await assert.rejects(search('later', null), { code: 'NOMINATIM_PAUSED' });
|
||||
assert.equal(calls.length, 1);
|
||||
time.advance(121_000);
|
||||
await search('after', null);
|
||||
assert.equal(calls.length, 2, 'requests resume once the pause has passed');
|
||||
// The refusal was not remembered as "no such place".
|
||||
await search('first', null);
|
||||
assert.equal(calls.length, 3);
|
||||
});
|
||||
|
||||
test('malformed Retry-After values retain the default refusal cooldown', async (t) => {
|
||||
const malformedValues = [
|
||||
'-1',
|
||||
' ',
|
||||
'1.5',
|
||||
'1e3',
|
||||
'2099-01-01',
|
||||
'October 3, 2026',
|
||||
];
|
||||
for (const [index, retryAfter] of malformedValues.entries()) {
|
||||
const time = clock();
|
||||
const { fetchImpl } = stubFetch(
|
||||
async () =>
|
||||
new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': retryAfter },
|
||||
}),
|
||||
);
|
||||
const gate = publicGate(t, {
|
||||
fetchImpl,
|
||||
...time,
|
||||
usage: createUsageStore({
|
||||
file: path.join(tempDir(t), `malformed-${index}.json`),
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
createNominatimSearchProvider({ gate })(`bad-${index}`, null),
|
||||
(error) =>
|
||||
error.code === 'NOMINATIM_REFUSED' && error.retryAfterMs === 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [index, status, expected] of [
|
||||
[0, 503, 60_000],
|
||||
[1, 403, 3_600_000],
|
||||
[2, 418, 3_600_000],
|
||||
[3, 502, 30_000],
|
||||
]) {
|
||||
const time = clock();
|
||||
const file = path.join(tempDir(t), `malformed-status-${index}.json`);
|
||||
const makeGate = () =>
|
||||
publicGate(t, {
|
||||
fetchImpl: async () =>
|
||||
new Response('wait', {
|
||||
status,
|
||||
headers: { 'Retry-After': '2099-01-01' },
|
||||
}),
|
||||
...time,
|
||||
usage: createUsageStore({ file }),
|
||||
});
|
||||
await assert.rejects(
|
||||
createNominatimSearchProvider({ gate: makeGate() })(
|
||||
`status-${status}`,
|
||||
null,
|
||||
),
|
||||
);
|
||||
assert.equal(makeGate().stats().pausedForMs, expected, String(status));
|
||||
}
|
||||
});
|
||||
|
||||
test('an outage stops queued requests; a server error is never cached as not found', async (t) => {
|
||||
const time = clock();
|
||||
let down = true;
|
||||
const { calls, fetchImpl } = stubFetch(async () =>
|
||||
down
|
||||
? new Response('bad gateway', { status: 502 })
|
||||
: json([row({ category: 'leisure', type: 'park', name: 'Zilker Park' })]),
|
||||
);
|
||||
const gate = publicGate(t, { fetchImpl, ...time });
|
||||
const outline = createNominatimOutlineProvider({ gate });
|
||||
const results = await Promise.allSettled([
|
||||
outline({ query: 'Zilker Park', kind: 'landmark' }),
|
||||
outline({ query: 'Barton Springs', kind: 'landmark' }),
|
||||
]);
|
||||
assert.equal(results[0].reason.code, 'NOMINATIM_UPSTREAM');
|
||||
assert.equal(results[1].reason.code, 'NOMINATIM_PAUSED');
|
||||
assert.equal(calls.length, 1);
|
||||
down = false;
|
||||
time.advance(31_000);
|
||||
const answer = await outline({ query: 'Zilker Park', kind: 'landmark' });
|
||||
assert.equal(answer.status, 'OK');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('a transport failure is retried once, then pauses and is not cached', async (t) => {
|
||||
const time = clock();
|
||||
let failures = 1;
|
||||
const { calls, fetchImpl } = stubFetch(async () => {
|
||||
if (failures-- > 0) throw new TypeError('fetch failed');
|
||||
return json([]);
|
||||
});
|
||||
const gate = publicGate(t, { fetchImpl, ...time });
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
assert.equal((await search('retry', null)).status, 'ZERO_RESULTS');
|
||||
assert.equal(calls.length, 2, 'one retry after a transport failure');
|
||||
|
||||
failures = 2;
|
||||
await assert.rejects(search('twice', null), { code: 'NOMINATIM_TRANSPORT' });
|
||||
assert.equal(calls.length, 4, 'never more than one retry');
|
||||
await assert.rejects(search('twice', null), { code: 'NOMINATIM_PAUSED' });
|
||||
time.advance(31_000);
|
||||
assert.equal(
|
||||
(await search('twice', null)).cached,
|
||||
undefined,
|
||||
'the failure was not cached',
|
||||
);
|
||||
assert.equal(calls.length, 5);
|
||||
});
|
||||
|
||||
test('a 403 block pauses for an hour', async (t) => {
|
||||
const time = clock();
|
||||
const { fetchImpl } = stubFetch(
|
||||
async () => new Response('', { status: 403 }),
|
||||
);
|
||||
const gate = publicGate(t, { fetchImpl, ...time });
|
||||
await assert.rejects(createNominatimSearchProvider({ gate })('x', null), {
|
||||
code: 'NOMINATIM_REFUSED',
|
||||
});
|
||||
assert.ok(gate.stats().pausedForMs >= 3_599_000);
|
||||
});
|
||||
|
||||
test('the queue is bounded', async (t) => {
|
||||
const time = clock();
|
||||
const { fetchImpl } = stubFetch(async () => json([]));
|
||||
const gate = publicGate(t, { fetchImpl, ...time, maxPending: 2 });
|
||||
const search = createNominatimSearchProvider({ gate });
|
||||
const results = await Promise.allSettled(
|
||||
['a', 'b', 'c', 'd'].map((q) => search(q, null)),
|
||||
);
|
||||
assert.deepEqual(
|
||||
results.map((r) => r.status === 'fulfilled' || r.reason.code),
|
||||
[true, true, 'NOMINATIM_QUEUE_FULL', 'NOMINATIM_QUEUE_FULL'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the daily count survives a restart when persisted', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-usage-'));
|
||||
const file = path.join(dir, 'usage.json');
|
||||
createUsageStore({ file }).increment('2026-09-28');
|
||||
createUsageStore({ file }).increment('2026-09-28');
|
||||
assert.equal(createUsageStore({ file }).count('2026-09-28'), 2);
|
||||
assert.equal(createUsageStore({ file }).count('2026-09-29'), 0);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- caches
|
||||
|
||||
test('cache lifetimes: 90 days for outlines, 30 for places, 24 hours for no result', async () => {
|
||||
const time = clock();
|
||||
const cache = createNominatimCache({ now: time.now });
|
||||
await cache.set('o', 1, NOMINATIM_CACHE_TTL_MS.outline);
|
||||
await cache.set('p', 2, NOMINATIM_CACHE_TTL_MS.place);
|
||||
await cache.set('n', 3, NOMINATIM_CACHE_TTL_MS.notFound);
|
||||
time.advance(23 * 3_600_000);
|
||||
assert.ok(await cache.get('n'));
|
||||
time.advance(2 * 3_600_000);
|
||||
assert.equal(await cache.get('n'), null);
|
||||
time.advance(28 * 86_400_000);
|
||||
assert.ok(await cache.get('p'));
|
||||
time.advance(2 * 86_400_000);
|
||||
assert.equal(await cache.get('p'), null);
|
||||
assert.ok(await cache.get('o'));
|
||||
time.advance(60 * 86_400_000);
|
||||
assert.equal(await cache.get('o'), null);
|
||||
});
|
||||
|
||||
test('the disk cache survives a restart and stays within its bounds', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-nominatim-cache-'));
|
||||
const time = clock();
|
||||
const first = createNominatimCache({
|
||||
dir,
|
||||
now: time.now,
|
||||
maxDiskEntries: 3,
|
||||
sweepEvery: 1000,
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
time.advance(1000);
|
||||
await first.set(
|
||||
`k${i}`,
|
||||
{ i },
|
||||
i === 0 ? 10 : NOMINATIM_CACHE_TTL_MS.outline,
|
||||
);
|
||||
}
|
||||
const second = createNominatimCache({
|
||||
dir,
|
||||
now: time.now,
|
||||
maxDiskEntries: 3,
|
||||
});
|
||||
assert.deepEqual((await second.get('k4')).payload, { i: 4 });
|
||||
await second.sweep();
|
||||
const files = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
||||
assert.equal(files.length, 3, 'expired entry removed, then oldest trimmed');
|
||||
assert.equal(
|
||||
await createNominatimCache({ dir, now: time.now }).get('k0'),
|
||||
null,
|
||||
);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('names are normalized Unicode-aware so small differences share an entry', () => {
|
||||
assert.equal(
|
||||
normalizePlaceName('São Paulo'),
|
||||
normalizePlaceName('SAO paulo!'),
|
||||
);
|
||||
assert.equal(normalizePlaceName('The Pentagon'), 'pentagon');
|
||||
assert.equal(normalizePlaceName('Zürich'), 'zurich');
|
||||
assert.equal(
|
||||
normalizePlaceName('東京都'),
|
||||
'東京都',
|
||||
'non-Latin names survive',
|
||||
);
|
||||
assert.equal(normalizePlaceName('Central Park'), 'central park');
|
||||
});
|
||||
|
||||
test('small view changes reuse one cached outline', async (t) => {
|
||||
const time = clock();
|
||||
const { calls, fetchImpl } = stubFetch(async () =>
|
||||
json([row({ category: 'leisure', type: 'park', name: 'Central Park' })]),
|
||||
);
|
||||
const gate = publicGate(t, { fetchImpl, ...time });
|
||||
const outline = createNominatimOutlineProvider({ gate });
|
||||
await outline({
|
||||
query: 'Central Park',
|
||||
kind: 'landmark',
|
||||
lat: 40.781,
|
||||
lon: -73.966,
|
||||
});
|
||||
const again = await outline({
|
||||
query: ' Central Park ',
|
||||
kind: 'landmark',
|
||||
lat: 40.7822,
|
||||
lon: -73.9651,
|
||||
});
|
||||
assert.equal(again.cached, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0], /viewbox=/);
|
||||
assert.equal(outlineBias('city', 48.85, 2.35).key, '49,2');
|
||||
assert.equal(outlineBias('landmark', 30.2747, -97.7404).key, '30.25,-97.75');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
test('wrong types are rejected: charging station for Mission District, station for Bandra', () => {
|
||||
const charging = row({
|
||||
category: 'amenity',
|
||||
type: 'charging_station',
|
||||
name: 'Revel - Mission District',
|
||||
});
|
||||
for (const kind of ['neighborhood', 'landmark', 'city'])
|
||||
assert.equal(
|
||||
selectOutlineResult([charging], {
|
||||
kind,
|
||||
query: 'Mission District, San Francisco',
|
||||
}).outline,
|
||||
null,
|
||||
kind,
|
||||
);
|
||||
const station = row({ category: 'railway', type: 'station', name: 'Bandra' });
|
||||
for (const kind of ['neighborhood', 'landmark', 'building', 'city'])
|
||||
assert.equal(
|
||||
selectOutlineResult([station], { kind, query: 'Bandra' }).outline,
|
||||
null,
|
||||
kind,
|
||||
);
|
||||
assert.equal(acceptsOutlineClass('landmark', 'highway', 'primary'), false);
|
||||
assert.equal(acceptsOutlineClass('city', 'leisure', 'park'), false);
|
||||
assert.equal(acceptsOutlineClass('landmark', 'office', 'government'), true);
|
||||
assert.equal(acceptsOutlineClass('landmark', 'man_made', 'tower'), true);
|
||||
assert.equal(acceptsOutlineClass('landmark', 'water', 'reservoir'), true);
|
||||
});
|
||||
|
||||
test('the first polygon-bearing result of the right class is taken (Mumbai)', () => {
|
||||
const node = row({
|
||||
category: 'place',
|
||||
type: 'city',
|
||||
name: 'Mumbai',
|
||||
geojson: { type: 'Point', coordinates: [72.8, 19.0] },
|
||||
});
|
||||
const wrong = row({
|
||||
category: 'railway',
|
||||
type: 'station',
|
||||
name: 'Mumbai Central',
|
||||
});
|
||||
const boundary = row({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
addresstype: 'city',
|
||||
name: 'Mumbai',
|
||||
osm_id: 7888990,
|
||||
geojson: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [square(72.8, 19.0, 0.01), square(72.9, 19.1, 0.2)],
|
||||
},
|
||||
});
|
||||
const { outline, skipped } = selectOutlineResult([node, wrong, boundary], {
|
||||
kind: 'city',
|
||||
query: 'Mumbai',
|
||||
});
|
||||
assert.equal(outline.osm, 'relation/7888990');
|
||||
assert.equal(outline.polygons.length, 2);
|
||||
assert.equal(outline.polygons[0][0][1][0], 72.9 + 0.2, 'largest part first');
|
||||
assert.deepEqual(skipped, [
|
||||
'place=city: no polygon',
|
||||
'railway=station: wrong type',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a result whose name does not answer the ask is passed over', () => {
|
||||
const other = row({ category: 'leisure', type: 'park', name: 'Pease Park' });
|
||||
assert.equal(
|
||||
selectOutlineResult([other], { kind: 'landmark', query: 'Zilker Park' })
|
||||
.outline,
|
||||
null,
|
||||
);
|
||||
const english = row({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
addresstype: 'city',
|
||||
name: '東京都',
|
||||
namedetails: { name: '東京都', 'name:en': 'Tokyo' },
|
||||
});
|
||||
assert.ok(
|
||||
selectOutlineResult([english], { kind: 'city', query: 'Tokyo' }).outline,
|
||||
);
|
||||
const capitol = row({
|
||||
category: 'office',
|
||||
type: 'government',
|
||||
name: 'Texas State Capitol',
|
||||
});
|
||||
assert.ok(
|
||||
selectOutlineResult([capitol], { kind: 'landmark', query: 'Texas Capitol' })
|
||||
.outline,
|
||||
);
|
||||
});
|
||||
|
||||
test('outlines are held to a vertex cap', () => {
|
||||
const ring = [];
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
const a = (i / 20_000) * Math.PI * 2;
|
||||
ring.push([Math.cos(a) + Math.sin(a * 40) * 0.001, Math.sin(a)]);
|
||||
}
|
||||
ring.push([...ring[0]]);
|
||||
const capped = capOutlineVertices([[ring]], 6000);
|
||||
const total = capped.flat().reduce((n, r) => n + r.length, 0);
|
||||
assert.ok(total <= 6000, `${total} vertices`);
|
||||
const first = capped[0][0];
|
||||
assert.deepEqual(first[0], first.at(-1), 'rings stay closed');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- the route
|
||||
|
||||
function mount(options) {
|
||||
let handler;
|
||||
geocodeProxy(options).configureServer({
|
||||
middlewares: { use: (_route, fn) => (handler = fn) },
|
||||
});
|
||||
return (url, headers = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const res = {
|
||||
writableEnded: false,
|
||||
on() {},
|
||||
writeHead(status, sent) {
|
||||
res.status = status;
|
||||
res.headers = sent;
|
||||
},
|
||||
end(body) {
|
||||
res.writableEnded = true;
|
||||
resolve({
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body: JSON.parse(body),
|
||||
});
|
||||
},
|
||||
};
|
||||
Promise.resolve(
|
||||
handler(
|
||||
{
|
||||
method: 'GET',
|
||||
url,
|
||||
headers,
|
||||
socket: { remoteAddress: `10.1.${Math.random()}` },
|
||||
on() {},
|
||||
},
|
||||
res,
|
||||
() => resolve({ status: 'next' }),
|
||||
),
|
||||
).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('the outline route answers OK with polygons and validates its input', async (t) => {
|
||||
const time = clock();
|
||||
const { calls, fetchImpl } = stubFetch(async () =>
|
||||
json([
|
||||
row({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
addresstype: 'city',
|
||||
name: 'Paris',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const request = mount({ gate: publicGate(t, { fetchImpl, ...time }) });
|
||||
const ok = await request('/outline?q=Paris&kind=city&lat=48.85&lon=2.35');
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.body.status, 'OK');
|
||||
assert.equal(ok.body.outline.polygons[0][0].length, 5);
|
||||
assert.equal('adminArea' in ok.body.outline, false);
|
||||
assert.equal('adminLevel' in ok.body.outline, false);
|
||||
assert.match(calls[0], /polygon_geojson=1/);
|
||||
assert.match(calls[0], /polygon_threshold=0\.0005/);
|
||||
assert.match(calls[0], /limit=3/);
|
||||
for (const bad of [
|
||||
'/outline?q=Paris',
|
||||
'/outline?kind=city',
|
||||
'/outline?q=Paris&kind=planet',
|
||||
])
|
||||
assert.equal((await request(bad)).status, 400, bad);
|
||||
const cross = await request('/outline?q=Paris&kind=city', {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
});
|
||||
assert.equal(cross.status, 403);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal((await request('/elsewhere')).status, 'next');
|
||||
});
|
||||
|
||||
test('the outline route serializes only selected-row administrative identity', async (t) => {
|
||||
const { fetchImpl } = stubFetch(async (url) => {
|
||||
const query = new URL(url).searchParams.get('q');
|
||||
const fixtures = {
|
||||
'Bagmati Province': { addresstype: 'province' },
|
||||
'Mystery Province': { place_rank: 8 },
|
||||
'Missing Evidence': {},
|
||||
'Northern Region': { addresstype: 'region', place_rank: 8 },
|
||||
'Central District': { addresstype: 'district', place_rank: 10 },
|
||||
'Example State': { addresstype: 'county' },
|
||||
'Bagmati Zone': { addresstype: 'state' },
|
||||
Austin: { addresstype: 'city' },
|
||||
};
|
||||
return json([
|
||||
row({
|
||||
category: 'boundary',
|
||||
type: 'administrative',
|
||||
name: query,
|
||||
...fixtures[query],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
const gate = createNominatimGate({ settings: OPERATOR, fetchImpl });
|
||||
const outline = createNominatimOutlineProvider({
|
||||
gate,
|
||||
cache: createNominatimCache({ dir: tempDir(t) }),
|
||||
});
|
||||
const request = mount({ outline });
|
||||
|
||||
const verified = await request('/outline?q=Bagmati%20Province&kind=admin');
|
||||
assert.equal(verified.status, 200);
|
||||
assert.equal(verified.body.outline.adminArea, 'Bagmati Province');
|
||||
assert.equal(verified.body.outline.adminLevel, 'admin1');
|
||||
|
||||
for (const query of [
|
||||
'Mystery Province',
|
||||
'Northern Region',
|
||||
'Central District',
|
||||
'Example State',
|
||||
]) {
|
||||
const unverified = await request(
|
||||
`/outline?q=${encodeURIComponent(query)}&kind=admin`,
|
||||
);
|
||||
assert.equal(unverified.status, 200, query);
|
||||
assert.equal(unverified.body.status, 'OK', query);
|
||||
assert.equal('adminArea' in unverified.body.outline, false, query);
|
||||
assert.equal('adminLevel' in unverified.body.outline, false, query);
|
||||
}
|
||||
|
||||
const missing = await request('/outline?q=Missing%20Evidence&kind=admin');
|
||||
assert.equal(missing.status, 200);
|
||||
assert.equal(missing.body.status, 'ZERO_RESULTS');
|
||||
assert.equal(missing.body.outline, null);
|
||||
|
||||
const retired = await request('/outline?q=Bagmati%20Zone&kind=admin');
|
||||
assert.equal(retired.status, 200);
|
||||
assert.equal(retired.body.status, 'OK');
|
||||
assert.equal(retired.body.outline.adminArea, 'Bagmati Zone');
|
||||
assert.equal('adminLevel' in retired.body.outline, false);
|
||||
|
||||
const city = await request('/outline?q=Austin&kind=city');
|
||||
assert.equal(city.status, 200);
|
||||
assert.equal(city.body.status, 'OK');
|
||||
assert.equal('adminArea' in city.body.outline, false);
|
||||
assert.equal('adminLevel' in city.body.outline, false);
|
||||
});
|
||||
|
||||
test('the outline route reports disabled and capped as permanent, busy as retryable', async (t) => {
|
||||
const disabled = mount({
|
||||
gate: createNominatimGate({
|
||||
settings: { endpoint: null, isPublic: false, dailyCap: 50 },
|
||||
}),
|
||||
});
|
||||
const off = await disabled('/outline?q=Paris&kind=city');
|
||||
assert.equal(off.status, 503);
|
||||
assert.equal(off.body.code, 'NOMINATIM_DISABLED');
|
||||
assert.equal(off.body.retryable, false);
|
||||
|
||||
const capped = mount({
|
||||
gate: publicGate(t, { settings: { ...PUBLIC, dailyCap: 0 } }),
|
||||
});
|
||||
const cap = await capped('/outline?q=Paris&kind=city');
|
||||
assert.equal(cap.status, 429);
|
||||
assert.equal(cap.body.retryable, false);
|
||||
|
||||
const time = clock();
|
||||
const paused = publicGate(t, {
|
||||
fetchImpl: async () =>
|
||||
new Response('', { status: 429, headers: { 'Retry-After': '90' } }),
|
||||
...time,
|
||||
});
|
||||
const busy = mount({ gate: paused });
|
||||
const refused = await busy('/outline?q=Paris&kind=city');
|
||||
assert.equal(refused.status, 429);
|
||||
assert.equal(refused.headers['Retry-After'], '90');
|
||||
});
|
||||
@@ -9,16 +9,26 @@
|
||||
// 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 { setSharedNominatimGate } from '../../server/providers/regional/nominatimGate.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.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.
|
||||
test.beforeEach(() => setSharedNominatimGate(null));
|
||||
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',
|
||||
@@ -33,13 +43,16 @@ const HIT = [
|
||||
/** Mount the geocode route the way the dev server does. */
|
||||
let clientCounter = 0;
|
||||
|
||||
function mountGeocode(
|
||||
install = (server) => geocodeProxy().configureServer(server),
|
||||
) {
|
||||
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({
|
||||
|
||||
@@ -31,6 +31,13 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
|
||||
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
|
||||
});
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ photonUrl: '' }).define[
|
||||
'import.meta.env.PHOTON_URL'
|
||||
],
|
||||
'""',
|
||||
'an empty PHOTON_URL reaches the browser as "disabled"',
|
||||
);
|
||||
assert.equal(
|
||||
createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server
|
||||
.allowedHosts,
|
||||
|
||||
Reference in new issue
Block a user