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 def test_get_layers_no_param():
227 assert glue.get_layers(FakeAdaptor()) is None
230 @pytest.mark.parametrize('param,expected', [
231 ('address', napi.DataLayer.ADDRESS),
232 ('POI', napi.DataLayer.POI),
233 ('address,poi', napi.DataLayer.ADDRESS | napi.DataLayer.POI),
234 (' address , poi ', napi.DataLayer.ADDRESS | napi.DataLayer.POI),
235 ('address,address', napi.DataLayer.ADDRESS),
236 ('address,', napi.DataLayer.ADDRESS)])
237 def test_get_layers_success(param, expected):
238 assert glue.get_layers(FakeAdaptor(params={'layer': param})) == expected
241 @pytest.mark.parametrize('param', ['', ' ', ',', ' , '])
242 def test_get_layers_empty_disables_filter(param):
243 assert glue.get_layers(FakeAdaptor(params={'layer': param})) is None
246 @pytest.mark.parametrize('param', ['bogus', 'address,bogus', 'name', '_name_',
247 'address poi', '<script>'])
248 def test_get_layers_invalid_value(param):
249 with pytest.raises(FakeError, match='^400 -- .*must be a comma-separated list'):
250 glue.get_layers(FakeAdaptor(params={'layer': param}))
255 class TestStatusEndpoint:
257 @pytest.fixture(autouse=True)
258 def patch_status_func(self, monkeypatch):
259 async def _status(*args, **kwargs):
262 monkeypatch.setattr(napi.NominatimAPIAsync, 'status', _status)
265 async def test_status_without_params(self):
267 self.status = napi.StatusResult(0, 'foo')
269 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
271 assert isinstance(resp, FakeResponse)
272 assert resp.status == 200
273 assert resp.content_type == 'text/plain; charset=utf-8'
276 async def test_status_with_error(self):
278 self.status = napi.StatusResult(405, 'foo')
280 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
282 assert isinstance(resp, FakeResponse)
283 assert resp.status == 500
284 assert resp.content_type == 'text/plain; charset=utf-8'
287 async def test_status_json_with_error(self):
288 a = FakeAdaptor(params={'format': 'json'})
289 self.status = napi.StatusResult(405, 'foo')
291 resp = await glue.status_endpoint(napi.NominatimAPIAsync(), a)
293 assert isinstance(resp, FakeResponse)
294 assert resp.status == 200
295 assert resp.content_type == 'application/json; charset=utf-8'
298 async def test_status_bad_format(self):
299 a = FakeAdaptor(params={'format': 'foo'})
300 self.status = napi.StatusResult(0, 'foo')
302 with pytest.raises(FakeError):
303 await glue.status_endpoint(napi.NominatimAPIAsync(), a)
308 class TestDetailsEndpoint:
310 @pytest.fixture(autouse=True)
311 def patch_lookup_func(self, monkeypatch):
312 self.result = napi.DetailedResult(napi.SourceTable.PLACEX,
314 napi.Point(1.0, 2.0))
315 self.lookup_args = []
317 async def _lookup(*args, **kwargs):
318 self.lookup_args.extend(args[1:])
321 monkeypatch.setattr(napi.NominatimAPIAsync, 'details', _lookup)
324 async def test_details_no_params(self):
327 with pytest.raises(FakeError, match='^400 -- .*Missing'):
328 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
331 async def test_details_by_place_id(self):
332 a = FakeAdaptor(params={'place_id': '4573'})
334 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
336 assert self.lookup_args[0].place_id == 4573
339 async def test_details_by_osm_id(self):
340 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45'})
342 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
344 assert self.lookup_args[0].osm_type == 'N'
345 assert self.lookup_args[0].osm_id == 45
346 assert self.lookup_args[0].osm_class is None
349 async def test_details_by_postcode(self):
350 a = FakeAdaptor(params={'postcode': 'us:94110'})
352 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
354 assert self.lookup_args[0].country_code == 'us'
355 assert self.lookup_args[0].postcode == '94110'
358 async def test_details_by_postcode_id(self):
359 a = FakeAdaptor(params={'postcode': 'Pus:94110'})
361 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
363 assert self.lookup_args[0].country_code == 'us'
364 assert self.lookup_args[0].postcode == '94110'
367 async def test_details_with_debugging(self):
368 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45', 'debug': '1'})
370 resp = await glue.details_endpoint(napi.NominatimAPIAsync(), a)
371 content = ET.fromstring(resp.output)
373 assert resp.content_type == 'text/html; charset=utf-8'
374 assert content.tag == 'html'
377 async def test_details_no_result(self):
378 a = FakeAdaptor(params={'place_id': '4573'})
381 with pytest.raises(FakeError, match='^404 -- .*found'):
382 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
386 class TestReverseEndPoint:
388 @pytest.fixture(autouse=True)
389 def patch_reverse_func(self, monkeypatch):
390 self.result = napi.ReverseResult(napi.SourceTable.PLACEX,
392 napi.Point(1.0, 2.0))
394 async def _reverse(*args, **kwargs):
397 monkeypatch.setattr(napi.NominatimAPIAsync, 'reverse', _reverse)
400 @pytest.mark.parametrize('params', [{}, {'lat': '3.4'}, {'lon': '6.7'}])
401 async def test_reverse_no_params(self, params):
404 a.params['format'] = 'xml'
406 with pytest.raises(FakeError, match='^400 -- (?s:.*)missing'):
407 await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
410 async def test_reverse_success(self):
412 a.params['lat'] = '56.3'
413 a.params['lon'] = '6.8'
415 assert await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
418 async def test_reverse_from_search(self):
420 a.params['q'] = '34.6 2.56'
421 a.params['format'] = 'json'
423 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
425 assert len(json.loads(res.output)) == 1
430 class TestLookupEndpoint:
432 @pytest.fixture(autouse=True)
433 def patch_lookup_func(self, monkeypatch):
434 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
436 napi.Point(1.0, 2.0))]
438 async def _lookup(*args, **kwargs):
439 return napi.SearchResults(self.results)
441 monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup)
444 async def test_lookup_no_params(self):
446 a.params['format'] = 'json'
448 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
450 assert res.output == '[]'
453 @pytest.mark.parametrize('param', ['w', 'bad', ''])
454 async def test_lookup_bad_params(self, param):
456 a.params['format'] = 'json'
457 a.params['osm_ids'] = f'W34,{param},N33333'
459 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
461 assert len(json.loads(res.output)) == 1
464 @pytest.mark.parametrize('param', ['p234234', '4563'])
465 async def test_lookup_bad_osm_type(self, param):
467 a.params['format'] = 'json'
468 a.params['osm_ids'] = f'W34,{param},N33333'
470 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
472 assert len(json.loads(res.output)) == 1
475 async def test_lookup_working(self):
477 a.params['format'] = 'json'
478 a.params['osm_ids'] = 'N23,W34'
480 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
482 assert len(json.loads(res.output)) == 1
487 class TestSearchEndPointSearch:
489 @pytest.fixture(autouse=True)
490 def patch_lookup_func(self, monkeypatch):
491 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
493 napi.Point(1.0, 2.0))]
495 async def _search(*args, **kwargs):
496 return napi.SearchResults(self.results)
498 monkeypatch.setattr(napi.NominatimAPIAsync, 'search', _search)
501 async def test_search_free_text(self):
503 a.params['q'] = 'something'
505 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
507 assert len(json.loads(res.output)) == 1
510 async def test_search_free_text_xml(self):
512 a.params['q'] = 'something'
513 a.params['format'] = 'xml'
515 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
517 assert res.status == 200
518 assert res.output.index('something') > 0
521 async def test_search_free_text_xml_uses_stable_postcode_exclude_ids(self):
522 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
523 ('place', 'postcode'),
524 napi.Point(1.0, 2.0),
526 names={'ref': 'EH4 7EA'},
528 a = FakeAdaptor(params={'q': 'something', 'format': 'xml'})
530 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
532 assert 'exclude_place_ids="Pgb:EH4_7EA"' in res.output
535 async def test_search_free_text_jsonv2_emits_postcode_id(self):
536 self.results = [napi.SearchResult(napi.SourceTable.POSTCODE,
537 ('place', 'postcode'),
538 napi.Point(1.0, 2.0),
540 names={'ref': 'EH4 7EA'},
542 a = FakeAdaptor(params={'q': 'something'})
544 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
546 assert '"postcode_id":"Pgb:EH4_7EA"' in res.output
549 async def test_search_free_and_structured(self):
551 a.params['q'] = 'something'
552 a.params['city'] = 'ignored'
554 with pytest.raises(FakeError, match='^400 -- .*cannot be used together'):
555 await glue.search_endpoint(napi.NominatimAPIAsync(), a)
558 @pytest.mark.parametrize('dedupe,numres', [(True, 1), (False, 2)])
559 async def test_search_dedupe(self, dedupe, numres):
560 self.results = self.results * 2
562 a.params['q'] = 'something'
564 a.params['dedupe'] = '0'
566 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
568 assert len(json.loads(res.output)) == numres
571 class TestSearchEndPointSearchAddress:
573 @pytest.fixture(autouse=True)
574 def patch_lookup_func(self, monkeypatch):
575 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
577 napi.Point(1.0, 2.0))]
579 async def _search(*args, **kwargs):
580 return napi.SearchResults(self.results)
582 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_address', _search)
585 async def test_search_structured(self):
587 a.params['street'] = 'something'
589 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
591 assert len(json.loads(res.output)) == 1
594 class TestSearchEndPointSearchCategory:
596 @pytest.fixture(autouse=True)
597 def patch_lookup_func(self, monkeypatch):
598 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
600 napi.Point(1.0, 2.0))]
602 async def _search(*args, **kwargs):
603 return napi.SearchResults(self.results)
605 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_category', _search)
608 async def test_search_category(self):
610 a.params['q'] = '[shop=fog]'
612 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
614 assert len(json.loads(res.output)) == 1