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_with_debugging(self):
321 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45', 'debug': '1'})
323 resp = await glue.details_endpoint(napi.NominatimAPIAsync(), a)
324 content = ET.fromstring(resp.output)
326 assert resp.content_type == 'text/html; charset=utf-8'
327 assert content.tag == 'html'
330 async def test_details_no_result(self):
331 a = FakeAdaptor(params={'place_id': '4573'})
334 with pytest.raises(FakeError, match='^404 -- .*found'):
335 await glue.details_endpoint(napi.NominatimAPIAsync(), a)
339 class TestReverseEndPoint:
341 @pytest.fixture(autouse=True)
342 def patch_reverse_func(self, monkeypatch):
343 self.result = napi.ReverseResult(napi.SourceTable.PLACEX,
345 napi.Point(1.0, 2.0))
347 async def _reverse(*args, **kwargs):
350 monkeypatch.setattr(napi.NominatimAPIAsync, 'reverse', _reverse)
353 @pytest.mark.parametrize('params', [{}, {'lat': '3.4'}, {'lon': '6.7'}])
354 async def test_reverse_no_params(self, params):
357 a.params['format'] = 'xml'
359 with pytest.raises(FakeError, match='^400 -- (?s:.*)missing'):
360 await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
363 async def test_reverse_success(self):
365 a.params['lat'] = '56.3'
366 a.params['lon'] = '6.8'
368 assert await glue.reverse_endpoint(napi.NominatimAPIAsync(), a)
371 async def test_reverse_from_search(self):
373 a.params['q'] = '34.6 2.56'
374 a.params['format'] = 'json'
376 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
378 assert len(json.loads(res.output)) == 1
383 class TestLookupEndpoint:
385 @pytest.fixture(autouse=True)
386 def patch_lookup_func(self, monkeypatch):
387 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
389 napi.Point(1.0, 2.0))]
391 async def _lookup(*args, **kwargs):
392 return napi.SearchResults(self.results)
394 monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup)
397 async def test_lookup_no_params(self):
399 a.params['format'] = 'json'
401 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
403 assert res.output == '[]'
406 @pytest.mark.parametrize('param', ['w', 'bad', ''])
407 async def test_lookup_bad_params(self, param):
409 a.params['format'] = 'json'
410 a.params['osm_ids'] = f'W34,{param},N33333'
412 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
414 assert len(json.loads(res.output)) == 1
417 @pytest.mark.parametrize('param', ['p234234', '4563'])
418 async def test_lookup_bad_osm_type(self, param):
420 a.params['format'] = 'json'
421 a.params['osm_ids'] = f'W34,{param},N33333'
423 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
425 assert len(json.loads(res.output)) == 1
428 async def test_lookup_working(self):
430 a.params['format'] = 'json'
431 a.params['osm_ids'] = 'N23,W34'
433 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(), a)
435 assert len(json.loads(res.output)) == 1
440 class TestSearchEndPointSearch:
442 @pytest.fixture(autouse=True)
443 def patch_lookup_func(self, monkeypatch):
444 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
446 napi.Point(1.0, 2.0))]
448 async def _search(*args, **kwargs):
449 return napi.SearchResults(self.results)
451 monkeypatch.setattr(napi.NominatimAPIAsync, 'search', _search)
454 async def test_search_free_text(self):
456 a.params['q'] = 'something'
458 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
460 assert len(json.loads(res.output)) == 1
463 async def test_search_free_text_xml(self):
465 a.params['q'] = 'something'
466 a.params['format'] = 'xml'
468 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
470 assert res.status == 200
471 assert res.output.index('something') > 0
474 async def test_search_free_and_structured(self):
476 a.params['q'] = 'something'
477 a.params['city'] = 'ignored'
479 with pytest.raises(FakeError, match='^400 -- .*cannot be used together'):
480 await glue.search_endpoint(napi.NominatimAPIAsync(), a)
483 @pytest.mark.parametrize('dedupe,numres', [(True, 1), (False, 2)])
484 async def test_search_dedupe(self, dedupe, numres):
485 self.results = self.results * 2
487 a.params['q'] = 'something'
489 a.params['dedupe'] = '0'
491 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
493 assert len(json.loads(res.output)) == numres
496 class TestSearchEndPointSearchAddress:
498 @pytest.fixture(autouse=True)
499 def patch_lookup_func(self, monkeypatch):
500 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
502 napi.Point(1.0, 2.0))]
504 async def _search(*args, **kwargs):
505 return napi.SearchResults(self.results)
507 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_address', _search)
510 async def test_search_structured(self):
512 a.params['street'] = 'something'
514 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
516 assert len(json.loads(res.output)) == 1
519 class TestSearchEndPointSearchCategory:
521 @pytest.fixture(autouse=True)
522 def patch_lookup_func(self, monkeypatch):
523 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
525 napi.Point(1.0, 2.0))]
527 async def _search(*args, **kwargs):
528 return napi.SearchResults(self.results)
530 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_category', _search)
533 async def test_search_category(self):
535 a.params['q'] = '[shop=fog]'
537 res = await glue.search_endpoint(napi.NominatimAPIAsync(), a)
539 assert len(json.loads(res.output)) == 1