mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Both stack parents (#837, #842) are on main, so the voice geometry branch now builds on main directly. Conflict resolutions keep main's behaviour and add the geometry work on top: osm_query gating on every provider, restored admin identity for Nominatim outlines, merged analyst follow-up memory, and the cancelled Globe View reset instruction ahead of the generic cancellation.
1466 lines
44 KiB
JavaScript
1466 lines
44 KiB
JavaScript
// 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,
|
|
createGateStateStore,
|
|
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,
|
|
outlineAt,
|
|
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 ||
|
|
createGateStateStore({ file: path.join(dir, 'state.json') }),
|
|
});
|
|
}
|
|
|
|
function cancellableFlight(answer) {
|
|
let release;
|
|
let sharedSignal;
|
|
let calls = 0;
|
|
let markStarted;
|
|
const started = new Promise((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const gate = {
|
|
settings: () => OPERATOR,
|
|
requestJson(_buildUrl, { signal }) {
|
|
calls += 1;
|
|
sharedSignal = signal;
|
|
markStarted();
|
|
return new Promise((resolve, reject) => {
|
|
release = () => resolve(answer);
|
|
signal.addEventListener(
|
|
'abort',
|
|
() =>
|
|
reject(
|
|
Object.assign(new Error('abandoned'), {
|
|
code: 'NOMINATIM_ABANDONED',
|
|
}),
|
|
),
|
|
{ once: true },
|
|
);
|
|
});
|
|
},
|
|
};
|
|
return {
|
|
gate,
|
|
started,
|
|
release: () => release(),
|
|
signal: () => sharedSignal,
|
|
calls: () => calls,
|
|
};
|
|
}
|
|
|
|
const emptyCache = () => ({
|
|
get: async () => null,
|
|
set: async () => {},
|
|
});
|
|
|
|
// ---------------------------------------------------------------- settings
|
|
|
|
test('endpoint settings: unset and empty disable, operator URLs are explicit', () => {
|
|
assert.deepEqual(resolveNominatimSettings({}), {
|
|
endpoint: null,
|
|
isPublic: false,
|
|
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_URL: 'http://nominatim.openstreetmap.org/search',
|
|
}).endpoint,
|
|
null,
|
|
'the public service is never contacted over cleartext',
|
|
);
|
|
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('one cancelled subscriber does not abort a shared search', async () => {
|
|
const flight = cancellableFlight([
|
|
{
|
|
lat: '48.8566',
|
|
lon: '2.3522',
|
|
display_name: 'Paris, France',
|
|
name: 'Paris',
|
|
addresstype: 'city',
|
|
boundingbox: ['48.80', '48.90', '2.25', '2.45'],
|
|
},
|
|
]);
|
|
const search = createNominatimSearchProvider({
|
|
gate: flight.gate,
|
|
cache: emptyCache(),
|
|
});
|
|
const cancelled = new AbortController();
|
|
const first = search('Paris', null, { signal: cancelled.signal });
|
|
const second = search('Paris');
|
|
await flight.started;
|
|
cancelled.abort();
|
|
await assert.rejects(first, (error) => error.code === 'NOMINATIM_ABANDONED');
|
|
assert.equal(flight.signal().aborted, false);
|
|
flight.release();
|
|
assert.equal((await second).status, 'OK');
|
|
assert.equal(flight.calls(), 1);
|
|
});
|
|
|
|
test('one cancelled subscriber does not abort a shared outline', async () => {
|
|
const flight = cancellableFlight([
|
|
row({
|
|
category: 'boundary',
|
|
type: 'administrative',
|
|
addresstype: 'city',
|
|
name: 'Paris',
|
|
}),
|
|
]);
|
|
const outline = createNominatimOutlineProvider({
|
|
gate: flight.gate,
|
|
cache: emptyCache(),
|
|
});
|
|
const cancelled = new AbortController();
|
|
const ask = { query: 'Paris', kind: 'city', lat: 48.85, lon: 2.35 };
|
|
const first = outline(ask, { signal: cancelled.signal });
|
|
const second = outline(ask);
|
|
await flight.started;
|
|
cancelled.abort();
|
|
await assert.rejects(first, (error) => error.code === 'NOMINATIM_ABANDONED');
|
|
assert.equal(flight.signal().aborted, false);
|
|
flight.release();
|
|
assert.equal((await second).status, 'OK');
|
|
assert.equal(flight.calls(), 1);
|
|
});
|
|
|
|
test('the last cancelled subscriber aborts the shared upstream request', async () => {
|
|
const flight = cancellableFlight([
|
|
{
|
|
lat: '48.8566',
|
|
lon: '2.3522',
|
|
display_name: 'Paris, France',
|
|
name: 'Paris',
|
|
addresstype: 'city',
|
|
boundingbox: ['48.80', '48.90', '2.25', '2.45'],
|
|
},
|
|
]);
|
|
const search = createNominatimSearchProvider({
|
|
gate: flight.gate,
|
|
cache: emptyCache(),
|
|
});
|
|
const firstController = new AbortController();
|
|
const secondController = new AbortController();
|
|
const first = search('Paris', null, { signal: firstController.signal });
|
|
const second = search('Paris', null, { signal: secondController.signal });
|
|
const firstRejected = assert.rejects(
|
|
first,
|
|
(error) => error.code === 'NOMINATIM_ABANDONED',
|
|
);
|
|
const secondRejected = assert.rejects(
|
|
second,
|
|
(error) => error.code === 'NOMINATIM_ABANDONED',
|
|
);
|
|
await flight.started;
|
|
firstController.abort();
|
|
await firstRejected;
|
|
assert.equal(flight.signal().aborted, false);
|
|
secondController.abort();
|
|
await secondRejected;
|
|
assert.equal(flight.signal().aborted, true);
|
|
assert.equal(flight.calls(), 1);
|
|
});
|
|
|
|
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: createGateStateStore({ 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: createGateStateStore({ 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: createGateStateStore({ 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: createGateStateStore({
|
|
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: createGateStateStore({ 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'],
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- 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,
|
|
);
|
|
assert.equal(
|
|
fs.statSync(dir).mode & 0o777,
|
|
0o700,
|
|
'the query cache directory is private',
|
|
);
|
|
const text = fs
|
|
.readdirSync(dir)
|
|
.filter((name) => name.endsWith('.json'))
|
|
.map((name) => fs.readFileSync(path.join(dir, name), 'utf8'))
|
|
.join('\n');
|
|
assert.equal(text.includes('k4'), false, 'disk entries never store raw keys');
|
|
for (const name of fs
|
|
.readdirSync(dir)
|
|
.filter((entry) => entry.endsWith('.json')))
|
|
assert.equal(
|
|
fs.statSync(path.join(dir, name)).mode & 0o777,
|
|
0o600,
|
|
`${name} is private`,
|
|
);
|
|
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');
|
|
// One key with the bundled place packs and tile street matching.
|
|
assert.equal(
|
|
normalizePlaceName('Saint Paul'),
|
|
normalizePlaceName('St. Paul'),
|
|
);
|
|
});
|
|
|
|
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',
|
|
]);
|
|
|
|
const withGeometry = (geojson) =>
|
|
selectOutlineResult([{ ...boundary, geojson }], {
|
|
kind: 'city',
|
|
query: 'Mumbai',
|
|
}).outline;
|
|
const parts = Array.from({ length: 24 }, (_, i) => square(i, 0));
|
|
assert.equal(
|
|
withGeometry({ type: 'MultiPolygon', coordinates: parts }).polygons.length,
|
|
24,
|
|
'all supported parts survive',
|
|
);
|
|
const holes = Array.from(
|
|
{ length: 16 },
|
|
(_, i) => square(0.1 + i * 0.002, 0.1)[0],
|
|
);
|
|
assert.equal(
|
|
withGeometry({
|
|
type: 'Polygon',
|
|
coordinates: [square(0, 0, 1)[0], ...holes],
|
|
}).polygons[0].length,
|
|
17,
|
|
'all supported holes survive',
|
|
);
|
|
for (const coordinates of [
|
|
[...parts, square(25, 0)],
|
|
[[square(0, 0, 1)[0], ...holes, square(0.04, 0)[0]]],
|
|
[
|
|
[
|
|
square(0, 0, 1)[0],
|
|
[
|
|
[0, 0],
|
|
[0, 1],
|
|
[NaN, 1],
|
|
[0, 0],
|
|
],
|
|
],
|
|
],
|
|
[square(0, 0), []],
|
|
]) {
|
|
assert.equal(
|
|
withGeometry({ type: 'MultiPolygon', coordinates }),
|
|
null,
|
|
'unsupported or malformed topology is refused whole, never truncated',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a result whose name does not answer the ask is passed over', () => {
|
|
const city = (name, namedetails) =>
|
|
row({
|
|
category: 'place',
|
|
type: 'city',
|
|
addresstype: 'city',
|
|
name,
|
|
namedetails,
|
|
});
|
|
const york = { kind: 'city', query: 'York' };
|
|
assert.equal(selectOutlineResult([city('New York')], york).outline, null);
|
|
assert.ok(selectOutlineResult([city('York')], york).outline);
|
|
assert.equal(
|
|
selectOutlineResult([city('Le Havre')], { kind: 'city', query: 'Havre' })
|
|
.outline,
|
|
null,
|
|
);
|
|
assert.ok(
|
|
selectOutlineResult([city('Le Havre')], { kind: 'city', query: 'Le Havre' })
|
|
.outline,
|
|
);
|
|
assert.ok(
|
|
selectOutlineResult([city('City of York', { short_name: 'York' })], york)
|
|
.outline,
|
|
);
|
|
// A name tag holding several names answers for each of them, not the joint text.
|
|
assert.ok(
|
|
selectOutlineResult([city('Eboracum', { alt_name: 'Jorvik;York' })], york)
|
|
.outline,
|
|
);
|
|
assert.equal(
|
|
selectOutlineResult(
|
|
[city('Eboracum', { alt_name: 'Jorvik;New York' })],
|
|
york,
|
|
).outline,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
selectOutlineResult([city('新東京')], { kind: 'city', query: '東京' })
|
|
.outline,
|
|
null,
|
|
);
|
|
const tokyo = { kind: 'city', query: '東京' };
|
|
assert.equal(selectOutlineResult([city('東京都')], tokyo).outline, null);
|
|
assert.ok(
|
|
selectOutlineResult([city('東京都', { short_name: '東京' })], tokyo)
|
|
.outline,
|
|
);
|
|
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');
|
|
|
|
const outer = square(0, 0, 1)[0];
|
|
const hole = square(0.1, 0.1, 0.1)[0];
|
|
assert.equal(
|
|
capOutlineVertices([[outer, hole]], 9),
|
|
null,
|
|
'a cap cannot discard a hole to make an exact outline fit',
|
|
);
|
|
assert.equal(
|
|
capOutlineVertices([[outer], [hole]], 9),
|
|
null,
|
|
'a cap cannot discard a part to make an exact outline fit',
|
|
);
|
|
|
|
const validHole = square(-0.1, -0.1, 0.2)[0];
|
|
const largeWithHole = capOutlineVertices([[ring, validHole]]);
|
|
assert.ok(largeWithHole, 'valid large outlines with holes still simplify');
|
|
assert.equal(largeWithHole[0].length, 2);
|
|
assert.ok(largeWithHole.flat().reduce((n, r) => n + r.length, 0) <= 6000);
|
|
const bay = [
|
|
[0, 0],
|
|
[0.001, 0],
|
|
[0.001, 0.0004],
|
|
[0.00101, 0.0004],
|
|
[0.00101, 0.0006],
|
|
[0.001, 0.0006],
|
|
[0.001, 0.001],
|
|
[0, 0.001],
|
|
[0, 0],
|
|
];
|
|
const bayHole = [
|
|
[0.001003, 0.00045],
|
|
[0.001007, 0.00045],
|
|
[0.001007, 0.00055],
|
|
[0.001003, 0.00055],
|
|
[0.001003, 0.00045],
|
|
];
|
|
const denseBay = bay.slice(0, -1).flatMap((point, i) => {
|
|
const end = bay[i + 1];
|
|
const steps = i === 0 || i === 6 ? 3001 : 1;
|
|
return Array.from({ length: steps }, (_, j) => [
|
|
point[0] + ((end[0] - point[0]) * j) / steps,
|
|
point[1] + ((end[1] - point[1]) * j) / steps,
|
|
]);
|
|
});
|
|
denseBay.push(bay[0]);
|
|
assert.ok(denseBay.length + bayHole.length > 6000);
|
|
assert.equal(
|
|
capOutlineVertices([[denseBay, bayHole]]),
|
|
null,
|
|
'default-cap simplification cannot leave the preserved hole outside its shell',
|
|
);
|
|
const concave = [
|
|
[0, 0],
|
|
[0.001, 0],
|
|
[0.001, 0.0005],
|
|
[0.0005, 0.0005],
|
|
[0.0005, 0.001],
|
|
[0, 0.001],
|
|
[0, 0],
|
|
];
|
|
const bentHole = [
|
|
[0.0001, 0.0001],
|
|
[0.0003, 0.0001],
|
|
[0.0005, 0.0001],
|
|
[0.0009, 0.0001],
|
|
[0.0009, 0.00049],
|
|
[0.00049, 0.00049],
|
|
[0.00049, 0.0009],
|
|
[0.0001, 0.0009],
|
|
[0.0001, 0.0001],
|
|
];
|
|
assert.equal(
|
|
capOutlineVertices([[concave, bentHole]], 11),
|
|
null,
|
|
'hole edges cannot cross a concave shell even when every output vertex is inside',
|
|
);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- 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() {},
|
|
setHeader(name, value) {
|
|
res.headers ||= {};
|
|
res.headers[name] = value;
|
|
},
|
|
writeHead(status, sent) {
|
|
res.status = status;
|
|
res.headers = sent;
|
|
},
|
|
end(body) {
|
|
res.writableEnded = true;
|
|
resolve({
|
|
status: res.status ?? res.statusCode,
|
|
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/);
|
|
// The resolved anchor picks among cached same-name results.
|
|
const anchored = await request(
|
|
'/outline?q=Paris&kind=city&lat=48.85&lon=2.35&alat=10&alon=10',
|
|
);
|
|
assert.equal(
|
|
anchored.body.outline.osm,
|
|
ok.body.outline.osm,
|
|
'else the first',
|
|
);
|
|
assert.equal(calls.length, 1, 'the anchor is not part of the upstream ask');
|
|
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);
|
|
for (const headers of [
|
|
{
|
|
host: 'localhost',
|
|
origin: 'https://foreign.example',
|
|
'sec-fetch-site': 'same-origin',
|
|
},
|
|
{ host: 'localhost', origin: 'null', 'sec-fetch-site': 'same-origin' },
|
|
{ host: 'localhost', 'sec-fetch-site': 'same-site' },
|
|
{ host: 'localhost', 'x-forwarded-for': '203.0.113.7' },
|
|
]) {
|
|
assert.equal(
|
|
(await request('/outline?q=Paris&kind=city', headers)).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 },
|
|
'Unknown Type': { addresstype: 'unrecognized', place_rank: 8 },
|
|
'Coerced Rank': { addresstype: 'state', place_rank: [8] },
|
|
'String Rank': { addresstype: 'state', place_rank: '8' },
|
|
'Contradicted State': { addresstype: 'state', place_rank: 20 },
|
|
'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' },
|
|
'Explicit Place City': {
|
|
category: 'place',
|
|
type: 'city',
|
|
place_rank: 16,
|
|
},
|
|
'Contradicted Place City': {
|
|
category: 'place',
|
|
type: 'city',
|
|
addresstype: 'suburb',
|
|
place_rank: 22,
|
|
},
|
|
'Malformed Place Rank': {
|
|
category: 'place',
|
|
type: 'city',
|
|
addresstype: 'city',
|
|
place_rank: '16',
|
|
},
|
|
'Accepted City Block': {
|
|
category: 'place',
|
|
type: 'city_block',
|
|
addresstype: 'city_block',
|
|
place_rank: 25,
|
|
},
|
|
};
|
|
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 [
|
|
'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);
|
|
}
|
|
|
|
for (const query of [
|
|
'Missing Evidence',
|
|
'Mystery Province',
|
|
'Unknown Type',
|
|
'Coerced Rank',
|
|
'String Rank',
|
|
'Contradicted State',
|
|
]) {
|
|
const missing = await request(
|
|
`/outline?q=${encodeURIComponent(query)}&kind=admin`,
|
|
);
|
|
assert.equal(missing.status, 200, query);
|
|
assert.equal(missing.body.status, 'ZERO_RESULTS', query);
|
|
assert.equal(missing.body.outline, null, query);
|
|
}
|
|
|
|
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);
|
|
for (const [query, kind, status] of [
|
|
['Explicit Place City', 'city', 'OK'],
|
|
['Contradicted Place City', 'city', 'ZERO_RESULTS'],
|
|
['Malformed Place Rank', 'city', 'ZERO_RESULTS'],
|
|
['Accepted City Block', 'neighborhood', 'OK'],
|
|
]) {
|
|
const answer = await request(
|
|
`/outline?q=${encodeURIComponent(query)}&kind=${kind}`,
|
|
);
|
|
assert.equal(answer.status, 200, query);
|
|
assert.equal(answer.body.status, status, query);
|
|
}
|
|
});
|
|
|
|
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);
|
|
// The capability probe answers 200, so a client can skip the refused lookup.
|
|
const probe = await disabled('/status');
|
|
assert.equal(probe.status, 200);
|
|
assert.deepEqual(probe.body, { configured: false });
|
|
|
|
const capped = mount({
|
|
gate: publicGate(t, { settings: { ...PUBLIC, dailyCap: 0 } }),
|
|
});
|
|
assert.deepEqual((await capped('/status')).body, { configured: true });
|
|
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');
|
|
});
|
|
|
|
test('a landmark ask is not answered by the place around it', () => {
|
|
const park = (name) =>
|
|
row({ category: 'leisure', type: 'park', name, display_name: name });
|
|
const landmark = (query) => ({ kind: 'landmark', query });
|
|
for (const query of [
|
|
'Central Park Zoo',
|
|
'Washington Square Arch',
|
|
'Lincoln Park Conservatory',
|
|
])
|
|
assert.equal(
|
|
selectOutlineResult(
|
|
[park(query.split(' ').slice(0, -1).join(' '))],
|
|
landmark(query),
|
|
).outline,
|
|
null,
|
|
query,
|
|
);
|
|
// The last word may say where the result is, or what it is.
|
|
const at = (name, display_name, overrides = {}) =>
|
|
row({
|
|
category: 'tourism',
|
|
type: 'attraction',
|
|
name,
|
|
display_name,
|
|
...overrides,
|
|
});
|
|
for (const [query, result] of [
|
|
[
|
|
'Space Needle Seattle',
|
|
at('Space Needle', 'Space Needle, Seattle, King County, Washington'),
|
|
],
|
|
[
|
|
'Golden Gate Bridge San Francisco',
|
|
at('Golden Gate Bridge', 'Golden Gate Bridge, San Francisco, California'),
|
|
],
|
|
[
|
|
'Notre Dame Cathedral',
|
|
at('Notre-Dame de Paris', 'Notre-Dame de Paris, Paris, France', {
|
|
category: 'building',
|
|
type: 'cathedral',
|
|
}),
|
|
],
|
|
])
|
|
assert.ok(selectOutlineResult([result], landmark(query)).outline, query);
|
|
// An exact name, then one holding every asked word, outranks earlier
|
|
// partial matches.
|
|
const exact = selectOutlineResult(
|
|
[park('Zilker Metropolitan Park'), park('Zilker Park')],
|
|
landmark('Zilker Park'),
|
|
);
|
|
assert.deepEqual(
|
|
exact.outlines.map((outline) => outline.name),
|
|
['Zilker Park', 'Zilker Metropolitan Park'],
|
|
);
|
|
const every = selectOutlineResult(
|
|
[park('Royal Park'), park('Royal Botanic Historic Park')],
|
|
landmark('Royal Botanic Park'),
|
|
);
|
|
assert.equal(every.outline.name, 'Royal Botanic Historic Park');
|
|
// A building word passes on a building-like result, not on a park.
|
|
for (const [query, result] of [
|
|
[
|
|
'Burj Khalifa Tower',
|
|
at('Burj Khalifa', 'Burj Khalifa, Downtown Dubai, Dubai', {
|
|
category: 'building',
|
|
type: 'yes',
|
|
}),
|
|
],
|
|
[
|
|
'Sagrada Familia Church',
|
|
at('Basílica de la Sagrada Família', 'Basílica, Barcelona, Spain', {
|
|
category: 'amenity',
|
|
type: 'place_of_worship',
|
|
}),
|
|
],
|
|
])
|
|
assert.ok(selectOutlineResult([result], landmark(query)).outline, query);
|
|
assert.equal(
|
|
selectOutlineResult([park('Central Park')], landmark('Central Park Tower'))
|
|
.outline,
|
|
null,
|
|
);
|
|
// Nor on a campus or district whose name merely starts the ask.
|
|
for (const [query, name, category, type] of [
|
|
['Duke University Chapel', 'Duke University', 'amenity', 'university'],
|
|
['Old Town Hall', 'Old Town', 'historic', 'district'],
|
|
])
|
|
assert.equal(
|
|
selectOutlineResult(
|
|
[at(name, `${name}, Somewhere`, { category, type })],
|
|
landmark(query),
|
|
).outline,
|
|
null,
|
|
query,
|
|
);
|
|
});
|
|
|
|
test('a worse name never wins the anchor by containment alone', () => {
|
|
const building = {
|
|
osm: 'way/1',
|
|
polygons: [square(0, 0, 0.0001)],
|
|
};
|
|
const plaza = { osm: 'way/2', polygons: [square(-0.001, -0.001, 0.003)] };
|
|
const anchor = [0.0002, 0.0002];
|
|
assert.equal(outlineAt([building, plaza], ...anchor, [3, 2]).osm, 'way/1');
|
|
assert.equal(outlineAt([building, plaza], ...anchor, [3, 3]).osm, 'way/2');
|
|
});
|
|
|
|
test('outlineAt reads a ring crossing the antimeridian in its own frame', () => {
|
|
const band = {
|
|
osm: 'way/1',
|
|
polygons: [
|
|
[
|
|
[
|
|
[170, -17],
|
|
[175, -17],
|
|
[175, -16],
|
|
[170, -16],
|
|
[170, -17],
|
|
],
|
|
],
|
|
],
|
|
};
|
|
const fiji = {
|
|
osm: 'way/2',
|
|
polygons: [
|
|
[
|
|
[
|
|
[179.9, -17],
|
|
[-179.9, -17],
|
|
[-179.9, -16],
|
|
[179.9, -16],
|
|
[179.9, -17],
|
|
],
|
|
],
|
|
],
|
|
};
|
|
assert.equal(outlineAt([band, fiji], -16.5, 179.995).osm, 'way/2');
|
|
assert.equal(outlineAt([band, fiji], -16.5, 172).osm, 'way/1');
|
|
assert.equal(
|
|
outlineAt([fiji, band], -16.5, 0).osm,
|
|
'way/2',
|
|
'else the first',
|
|
);
|
|
});
|
|
|
|
test('a same-name outline answers the ask whose anchor it holds, cached or not', async (t) => {
|
|
const london = (lon, lat, osm_id) =>
|
|
row({
|
|
category: 'place',
|
|
type: 'city',
|
|
addresstype: 'city',
|
|
name: 'London',
|
|
osm_id,
|
|
geojson: { type: 'Polygon', coordinates: square(lon, lat, 0.2) },
|
|
});
|
|
const { calls, fetchImpl } = stubFetch(async () =>
|
|
json([london(-0.2, 51.4, 1), london(-81.35, 42.9, 2)]),
|
|
);
|
|
const gate = publicGate(t, { fetchImpl, ...clock() });
|
|
const outline = createNominatimOutlineProvider({ gate });
|
|
const ask = { query: 'London', kind: 'city', lat: 43, lon: -81 };
|
|
const ontario = await outline({
|
|
...ask,
|
|
anchorLat: 42.98,
|
|
anchorLon: -81.25,
|
|
});
|
|
assert.equal(ontario.outline.osm, 'relation/2');
|
|
const england = await outline({ ...ask, anchorLat: 51.5, anchorLon: -0.12 });
|
|
assert.equal(england.outline.osm, 'relation/1');
|
|
assert.equal(england.cached, true);
|
|
const unanchored = await outline(ask);
|
|
assert.equal(unanchored.outline.osm, 'relation/1', 'first without an anchor');
|
|
assert.equal(calls.length, 1, 'one upstream request for every anchor');
|
|
});
|