1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 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
12 from pathlib import Path
16 from fake_adaptor import FakeAdaptor, FakeError, FakeResponse
18 import nominatim_api.v1.server_glue as glue
19 import nominatim_api as napi
20 import nominatim_api.logging as loglib
23 # ASGIAdaptor.get_int/bool()
25 @pytest.mark.parametrize('func', ['get_int', 'get_bool'])
26 def test_adaptor_get_int_missing_but_required(func):
27 with pytest.raises(FakeError, match='^400 -- .*missing'):
28 getattr(FakeAdaptor(), func)('something')
31 @pytest.mark.parametrize('func, val', [('get_int', 23), ('get_bool', True)])
32 def test_adaptor_get_int_missing_with_default(func, val):
33 assert getattr(FakeAdaptor(), func)('something', val) == val
36 @pytest.mark.parametrize('inp', ['0', '234', '-4566953498567934876'])
37 def test_adaptor_get_int_success(inp):
38 assert FakeAdaptor(params={'foo': inp}).get_int('foo') == int(inp)
39 assert FakeAdaptor(params={'foo': inp}).get_int('foo', 4) == int(inp)
42 @pytest.mark.parametrize('inp', ['rs', '4.5', '6f'])
43 def test_adaptor_get_int_bad_number(inp):
44 with pytest.raises(FakeError, match='^400 -- .*must be a number'):
45 FakeAdaptor(params={'foo': inp}).get_int('foo')
48 @pytest.mark.parametrize('inp', ['1', 'true', 'whatever', 'false'])
49 def test_adaptor_get_bool_trueish(inp):
50 assert FakeAdaptor(params={'foo': inp}).get_bool('foo')
53 def test_adaptor_get_bool_falsish():
54 assert not FakeAdaptor(params={'foo': '0'}).get_bool('foo')
57 # ASGIAdaptor.parse_format()
59 def test_adaptor_parse_format_use_default():
60 adaptor = FakeAdaptor()
62 assert glue.parse_format(adaptor, napi.StatusResult, 'text') == 'text'
63 assert adaptor.content_type == 'text/plain; charset=utf-8'
66 def test_adaptor_parse_format_use_configured():
67 adaptor = FakeAdaptor(params={'format': 'json'})
69 assert glue.parse_format(adaptor, napi.StatusResult, 'text') == 'json'
70 assert adaptor.content_type == 'application/json; charset=utf-8'
73 def test_adaptor_parse_format_invalid_value():
74 adaptor = FakeAdaptor(params={'format': '@!#'})
76 with pytest.raises(FakeError, match='^400 -- .*must be one of'):
77 glue.parse_format(adaptor, napi.StatusResult, 'text')
80 # ASGIAdaptor.get_accepted_languages()
82 def test_accepted_languages_from_param():
83 a = FakeAdaptor(params={'accept-language': 'de'})
84 assert glue.get_accepted_languages(a) == 'de'
87 def test_accepted_languages_from_header():
88 a = FakeAdaptor(headers={'accept-language': 'de'})
89 assert glue.get_accepted_languages(a) == 'de'
92 def test_accepted_languages_from_default(monkeypatch):
93 monkeypatch.setenv('NOMINATIM_DEFAULT_LANGUAGE', 'de')
95 assert glue.get_accepted_languages(a) == 'de'
98 def test_accepted_languages_param_over_header():
99 a = FakeAdaptor(params={'accept-language': 'de'},
100 headers={'accept-language': 'en'})
101 assert glue.get_accepted_languages(a) == 'de'
104 def test_accepted_languages_header_over_default(monkeypatch):
105 monkeypatch.setenv('NOMINATIM_DEFAULT_LANGUAGE', 'en')
106 a = FakeAdaptor(headers={'accept-language': 'de'})
107 assert glue.get_accepted_languages(a) == 'de'
110 # ASGIAdaptor.raise_error()
112 class TestAdaptorRaiseError:
114 @pytest.fixture(autouse=True)
115 def init_adaptor(self):
116 self.adaptor = FakeAdaptor()
117 glue.setup_debugging(self.adaptor)
119 def run_raise_error(self, msg, status):
120 with pytest.raises(FakeError) as excinfo:
121 self.adaptor.raise_error(msg, status=status)
126 def test_without_content_set(self):
127 err = self.run_raise_error('TEST', 404)
129 assert self.adaptor.content_type == 'text/plain; charset=utf-8'
130 assert err.msg == 'ERROR 404: TEST'
131 assert err.status == 404
135 self.adaptor.content_type = 'application/json; charset=utf-8'
137 err = self.run_raise_error('TEST', 501)
139 content = json.loads(err.msg)['error']
140 assert content['code'] == 501
141 assert content['message'] == 'TEST'
145 self.adaptor.content_type = 'text/xml; charset=utf-8'
147 err = self.run_raise_error('this!', 503)
149 content = ET.fromstring(err.msg)
151 assert content.tag == 'error'
152 assert content.find('code').text == '503'
153 assert content.find('message').text == 'this!'
156 def test_raise_error_during_debug():
157 a = FakeAdaptor(params={'debug': '1'})
158 glue.setup_debugging(a)
159 loglib.log().section('Ongoing')
161 with pytest.raises(FakeError) as excinfo:
162 a.raise_error('badstate')
164 content = ET.fromstring(excinfo.value.msg)
166 assert content.tag == 'html'
168 assert '>Ongoing<' in excinfo.value.msg
169 assert 'badstate' in excinfo.value.msg
172 # ASGIAdaptor.build_response
174 def test_build_response_without_content_type():
175 resp = glue.build_response(FakeAdaptor(), 'attention')
177 assert isinstance(resp, FakeResponse)
178 assert resp.status == 200
179 assert resp.output == 'attention'
180 assert resp.content_type == 'text/plain; charset=utf-8'
183 def test_build_response_with_status():
184 a = FakeAdaptor(params={'format': 'json'})
185 glue.parse_format(a, napi.StatusResult, 'text')
187 resp = glue.build_response(a, 'stuff\nmore stuff', status=404)
189 assert isinstance(resp, FakeResponse)
190 assert resp.status == 404
191 assert resp.output == 'stuff\nmore stuff'
192 assert resp.content_type == 'application/json; charset=utf-8'
195 def test_build_response_jsonp_with_json():
196 a = FakeAdaptor(params={'format': 'json', 'json_callback': 'test.func'})
197 glue.parse_format(a, napi.StatusResult, 'text')
199 resp = glue.build_response(a, '{}')
201 assert isinstance(resp, FakeResponse)
202 assert resp.status == 200
203 assert resp.output == 'test.func({})'
204 assert resp.content_type == 'application/javascript; charset=utf-8'
207 def test_build_response_jsonp_without_json():
208 a = FakeAdaptor(params={'format': 'text', 'json_callback': 'test.func'})
209 glue.parse_format(a, napi.StatusResult, 'text')
211 resp = glue.build_response(a, '{}')
213 assert isinstance(resp, FakeResponse)
214 assert resp.status == 200
215 assert resp.output == '{}'
216 assert resp.content_type == 'text/plain; charset=utf-8'
219 @pytest.mark.parametrize('param', ['alert(); func', '\\n', '', 'a b'])
220 def test_build_response_jsonp_bad_format(param):
221 a = FakeAdaptor(params={'format': 'json', 'json_callback': param})
222 glue.parse_format(a, napi.StatusResult, 'text')
224 with pytest.raises(FakeError, match='^400 -- .*Invalid'):
225 glue.build_response(a, '{}')
230 class TestStatusEndpoint:
232 @pytest.fixture(autouse=True)
233 def patch_status_func(self, monkeypatch):
234 async def _status(*args, **kwargs):
237 monkeypatch.setattr(napi.NominatimAPIAsync, 'status', _status)
241 async def test_status_without_params(self):
243 self.status = napi.StatusResult(0, 'foo')
245 resp = await glue.status_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
247 assert isinstance(resp, FakeResponse)
248 assert resp.status == 200
249 assert resp.content_type == 'text/plain; charset=utf-8'
253 async def test_status_with_error(self):
255 self.status = napi.StatusResult(405, 'foo')
257 resp = await glue.status_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
259 assert isinstance(resp, FakeResponse)
260 assert resp.status == 500
261 assert resp.content_type == 'text/plain; charset=utf-8'
265 async def test_status_json_with_error(self):
266 a = FakeAdaptor(params={'format': 'json'})
267 self.status = napi.StatusResult(405, 'foo')
269 resp = await glue.status_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
271 assert isinstance(resp, FakeResponse)
272 assert resp.status == 200
273 assert resp.content_type == 'application/json; charset=utf-8'
277 async def test_status_bad_format(self):
278 a = FakeAdaptor(params={'format': 'foo'})
279 self.status = napi.StatusResult(0, 'foo')
281 with pytest.raises(FakeError):
282 await glue.status_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
287 class TestDetailsEndpoint:
289 @pytest.fixture(autouse=True)
290 def patch_lookup_func(self, monkeypatch):
291 self.result = napi.DetailedResult(napi.SourceTable.PLACEX,
293 napi.Point(1.0, 2.0))
294 self.lookup_args = []
296 async def _lookup(*args, **kwargs):
297 self.lookup_args.extend(args[1:])
300 monkeypatch.setattr(napi.NominatimAPIAsync, 'details', _lookup)
304 async def test_details_no_params(self):
307 with pytest.raises(FakeError, match='^400 -- .*Missing'):
308 await glue.details_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
312 async def test_details_by_place_id(self):
313 a = FakeAdaptor(params={'place_id': '4573'})
315 await glue.details_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
317 assert self.lookup_args[0].place_id == 4573
321 async def test_details_by_osm_id(self):
322 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45'})
324 await glue.details_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
326 assert self.lookup_args[0].osm_type == 'N'
327 assert self.lookup_args[0].osm_id == 45
328 assert self.lookup_args[0].osm_class is None
332 async def test_details_with_debugging(self):
333 a = FakeAdaptor(params={'osmtype': 'N', 'osmid': '45', 'debug': '1'})
335 resp = await glue.details_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
336 content = ET.fromstring(resp.output)
338 assert resp.content_type == 'text/html; charset=utf-8'
339 assert content.tag == 'html'
343 async def test_details_no_result(self):
344 a = FakeAdaptor(params={'place_id': '4573'})
347 with pytest.raises(FakeError, match='^404 -- .*found'):
348 await glue.details_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
352 class TestReverseEndPoint:
354 @pytest.fixture(autouse=True)
355 def patch_reverse_func(self, monkeypatch):
356 self.result = napi.ReverseResult(napi.SourceTable.PLACEX,
358 napi.Point(1.0, 2.0))
359 async def _reverse(*args, **kwargs):
362 monkeypatch.setattr(napi.NominatimAPIAsync, 'reverse', _reverse)
366 @pytest.mark.parametrize('params', [{}, {'lat': '3.4'}, {'lon': '6.7'}])
367 async def test_reverse_no_params(self, params):
370 a.params['format'] = 'xml'
372 with pytest.raises(FakeError, match='^400 -- (?s:.*)missing'):
373 await glue.reverse_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
377 @pytest.mark.parametrize('params', [{'lat': '45.6', 'lon': '4563'}])
378 async def test_reverse_success(self, params):
381 a.params['format'] = 'json'
383 res = await glue.reverse_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
389 async def test_reverse_success(self):
391 a.params['lat'] = '56.3'
392 a.params['lon'] = '6.8'
394 assert await glue.reverse_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
398 async def test_reverse_from_search(self):
400 a.params['q'] = '34.6 2.56'
401 a.params['format'] = 'json'
403 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
405 assert len(json.loads(res.output)) == 1
410 class TestLookupEndpoint:
412 @pytest.fixture(autouse=True)
413 def patch_lookup_func(self, monkeypatch):
414 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
416 napi.Point(1.0, 2.0))]
417 async def _lookup(*args, **kwargs):
418 return napi.SearchResults(self.results)
420 monkeypatch.setattr(napi.NominatimAPIAsync, 'lookup', _lookup)
424 async def test_lookup_no_params(self):
426 a.params['format'] = 'json'
428 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
430 assert res.output == '[]'
434 @pytest.mark.parametrize('param', ['w', 'bad', ''])
435 async def test_lookup_bad_params(self, param):
437 a.params['format'] = 'json'
438 a.params['osm_ids'] = f'W34,{param},N33333'
440 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
442 assert len(json.loads(res.output)) == 1
446 @pytest.mark.parametrize('param', ['p234234', '4563'])
447 async def test_lookup_bad_osm_type(self, param):
449 a.params['format'] = 'json'
450 a.params['osm_ids'] = f'W34,{param},N33333'
452 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
454 assert len(json.loads(res.output)) == 1
458 async def test_lookup_working(self):
460 a.params['format'] = 'json'
461 a.params['osm_ids'] = 'N23,W34'
463 res = await glue.lookup_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
465 assert len(json.loads(res.output)) == 1
470 class TestSearchEndPointSearch:
472 @pytest.fixture(autouse=True)
473 def patch_lookup_func(self, monkeypatch):
474 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
476 napi.Point(1.0, 2.0))]
477 async def _search(*args, **kwargs):
478 return napi.SearchResults(self.results)
480 monkeypatch.setattr(napi.NominatimAPIAsync, 'search', _search)
484 async def test_search_free_text(self):
486 a.params['q'] = 'something'
488 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
490 assert len(json.loads(res.output)) == 1
494 async def test_search_free_text_xml(self):
496 a.params['q'] = 'something'
497 a.params['format'] = 'xml'
499 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
501 assert res.status == 200
502 assert res.output.index('something') > 0
506 async def test_search_free_and_structured(self):
508 a.params['q'] = 'something'
509 a.params['city'] = 'ignored'
511 with pytest.raises(FakeError, match='^400 -- .*cannot be used together'):
512 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
516 @pytest.mark.parametrize('dedupe,numres', [(True, 1), (False, 2)])
517 async def test_search_dedupe(self, dedupe, numres):
518 self.results = self.results * 2
520 a.params['q'] = 'something'
522 a.params['dedupe'] = '0'
524 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
526 assert len(json.loads(res.output)) == numres
529 class TestSearchEndPointSearchAddress:
531 @pytest.fixture(autouse=True)
532 def patch_lookup_func(self, monkeypatch):
533 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
535 napi.Point(1.0, 2.0))]
536 async def _search(*args, **kwargs):
537 return napi.SearchResults(self.results)
539 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_address', _search)
543 async def test_search_structured(self):
545 a.params['street'] = 'something'
547 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
549 assert len(json.loads(res.output)) == 1
552 class TestSearchEndPointSearchCategory:
554 @pytest.fixture(autouse=True)
555 def patch_lookup_func(self, monkeypatch):
556 self.results = [napi.SearchResult(napi.SourceTable.PLACEX,
558 napi.Point(1.0, 2.0))]
559 async def _search(*args, **kwargs):
560 return napi.SearchResults(self.results)
562 monkeypatch.setattr(napi.NominatimAPIAsync, 'search_category', _search)
566 async def test_search_category(self):
568 a.params['q'] = '[shop=fog]'
570 res = await glue.search_endpoint(napi.NominatimAPIAsync(Path('/invalid')), a)
572 assert len(json.loads(res.output)) == 1