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
22 # ASGIAdaptor.get_int/bool()
24 @pytest.mark.parametrize('func', ['get_int', 'get_bool'])
25 def test_adaptor_get_int_missing_but_required(func):
26 with pytest.raises(FakeError, match='^400 -- .*missing'):
27 getattr(FakeAdaptor(), func)('something')
30 @pytest.mark.parametrize('func, val', [('get_int', 23), ('get_bool', True)])
31 def test_adaptor_get_int_missing_with_default(func, val):
32 assert getattr(FakeAdaptor(), func)('something', val) == val
35 @pytest.mark.parametrize('inp', ['0', '234', '-4566953498567934876'])
36 def test_adaptor_get_int_success(inp):
37 assert FakeAdaptor(params={'foo': inp}).get_int('foo') == int(inp)
38 assert FakeAdaptor(params={'foo': inp}).get_int('foo', 4) == int(inp)
41 @pytest.mark.parametrize('inp', ['rs', '4.5', '6f'])
42 def test_adaptor_get_int_bad_number(inp):
43 with pytest.raises(FakeError, match='^400 -- .*must be a number'):
44 FakeAdaptor(params={'foo': inp}).get_int('foo')
47 @pytest.mark.parametrize('inp', ['1', 'true', 'whatever', 'false'])
48 def test_adaptor_get_bool_trueish(inp):
49 assert FakeAdaptor(params={'foo': inp}).get_bool('foo')
52 def test_adaptor_get_bool_falsish():
53 assert not FakeAdaptor(params={'foo': '0'}).get_bool('foo')
56 # ASGIAdaptor.parse_format()
58 def test_adaptor_parse_format_use_default():
59 adaptor = FakeAdaptor()
61 assert glue.parse_format(adaptor, napi.StatusResult, 'text') == 'text'
62 assert adaptor.content_type == 'text/plain; charset=utf-8'
65 def test_adaptor_parse_format_use_configured():
66 adaptor = FakeAdaptor(params={'format': 'json'})
68 assert glue.parse_format(adaptor, napi.StatusResult, 'text') == 'json'
69 assert adaptor.content_type == 'application/json; charset=utf-8'
72 def test_adaptor_parse_format_invalid_value():
73 adaptor = FakeAdaptor(params={'format': '@!#'})
75 with pytest.raises(FakeError, match='^400 -- .*must be one of'):
76 glue.parse_format(adaptor, napi.StatusResult, 'text')
79 # ASGIAdaptor.get_accepted_languages()
81 def test_accepted_languages_from_param():
82 a = FakeAdaptor(params={'accept-language': 'de'})
83 assert glue.get_accepted_languages(a) == 'de'
86 def test_accepted_languages_from_header():
87 a = FakeAdaptor(headers={'accept-language': 'de'})
88 assert glue.get_accepted_languages(a) == 'de'
91 def test_accepted_languages_from_default(monkeypatch):
92 monkeypatch.setenv('NOMINATIM_DEFAULT_LANGUAGE', 'de')
94 assert glue.get_accepted_languages(a) == 'de'
97 def test_accepted_languages_param_over_header():
98 a = FakeAdaptor(params={'accept-language': 'de'},
99 headers={'accept-language': 'en'})
100 assert glue.get_accepted_languages(a) == 'de'
103 def test_accepted_languages_header_over_default(monkeypatch):
104 monkeypatch.setenv('NOMINATIM_DEFAULT_LANGUAGE', 'en')
105 a = FakeAdaptor(headers={'accept-language': 'de'})
106 assert glue.get_accepted_languages(a) == 'de'
109 # ASGIAdaptor.raise_error()
111 class TestAdaptorRaiseError:
113 @pytest.fixture(autouse=True)
114 def init_adaptor(self):
115 self.adaptor = FakeAdaptor()
116 glue.setup_debugging(self.adaptor)
118 def run_raise_error(self, msg, status):
119 with pytest.raises(FakeError) as excinfo:
120 self.adaptor.raise_error(msg, status=status)
124 def test_without_content_set(self):
125 err = self.run_raise_error('TEST', 404)
127 assert self.adaptor.content_type == 'text/plain; charset=utf-8'
128 assert err.msg == 'ERROR 404: TEST'
129 assert err.status == 404
132 self.adaptor.content_type = 'application/json; charset=utf-8'
134 err = self.run_raise_error('TEST', 501)
136 content = json.loads(err.msg)['error']
137 assert content['code'] == 501
138 assert content['message'] == 'TEST'
141 self.adaptor.content_type = 'text/xml; charset=utf-8'
143 err = self.run_raise_error('this!', 503)
145 content = ET.fromstring(err.msg)
147 assert content.tag == 'error'
148 assert content.find('code').text == '503'
149 assert content.find('message').text == 'this!'
152 def test_raise_error_during_debug():
153 a = FakeAdaptor(params={'debug': '1'})
154 glue.setup_debugging(a)
155 loglib.log().section('Ongoing')
157 with pytest.raises(FakeError) as excinfo:
158 a.raise_error('badstate')
160 content = ET.fromstring(excinfo.value.msg)
162 assert content.tag == 'html'
164 assert '>Ongoing<' in excinfo.value.msg
165 assert 'badstate' in excinfo.value.msg
168 # ASGIAdaptor.build_response
170 def test_build_response_without_content_type():
171 resp = glue.build_response(FakeAdaptor(), 'attention')
173 assert isinstance(resp, FakeResponse)
174 assert resp.status == 200
175 assert resp.output == 'attention'
176 assert resp.content_type == 'text/plain; charset=utf-8'
179 def test_build_response_with_status():
180 a = FakeAdaptor(params={'format': 'json'})
181 glue.parse_format(a, napi.StatusResult, 'text')
183 resp = glue.build_response(a, 'stuff\nmore stuff', status=404)
185 assert isinstance(resp, FakeResponse)
186 assert resp.status == 404
187 assert resp.output == 'stuff\nmore stuff'
188 assert resp.content_type == 'application/json; charset=utf-8'
191 def test_build_response_jsonp_with_json():
192 a = FakeAdaptor(params={'format': 'json', 'json_callback': 'test.func'})
193 glue.parse_format(a, napi.StatusResult, 'text')
195 resp = glue.build_response(a, '{}')
197 assert isinstance(resp, FakeResponse)
198 assert resp.status == 200
199 assert resp.output == 'test.func({})'
200 assert resp.content_type == 'application/javascript; charset=utf-8'
203 def test_build_response_jsonp_without_json():
204 a = FakeAdaptor(params={'format': 'text', 'json_callback': 'test.func'})
205 glue.parse_format(a, napi.StatusResult, 'text')
207 resp = glue.build_response(a, '{}')
209 assert isinstance(resp, FakeResponse)
210 assert resp.status == 200
211 assert resp.output == '{}'
212 assert resp.content_type == 'text/plain; charset=utf-8'
215 @pytest.mark.parametrize('param', ['alert(); func', '\\n', '', 'a b'])
216 def test_build_response_jsonp_bad_format(param):
217 a = FakeAdaptor(params={'format': 'json', 'json_callback': param})
218 glue.parse_format(a, napi.StatusResult, 'text')
220 with pytest.raises(FakeError, match='^400 -- .*Invalid'):
221 glue.build_response(a, '{}')
226 class TestStatusEndpoint:
228 @pytest.fixture(autouse=True)
229 def patch_status_func(self, monkeypatch):
230 async def _status(*args, **kwargs):
233 monkeypatch.setattr(napi.NominatimAPIAsync, 'status', _status)
236 async def test_status_without_params(self):
238 self.status = napi.StatusResult(0, 'foo')
240 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
242 assert isinstance(resp, FakeResponse)
243 assert resp.status == 200
244 assert resp.content_type == 'text/plain; charset=utf-8'
247 async def test_status_with_error(self):
249 self.status = napi.StatusResult(405, 'foo')
251 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
253 assert isinstance(resp, FakeResponse)
254 assert resp.status == 500
255 assert resp.content_type == 'text/plain; charset=utf-8'
258 async def test_status_json_with_error(self):
259 a = FakeAdaptor(params={'format': 'json'})
260 self.status = napi.StatusResult(405, 'foo')
262 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
264 assert isinstance(resp, FakeResponse)
265 assert resp.status == 200
266 assert resp.content_type == 'application/json; charset=utf-8'
269 async def test_status_bad_format(self):
270 a = FakeAdaptor(params={'format': 'foo'})
271 self.status = napi.StatusResult(0, 'foo')
273 with pytest.raises(FakeError):
274 await glue.status_endpoint(napi.NominatimAPIAsync(), a)
279 class TestDetailsEndpoint:
281 @pytest.fixture(autouse=True)
282 def patch_lookup_func(self, monkeypatch):
283 self.result = napi.DetailedResult(napi.SourceTable.PLACEX,
285 napi.Point(1.0, 2.0))
286 self.lookup_args = []
288 async def _lookup(*args, **kwargs):
289 self.lookup_args.extend(args[1:])
292 monkeypatch.setattr(napi.NominatimAPIAsync, 'details', _lookup)
295 async def test_details_no_params(self):
298 with pytest.raises(FakeError, match='^400 -- .*Missing'):
299 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
302 async def test_details_by_place_id(self):
303 a = FakeAdaptor(params={'place_id': '4573'})
305 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
307 assert self.lookup_args[0].place_id == 4573
310 async def test_details_by_osm_id(self):
311 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45'})
313 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
315 assert self.lookup_args[0].osm_type == 'N'
316 assert self.lookup_args[0].osm_id == 45
317 assert self.lookup_args[0].osm_class is None
320 async def test_details_by_postcode(self):
321 a = FakeAdaptor(params={'postcode': 'us:94110'})
323 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
325 assert self.lookup_args[0].country_code == 'us'
326 assert self.lookup_args[0].postcode == '94110'
329 async def test_details_by_postcode_id(self):
330 a = FakeAdaptor(params={'postcode': 'Pus:94110'})
332 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
334 assert self.lookup_args[0].country_code == 'us'
335 assert self.lookup_args[0].postcode == '94110'
338 async def test_details_with_debugging(self):
339 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45', 'debug': '1'})
341 resp = await glue.details_endpoint(napi.NominatimAPIAsync(), a)
342 content = ET.fromstring(resp.output)
344 assert resp.content_type == 'text/html; charset=utf-8'
345 assert content.tag == 'html'
348 async def test_details_no_result(self):
349 a = FakeAdaptor(params={'place_id': '4573'})
352 with pytest.raises(FakeError, match='^404 -- .*found'):
353 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
357 class TestReverseEndPoint:
359 @pytest.fixture(autouse=True)
360 def patch_reverse_func(self, monkeypatch):
361 self.result = napi.ReverseResult(napi.SourceTable.PLACEX,
363 napi.Point(1.0, 2.0))
365 async def _reverse(*args, **kwargs):
368 monkeypatch.setattr(napi.NominatimAPIAsync, 'reverse', _reverse)
371 @pytest.mark.parametrize('params', [{}, {'lat': '3.4'}, {'lon': '6.7'}])
372 async def test_reverse_no_params(self, params):
375 a.params['format'] = 'xml'
377 with pytest.raises(FakeError, match='^400 -- (?s:.*)missing'):
378 await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
381 async def test_reverse_success(self):
383 a.params['lat'] = '56.3'
384 a.params['lon'] = '6.8'
386 assert await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
389 async def test_reverse_from_search(self):
391 a.params['q'] = '34.6 2.56'
392 a.params['format'] = 'json'
394 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
396 assert len(json.loads(res.output)) == 1
401 class TestLookupEndpoint:
403 @pytest.fixture(autouse=True)
404 def patch_lookup_func(self, monkeypatch):
405 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
407 napi.Point(1.0, 2.0))]
409 async def _lookup(*args, **kwargs):
410 return napi.SearchResults(self.results)
412 monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup)
415 async def test_lookup_no_params(self):
417 a.params['format'] = 'json'
419 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
421 assert res.output == '[]'
424 @pytest.mark.parametrize('param', ['w', 'bad', ''])
425 async def test_lookup_bad_params(self, param):
427 a.params['format'] = 'json'
428 a.params['osm_ids'] = f'W34,{param},N33333'
430 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
432 assert len(json.loads(res.output)) == 1
435 @pytest.mark.parametrize('param', ['p234234', '4563'])
436 async def test_lookup_bad_osm_type(self, param):
438 a.params['format'] = 'json'
439 a.params['osm_ids'] = f'W34,{param},N33333'
441 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
443 assert len(json.loads(res.output)) == 1
446 async def test_lookup_working(self):
448 a.params['format'] = 'json'
449 a.params['osm_ids'] = 'N23,W34'
451 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
453 assert len(json.loads(res.output)) == 1
458 class TestSearchEndPointSearch:
460 @pytest.fixture(autouse=True)
461 def patch_lookup_func(self, monkeypatch):
462 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
464 napi.Point(1.0, 2.0))]
466 async def _search(*args, **kwargs):
467 return napi.SearchResults(self.results)
469 monkeypatch.setattr(napi.NominatimAPIAsync, 'search', _search)
472 async def test_search_free_text(self):
474 a.params['q'] = 'something'
476 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
478 assert len(json.loads(res.output)) == 1
481 async def test_search_free_text_xml(self):
483 a.params['q'] = 'something'
484 a.params['format'] = 'xml'
486 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
488 assert res.status == 200
489 assert res.output.index('something') > 0
492 async def test_search_free_text_xml_uses_stable_postcode_exclude_ids(self):
493 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
494 ('place', 'postcode'),
495 napi.Point(1.0, 2.0),
497 names={'ref': 'EH4 7EA'},
499 a = FakeAdaptor(params={'q': 'something', 'format': 'xml'})
501 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
503 assert 'exclude_place_ids="Pgb:EH4_7EA"' in res.output
506 async def test_search_free_text_jsonv2_emits_postcode_id(self):
507 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
508 ('place', 'postcode'),
509 napi.Point(1.0, 2.0),
511 names={'ref': 'EH4 7EA'},
513 a = FakeAdaptor(params={'q': 'something'})
515 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
517 assert '"postcode_id":"Pgb:EH4_7EA"' in res.output
520 async def test_search_free_and_structured(self):
522 a.params['q'] = 'something'
523 a.params['city'] = 'ignored'
525 with pytest.raises(FakeError, match='^400 -- .*cannot be used together'):
526 await glue.search_endpoint(napi.NominatimAPIAsync(), a)
529 @pytest.mark.parametrize('dedupe,numres', [(True, 1), (False, 2)])
530 async def test_search_dedupe(self, dedupe, numres):
531 self.results = self.results * 2
533 a.params['q'] = 'something'
535 a.params['dedupe'] = '0'
537 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
539 assert len(json.loads(res.output)) == numres
542 class TestSearchEndPointSearchAddress:
544 @pytest.fixture(autouse=True)
545 def patch_lookup_func(self, monkeypatch):
546 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
548 napi.Point(1.0, 2.0))]
550 async def _search(*args, **kwargs):
551 return napi.SearchResults(self.results)
553 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_address', _search)
556 async def test_search_structured(self):
558 a.params['street'] = 'something'
560 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
562 assert len(json.loads(res.output)) == 1
565 class TestSearchEndPointSearchCategory:
567 @pytest.fixture(autouse=True)
568 def patch_lookup_func(self, monkeypatch):
569 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
571 napi.Point(1.0, 2.0))]
573 async def _search(*args, **kwargs):
574 return napi.SearchResults(self.results)
576 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_category', _search)
579 async def test_search_category(self):
581 a.params['q'] = '[shop=fog]'
583 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
585 assert len(json.loads(res.output)) == 1