1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2025 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Tests for the Python web frameworks adaptor, v1 API.
11 import xml.etree.ElementTree as ET
15 from fake_adaptor import FakeAdaptor, FakeError, FakeResponse
17 import nominatim_api.v1.server_glue as glue
18 import nominatim_api as napi
19 import nominatim_api.logging as loglib
20 from nominatim_api.config import Configuration
23 def debug_config(enabled):
24 """ Return a configuration where the HTML debug output is explicitly
27 return Configuration(None,
28 environ={'NOMINATIM_SERVE_DEBUG_OUTPUT':
29 'yes' if enabled else 'no'})
32 # ASGIAdaptor.get_int/bool()
34 @pytest.mark.parametrize('func', ['get_int', 'get_bool'])
35 def test_adaptor_get_int_missing_but_required(func):
36 with pytest.raises(FakeError, match='^400 -- .*missing'):
37 getattr(FakeAdaptor(), func)('something')
40 @pytest.mark.parametrize('func, val', [('get_int', 23), ('get_bool', True)])
41 def test_adaptor_get_int_missing_with_default(func, val):
42 assert getattr(FakeAdaptor(), func)('something', val) == val
45 @pytest.mark.parametrize('inp', ['0', '234', '-4566953498567934876'])
46 def test_adaptor_get_int_success(inp):
47 assert FakeAdaptor(params={'foo': inp}).get_int('foo') == int(inp)
48 assert FakeAdaptor(params={'foo': inp}).get_int('foo', 4) == int(inp)
51 @pytest.mark.parametrize('inp', ['rs', '4.5', '6f'])
52 def test_adaptor_get_int_bad_number(inp):
53 with pytest.raises(FakeError, match='^400 -- .*must be a number'):
54 FakeAdaptor(params={'foo': inp}).get_int('foo')
57 @pytest.mark.parametrize('inp', ['1', 'true', 'whatever', 'false'])
58 def test_adaptor_get_bool_trueish(inp):
59 assert FakeAdaptor(params={'foo': inp}).get_bool('foo')
62 def test_adaptor_get_bool_falsish():
63 assert not FakeAdaptor(params={'foo': '0'}).get_bool('foo')
66 # ASGIAdaptor.parse_format()
68 def test_adaptor_parse_format_use_default():
69 adaptor = FakeAdaptor()
71 assert glue.parse_format(adaptor, napi.StatusResult, 'text') == 'text'
72 assert adaptor.content_type == 'text/plain; charset=utf-8'
75 def test_adaptor_parse_format_use_configured():
76 adaptor = FakeAdaptor(params={'format': 'json'})
78 assert glue.parse_format(adaptor, napi.StatusResult, 'text') == 'json'
79 assert adaptor.content_type == 'application/json; charset=utf-8'
82 def test_adaptor_parse_format_invalid_value():
83 adaptor = FakeAdaptor(params={'format': '@!#'})
85 with pytest.raises(FakeError, match='^400 -- .*must be one of'):
86 glue.parse_format(adaptor, napi.StatusResult, 'text')
89 # ASGIAdaptor.get_accepted_languages()
91 def test_accepted_languages_from_param():
92 a = FakeAdaptor(params={'accept-language': 'de'})
93 assert glue.get_accepted_languages(a) == 'de'
96 def test_accepted_languages_from_header():
97 a = FakeAdaptor(headers={'accept-language': 'de'})
98 assert glue.get_accepted_languages(a) == 'de'
101 def test_accepted_languages_from_default(monkeypatch):
102 monkeypatch.setenv('NOMINATIM_DEFAULT_LANGUAGE', 'de')
104 assert glue.get_accepted_languages(a) == 'de'
107 def test_accepted_languages_param_over_header():
108 a = FakeAdaptor(params={'accept-language': 'de'},
109 headers={'accept-language': 'en'})
110 assert glue.get_accepted_languages(a) == 'de'
113 def test_accepted_languages_header_over_default(monkeypatch):
114 monkeypatch.setenv('NOMINATIM_DEFAULT_LANGUAGE', 'en')
115 a = FakeAdaptor(headers={'accept-language': 'de'})
116 assert glue.get_accepted_languages(a) == 'de'
119 # NOMINATIM_SERVE_DEBUG_OUTPUT enables debug=1
121 @pytest.mark.parametrize('environ', [{}, {'NOMINATIM_SERVE_DEBUG_OUTPUT': 'no'}])
122 def test_setup_debugging_rejected_when_disabled(environ):
123 a = FakeAdaptor(params={'debug': '1'},
124 config=Configuration(None, environ=environ))
126 with pytest.raises(FakeError, match='^400 -- .*not enabled'):
127 glue.setup_debugging(a)
130 def test_setup_debugging_enabled():
131 a = FakeAdaptor(params={'debug': '1'}, config=debug_config(True))
133 assert glue.setup_debugging(a)
134 assert a.content_type == 'text/html; charset=utf-8'
137 @pytest.mark.parametrize('params', [{}, {'debug': '0'}])
138 def test_setup_debugging_not_requested(params):
139 a = FakeAdaptor(params=params, config=debug_config(True))
141 assert not glue.setup_debugging(a)
142 assert a.content_type == 'text/plain; charset=utf-8'
145 @pytest.mark.parametrize('content_type', ['application/json; charset=utf-8',
146 'text/xml; charset=utf-8'])
147 def test_setup_debugging_rejection_keeps_output_format(content_type):
148 a = FakeAdaptor(params={'debug': '1'}, config=debug_config(False))
149 a.content_type = content_type
151 with pytest.raises(FakeError, match='(?s)^400 -- .*not enabled'):
152 glue.setup_debugging(a)
155 # ASGIAdaptor.raise_error()
157 class TestAdaptorRaiseError:
159 @pytest.fixture(autouse=True)
160 def init_adaptor(self):
161 self.adaptor = FakeAdaptor()
162 glue.setup_debugging(self.adaptor)
164 def run_raise_error(self, msg, status):
165 with pytest.raises(FakeError) as excinfo:
166 self.adaptor.raise_error(msg, status=status)
170 def test_without_content_set(self):
171 err = self.run_raise_error('TEST', 404)
173 assert self.adaptor.content_type == 'text/plain; charset=utf-8'
174 assert err.msg == 'ERROR 404: TEST'
175 assert err.status == 404
178 self.adaptor.content_type = 'application/json; charset=utf-8'
180 err = self.run_raise_error('TEST', 501)
182 content = json.loads(err.msg)['error']
183 assert content['code'] == 501
184 assert content['message'] == 'TEST'
187 self.adaptor.content_type = 'text/xml; charset=utf-8'
189 err = self.run_raise_error('this!', 503)
191 content = ET.fromstring(err.msg)
193 assert content.tag == 'error'
194 assert content.find('code').text == '503'
195 assert content.find('message').text == 'this!'
198 def test_raise_error_during_debug():
199 a = FakeAdaptor(params={'debug': '1'}, config=debug_config(True))
200 glue.setup_debugging(a)
201 loglib.log().section('Ongoing')
203 with pytest.raises(FakeError) as excinfo:
204 a.raise_error('badstate')
206 content = ET.fromstring(excinfo.value.msg)
208 assert content.tag == 'html'
210 assert '>Ongoing<' in excinfo.value.msg
211 assert 'badstate' in excinfo.value.msg
214 # ASGIAdaptor.build_response
216 def test_build_response_without_content_type():
217 resp = glue.build_response(FakeAdaptor(), 'attention')
219 assert isinstance(resp, FakeResponse)
220 assert resp.status == 200
221 assert resp.output == 'attention'
222 assert resp.content_type == 'text/plain; charset=utf-8'
225 def test_build_response_with_status():
226 a = FakeAdaptor(params={'format': 'json'})
227 glue.parse_format(a, napi.StatusResult, 'text')
229 resp = glue.build_response(a, 'stuff\nmore stuff', status=404)
231 assert isinstance(resp, FakeResponse)
232 assert resp.status == 404
233 assert resp.output == 'stuff\nmore stuff'
234 assert resp.content_type == 'application/json; charset=utf-8'
237 def test_build_response_jsonp_with_json():
238 a = FakeAdaptor(params={'format': 'json', 'json_callback': 'test.func'})
239 glue.parse_format(a, napi.StatusResult, 'text')
241 resp = glue.build_response(a, '{}')
243 assert isinstance(resp, FakeResponse)
244 assert resp.status == 200
245 assert resp.output == 'test.func({})'
246 assert resp.content_type == 'application/javascript; charset=utf-8'
249 def test_build_response_jsonp_without_json():
250 a = FakeAdaptor(params={'format': 'text', 'json_callback': 'test.func'})
251 glue.parse_format(a, napi.StatusResult, 'text')
253 resp = glue.build_response(a, '{}')
255 assert isinstance(resp, FakeResponse)
256 assert resp.status == 200
257 assert resp.output == '{}'
258 assert resp.content_type == 'text/plain; charset=utf-8'
261 @pytest.mark.parametrize('param', ['alert(); func', '\\n', '', 'a b'])
262 def test_build_response_jsonp_bad_format(param):
263 a = FakeAdaptor(params={'format': 'json', 'json_callback': param})
264 glue.parse_format(a, napi.StatusResult, 'text')
266 with pytest.raises(FakeError, match='^400 -- .*Invalid'):
267 glue.build_response(a, '{}')
272 class TestStatusEndpoint:
274 @pytest.fixture(autouse=True)
275 def patch_status_func(self, monkeypatch):
276 async def _status(*args, **kwargs):
279 monkeypatch.setattr(napi.NominatimAPIAsync, 'status', _status)
282 async def test_status_without_params(self):
284 self.status = napi.StatusResult(0, 'foo')
286 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
288 assert isinstance(resp, FakeResponse)
289 assert resp.status == 200
290 assert resp.content_type == 'text/plain; charset=utf-8'
293 async def test_status_with_error(self):
295 self.status = napi.StatusResult(405, 'foo')
297 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
299 assert isinstance(resp, FakeResponse)
300 assert resp.status == 500
301 assert resp.content_type == 'text/plain; charset=utf-8'
304 async def test_status_json_with_error(self):
305 a = FakeAdaptor(params={'format': 'json'})
306 self.status = napi.StatusResult(405, 'foo')
308 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
310 assert isinstance(resp, FakeResponse)
311 assert resp.status == 200
312 assert resp.content_type == 'application/json; charset=utf-8'
315 async def test_status_bad_format(self):
316 a = FakeAdaptor(params={'format': 'foo'})
317 self.status = napi.StatusResult(0, 'foo')
319 with pytest.raises(FakeError):
320 await glue.status_endpoint(napi.NominatimAPIAsync(), a)
325 class TestDetailsEndpoint:
327 @pytest.fixture(autouse=True)
328 def patch_lookup_func(self, monkeypatch):
329 self.result = napi.DetailedResult(napi.SourceTable.PLACEX,
331 napi.Point(1.0, 2.0))
332 self.lookup_args = []
334 async def _lookup(*args, **kwargs):
335 self.lookup_args.extend(args[1:])
338 monkeypatch.setattr(napi.NominatimAPIAsync, 'details', _lookup)
341 async def test_details_no_params(self):
344 with pytest.raises(FakeError, match='^400 -- .*Missing'):
345 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
348 async def test_details_by_place_id(self):
349 a = FakeAdaptor(params={'place_id': '4573'})
351 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
353 assert self.lookup_args[0].place_id == 4573
356 async def test_details_by_osm_id(self):
357 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45'})
359 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
361 assert self.lookup_args[0].osm_type == 'N'
362 assert self.lookup_args[0].osm_id == 45
363 assert self.lookup_args[0].osm_class is None
366 async def test_details_by_postcode(self):
367 a = FakeAdaptor(params={'postcode': 'us:94110'})
369 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
371 assert self.lookup_args[0].country_code == 'us'
372 assert self.lookup_args[0].postcode == '94110'
375 async def test_details_by_postcode_id(self):
376 a = FakeAdaptor(params={'postcode': 'Pus:94110'})
378 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
380 assert self.lookup_args[0].country_code == 'us'
381 assert self.lookup_args[0].postcode == '94110'
384 async def test_details_with_debugging(self):
385 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45', 'debug': '1'},
386 config=debug_config(True))
388 resp = await glue.details_endpoint(napi.NominatimAPIAsync(), a)
389 content = ET.fromstring(resp.output)
391 assert resp.content_type == 'text/html; charset=utf-8'
392 assert content.tag == 'html'
395 async def test_details_no_result(self):
396 a = FakeAdaptor(params={'place_id': '4573'})
399 with pytest.raises(FakeError, match='^404 -- .*found'):
400 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
404 class TestReverseEndPoint:
406 @pytest.fixture(autouse=True)
407 def patch_reverse_func(self, monkeypatch):
408 self.result = napi.ReverseResult(napi.SourceTable.PLACEX,
410 napi.Point(1.0, 2.0))
412 async def _reverse(*args, **kwargs):
415 monkeypatch.setattr(napi.NominatimAPIAsync, 'reverse', _reverse)
418 @pytest.mark.parametrize('params', [{}, {'lat': '3.4'}, {'lon': '6.7'}])
419 async def test_reverse_no_params(self, params):
422 a.params['format'] = 'xml'
424 with pytest.raises(FakeError, match='^400 -- (?s:.*)missing'):
425 await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
428 async def test_reverse_success(self):
430 a.params['lat'] = '56.3'
431 a.params['lon'] = '6.8'
433 assert await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
436 async def test_reverse_from_search(self):
438 a.params['q'] = '34.6 2.56'
439 a.params['format'] = 'json'
441 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
443 assert len(json.loads(res.output)) == 1
448 class TestLookupEndpoint:
450 @pytest.fixture(autouse=True)
451 def patch_lookup_func(self, monkeypatch):
452 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
454 napi.Point(1.0, 2.0))]
456 async def _lookup(*args, **kwargs):
457 return napi.SearchResults(self.results)
459 monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup)
462 async def test_lookup_no_params(self):
464 a.params['format'] = 'json'
466 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
468 assert res.output == '[]'
471 @pytest.mark.parametrize('param', ['w', 'bad', ''])
472 async def test_lookup_bad_params(self, param):
474 a.params['format'] = 'json'
475 a.params['osm_ids'] = f'W34,{param},N33333'
477 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
479 assert len(json.loads(res.output)) == 1
482 @pytest.mark.parametrize('param', ['p234234', '4563'])
483 async def test_lookup_bad_osm_type(self, param):
485 a.params['format'] = 'json'
486 a.params['osm_ids'] = f'W34,{param},N33333'
488 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
490 assert len(json.loads(res.output)) == 1
493 async def test_lookup_working(self):
495 a.params['format'] = 'json'
496 a.params['osm_ids'] = 'N23,W34'
498 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
500 assert len(json.loads(res.output)) == 1
505 class TestSearchEndPointSearch:
507 @pytest.fixture(autouse=True)
508 def patch_lookup_func(self, monkeypatch):
509 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
511 napi.Point(1.0, 2.0))]
513 async def _search(*args, **kwargs):
514 return napi.SearchResults(self.results)
516 monkeypatch.setattr(napi.NominatimAPIAsync, 'search', _search)
519 async def test_search_free_text(self):
521 a.params['q'] = 'something'
523 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
525 assert len(json.loads(res.output)) == 1
528 async def test_search_free_text_xml(self):
530 a.params['q'] = 'something'
531 a.params['format'] = 'xml'
533 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
535 assert res.status == 200
536 assert res.output.index('something') > 0
539 async def test_search_free_text_xml_uses_stable_postcode_exclude_ids(self):
540 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
541 ('place', 'postcode'),
542 napi.Point(1.0, 2.0),
544 names={'ref': 'EH4 7EA'},
546 a = FakeAdaptor(params={'q': 'something', 'format': 'xml'})
548 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
550 assert 'exclude_place_ids="Pgb:EH4_7EA"' in res.output
553 async def test_search_free_text_jsonv2_emits_postcode_id(self):
554 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
555 ('place', 'postcode'),
556 napi.Point(1.0, 2.0),
558 names={'ref': 'EH4 7EA'},
560 a = FakeAdaptor(params={'q': 'something'})
562 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
564 assert '"postcode_id":"Pgb:EH4_7EA"' in res.output
567 async def test_search_free_and_structured(self):
569 a.params['q'] = 'something'
570 a.params['city'] = 'ignored'
572 with pytest.raises(FakeError, match='^400 -- .*cannot be used together'):
573 await glue.search_endpoint(napi.NominatimAPIAsync(), a)
576 @pytest.mark.parametrize('dedupe,numres', [(True, 1), (False, 2)])
577 async def test_search_dedupe(self, dedupe, numres):
578 self.results = self.results * 2
580 a.params['q'] = 'something'
582 a.params['dedupe'] = '0'
584 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
586 assert len(json.loads(res.output)) == numres
589 class TestSearchEndPointSearchAddress:
591 @pytest.fixture(autouse=True)
592 def patch_lookup_func(self, monkeypatch):
593 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
595 napi.Point(1.0, 2.0))]
597 async def _search(*args, **kwargs):
598 return napi.SearchResults(self.results)
600 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_address', _search)
603 async def test_search_structured(self):
605 a.params['street'] = 'something'
607 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
609 assert len(json.loads(res.output)) == 1
612 class TestSearchEndPointSearchCategory:
614 @pytest.fixture(autouse=True)
615 def patch_lookup_func(self, monkeypatch):
616 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
618 napi.Point(1.0, 2.0))]
620 async def _search(*args, **kwargs):
621 return napi.SearchResults(self.results)
623 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_category', _search)
626 async def test_search_category(self):
628 a.params['q'] = '[shop=fog]'
630 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
632 assert len(json.loads(res.output)) == 1