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 def test_get_layers_no_param():
273 assert glue.get_layers(FakeAdaptor()) is None
276 @pytest.mark.parametrize('param,expected', [
277 ('address', napi.DataLayer.ADDRESS),
278 ('POI', napi.DataLayer.POI),
279 ('address,poi', napi.DataLayer.ADDRESS | napi.DataLayer.POI),
280 (' address , poi ', napi.DataLayer.ADDRESS | napi.DataLayer.POI),
281 ('address,address', napi.DataLayer.ADDRESS),
282 ('address,', napi.DataLayer.ADDRESS)])
283 def test_get_layers_success(param, expected):
284 assert glue.get_layers(FakeAdaptor(params={'layer': param})) == expected
287 @pytest.mark.parametrize('param', ['', ' ', ',', ' , '])
288 def test_get_layers_empty_disables_filter(param):
289 assert glue.get_layers(FakeAdaptor(params={'layer': param})) is None
292 @pytest.mark.parametrize('param', ['bogus', 'address,bogus', 'name', '_name_',
293 'address poi', '<script>'])
294 def test_get_layers_invalid_value(param):
295 with pytest.raises(FakeError, match='^400 -- .*must be a comma-separated list'):
296 glue.get_layers(FakeAdaptor(params={'layer': param}))
301 class TestStatusEndpoint:
303 @pytest.fixture(autouse=True)
304 def patch_status_func(self, monkeypatch):
305 async def _status(*args, **kwargs):
308 monkeypatch.setattr(napi.NominatimAPIAsync, 'status', _status)
311 async def test_status_without_params(self):
313 self.status = napi.StatusResult(0, 'foo')
315 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
317 assert isinstance(resp, FakeResponse)
318 assert resp.status == 200
319 assert resp.content_type == 'text/plain; charset=utf-8'
322 async def test_status_with_error(self):
324 self.status = napi.StatusResult(405, 'foo')
326 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
328 assert isinstance(resp, FakeResponse)
329 assert resp.status == 500
330 assert resp.content_type == 'text/plain; charset=utf-8'
333 async def test_status_json_with_error(self):
334 a = FakeAdaptor(params={'format': 'json'})
335 self.status = napi.StatusResult(405, 'foo')
337 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
339 assert isinstance(resp, FakeResponse)
340 assert resp.status == 200
341 assert resp.content_type == 'application/json; charset=utf-8'
344 async def test_status_bad_format(self):
345 a = FakeAdaptor(params={'format': 'foo'})
346 self.status = napi.StatusResult(0, 'foo')
348 with pytest.raises(FakeError):
349 await glue.status_endpoint(napi.NominatimAPIAsync(), a)
354 class TestDetailsEndpoint:
356 @pytest.fixture(autouse=True)
357 def patch_lookup_func(self, monkeypatch):
358 self.result = napi.DetailedResult(napi.SourceTable.PLACEX,
360 napi.Point(1.0, 2.0))
361 self.lookup_args = []
363 async def _lookup(*args, **kwargs):
364 self.lookup_args.extend(args[1:])
367 monkeypatch.setattr(napi.NominatimAPIAsync, 'details', _lookup)
370 async def test_details_no_params(self):
373 with pytest.raises(FakeError, match='^400 -- .*Missing'):
374 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
377 async def test_details_by_place_id(self):
378 a = FakeAdaptor(params={'place_id': '4573'})
380 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
382 assert self.lookup_args[0].place_id == 4573
385 async def test_details_by_osm_id(self):
386 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45'})
388 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
390 assert self.lookup_args[0].osm_type == 'N'
391 assert self.lookup_args[0].osm_id == 45
392 assert self.lookup_args[0].osm_class is None
395 async def test_details_by_postcode(self):
396 a = FakeAdaptor(params={'postcode': 'us:94110'})
398 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
400 assert self.lookup_args[0].country_code == 'us'
401 assert self.lookup_args[0].postcode == '94110'
404 async def test_details_by_postcode_id(self):
405 a = FakeAdaptor(params={'postcode': 'Pus:94110'})
407 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
409 assert self.lookup_args[0].country_code == 'us'
410 assert self.lookup_args[0].postcode == '94110'
413 async def test_details_with_debugging(self):
414 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45', 'debug': '1'},
415 config=debug_config(True))
417 resp = await glue.details_endpoint(napi.NominatimAPIAsync(), a)
418 content = ET.fromstring(resp.output)
420 assert resp.content_type == 'text/html; charset=utf-8'
421 assert content.tag == 'html'
424 async def test_details_no_result(self):
425 a = FakeAdaptor(params={'place_id': '4573'})
428 with pytest.raises(FakeError, match='^404 -- .*found'):
429 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
433 class TestReverseEndPoint:
435 @pytest.fixture(autouse=True)
436 def patch_reverse_func(self, monkeypatch):
437 self.result = napi.ReverseResult(napi.SourceTable.PLACEX,
439 napi.Point(1.0, 2.0))
441 async def _reverse(*args, **kwargs):
444 monkeypatch.setattr(napi.NominatimAPIAsync, 'reverse', _reverse)
447 @pytest.mark.parametrize('params', [{}, {'lat': '3.4'}, {'lon': '6.7'}])
448 async def test_reverse_no_params(self, params):
451 a.params['format'] = 'xml'
453 with pytest.raises(FakeError, match='^400 -- (?s:.*)missing'):
454 await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
457 async def test_reverse_success(self):
459 a.params['lat'] = '56.3'
460 a.params['lon'] = '6.8'
462 assert await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
465 async def test_reverse_from_search(self):
467 a.params['q'] = '34.6 2.56'
468 a.params['format'] = 'json'
470 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
472 assert len(json.loads(res.output)) == 1
477 class TestLookupEndpoint:
479 @pytest.fixture(autouse=True)
480 def patch_lookup_func(self, monkeypatch):
481 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
483 napi.Point(1.0, 2.0))]
485 async def _lookup(*args, **kwargs):
486 return napi.SearchResults(self.results)
488 monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup)
491 async def test_lookup_no_params(self):
493 a.params['format'] = 'json'
495 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
497 assert res.output == '[]'
500 @pytest.mark.parametrize('param', ['w', 'bad', ''])
501 async def test_lookup_bad_params(self, param):
503 a.params['format'] = 'json'
504 a.params['osm_ids'] = f'W34,{param},N33333'
506 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
508 assert len(json.loads(res.output)) == 1
511 @pytest.mark.parametrize('param', ['p234234', '4563'])
512 async def test_lookup_bad_osm_type(self, param):
514 a.params['format'] = 'json'
515 a.params['osm_ids'] = f'W34,{param},N33333'
517 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
519 assert len(json.loads(res.output)) == 1
522 async def test_lookup_working(self):
524 a.params['format'] = 'json'
525 a.params['osm_ids'] = 'N23,W34'
527 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
529 assert len(json.loads(res.output)) == 1
534 class TestSearchEndPointSearch:
536 @pytest.fixture(autouse=True)
537 def patch_lookup_func(self, monkeypatch):
538 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
540 napi.Point(1.0, 2.0))]
542 async def _search(*args, **kwargs):
543 return napi.SearchResults(self.results)
545 monkeypatch.setattr(napi.NominatimAPIAsync, 'search', _search)
548 async def test_search_free_text(self):
550 a.params['q'] = 'something'
552 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
554 assert len(json.loads(res.output)) == 1
557 async def test_search_free_text_xml(self):
559 a.params['q'] = 'something'
560 a.params['format'] = 'xml'
562 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
564 assert res.status == 200
565 assert res.output.index('something') > 0
568 async def test_search_free_text_xml_uses_stable_postcode_exclude_ids(self):
569 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
570 ('place', 'postcode'),
571 napi.Point(1.0, 2.0),
573 names={'ref': 'EH4 7EA'},
575 a = FakeAdaptor(params={'q': 'something', 'format': 'xml'})
577 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
579 assert 'exclude_place_ids="Pgb:EH4_7EA"' in res.output
582 async def test_search_free_text_jsonv2_emits_postcode_id(self):
583 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
584 ('place', 'postcode'),
585 napi.Point(1.0, 2.0),
587 names={'ref': 'EH4 7EA'},
589 a = FakeAdaptor(params={'q': 'something'})
591 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
593 assert '"postcode_id":"Pgb:EH4_7EA"' in res.output
596 async def test_search_free_and_structured(self):
598 a.params['q'] = 'something'
599 a.params['city'] = 'ignored'
601 with pytest.raises(FakeError, match='^400 -- .*cannot be used together'):
602 await glue.search_endpoint(napi.NominatimAPIAsync(), a)
605 @pytest.mark.parametrize('dedupe,numres', [(True, 1), (False, 2)])
606 async def test_search_dedupe(self, dedupe, numres):
607 self.results = self.results * 2
609 a.params['q'] = 'something'
611 a.params['dedupe'] = '0'
613 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
615 assert len(json.loads(res.output)) == numres
618 class TestSearchEndPointSearchAddress:
620 @pytest.fixture(autouse=True)
621 def patch_lookup_func(self, monkeypatch):
622 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
624 napi.Point(1.0, 2.0))]
626 async def _search(*args, **kwargs):
627 return napi.SearchResults(self.results)
629 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_address', _search)
632 async def test_search_structured(self):
634 a.params['street'] = 'something'
636 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
638 assert len(json.loads(res.output)) == 1
641 class TestSearchEndPointSearchCategory:
643 @pytest.fixture(autouse=True)
644 def patch_lookup_func(self, monkeypatch):
645 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
647 napi.Point(1.0, 2.0))]
649 async def _search(*args, **kwargs):
650 return napi.SearchResults(self.results)
652 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_category', _search)
655 async def test_search_category(self):
657 a.params['q'] = '[shop=fog]'
659 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
661 assert len(json.loads(res.output)) == 1