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 formatting results for the V1 API.
10 These test only ensure that the Python code is correct.
11 For functional tests see BDD test suite.
15 import xml.etree.ElementTree as ET
19 from nominatim_api.v1.format import dispatch as v1_format
20 import nominatim_api as napi
22 STATUS_FORMATS = {'text', 'json'}
27 def test_status_format_list():
28 assert set(v1_format.list_formats(napi.StatusResult)) == STATUS_FORMATS
31 @pytest.mark.parametrize('fmt', list(STATUS_FORMATS))
32 def test_status_supported(fmt):
33 assert v1_format.supports_format(napi.StatusResult, fmt)
36 def test_status_unsupported():
37 assert not v1_format.supports_format(napi.StatusResult, 'gagaga')
40 def test_status_format_text():
41 assert v1_format.format_result(napi.StatusResult(0, 'message here'), 'text', {}) \
45 def test_status_format_error_text():
46 assert v1_format.format_result(napi.StatusResult(500, 'message here'), 'text', {}) \
47 == 'ERROR: message here'
50 def test_status_format_json_minimal():
51 status = napi.StatusResult(700, 'Bad format.')
53 result = v1_format.format_result(status, 'json', {})
55 assert json.loads(result) == {'status': 700,
56 'message': 'Bad format.',
57 'software_version': napi.__version__}
60 def test_status_format_json_full():
61 status = napi.StatusResult(0, 'OK')
62 status.data_updated = dt.datetime(2010, 2, 7, 20, 20, 3, 0, tzinfo=dt.timezone.utc)
63 status.database_version = '5.6'
65 result = v1_format.format_result(status, 'json', {})
67 assert json.loads(result) == {'status': 0,
69 'data_updated': '2010-02-07T20:20:03+00:00',
70 'software_version': napi.__version__,
71 'database_version': '5.6'}
76 def test_search_details_minimal():
77 search = napi.DetailedResult(napi.SourceTable.PLACEX,
81 result = v1_format.format_result(search, 'json', {})
83 assert json.loads(result) == \
89 'calculated_importance': pytest.approx(0.00001),
95 'centroid': {'type': 'Point', 'coordinates': [1.0, 2.0]},
96 'geometry': {'type': 'Point', 'coordinates': [1.0, 2.0]},
100 def test_search_details_full():
101 import_date = dt.datetime(2010, 2, 7, 20, 20, 3, 0, tzinfo=dt.timezone.utc)
102 search = napi.DetailedResult(
103 source_table=napi.SourceTable.PLACEX,
104 category=('amenity', 'bank'),
105 centroid=napi.Point(56.947, -87.44),
108 linked_place_id=55693,
109 osm_object=('W', 442100),
111 names={'name': 'Bank', 'name:fr': 'Banque'},
112 address={'city': 'Niento', 'housenumber': ' 3'},
113 extratags={'atm': 'yes'},
121 indexed_date=import_date
123 napi.Locales().localize_results([search])
125 result = v1_format.format_result(search, 'json', {})
127 assert json.loads(result) == \
129 'parent_place_id': 114,
132 'category': 'amenity',
136 'names': {'name': 'Bank', 'name:fr': 'Banque'},
137 'addresstags': {'city': 'Niento', 'housenumber': ' 3'},
139 'calculated_postcode': '556 X23',
140 'country_code': 'll',
141 'indexed_date': '2010-02-07T20:20:03+00:00',
142 'importance': pytest.approx(0.0443),
143 'calculated_importance': pytest.approx(0.0443),
144 'extratags': {'atm': 'yes'},
145 'calculated_wikipedia': 'en:Bank',
149 'centroid': {'type': 'Point', 'coordinates': [56.947, -87.44]},
150 'geometry': {'type': 'Point', 'coordinates': [56.947, -87.44]},
154 @pytest.mark.parametrize('gtype,isarea', [('ST_Point', False),
155 ('ST_LineString', False),
156 ('ST_Polygon', True),
157 ('ST_MultiPolygon', True)])
158 def test_search_details_no_geometry(gtype, isarea):
159 search = napi.DetailedResult(napi.SourceTable.PLACEX,
161 napi.Point(1.0, 2.0),
162 geometry={'type': gtype})
164 result = v1_format.format_result(search, 'json', {})
165 js = json.loads(result)
167 assert js['geometry'] == {'type': 'Point', 'coordinates': [1.0, 2.0]}
168 assert js['isarea'] == isarea
171 def test_search_details_with_geometry():
172 search = napi.DetailedResult(
173 napi.SourceTable.PLACEX,
175 napi.Point(1.0, 2.0),
176 geometry={'geojson': '{"type":"Point","coordinates":[56.947,-87.44]}'})
178 result = v1_format.format_result(search, 'json', {})
179 js = json.loads(result)
181 assert js['geometry'] == {'type': 'Point', 'coordinates': [56.947, -87.44]}
182 assert js['isarea'] is False
185 def test_search_details_with_icon_available():
186 search = napi.DetailedResult(napi.SourceTable.PLACEX,
187 ('amenity', 'restaurant'),
188 napi.Point(1.0, 2.0))
190 result = v1_format.format_result(search, 'json', {'icon_base_url': 'foo'})
191 js = json.loads(result)
193 assert js['icon'] == 'foo/food_restaurant.p.20.png'
196 def test_search_details_with_icon_not_available():
197 search = napi.DetailedResult(napi.SourceTable.PLACEX,
199 napi.Point(1.0, 2.0))
201 result = v1_format.format_result(search, 'json', {'icon_base_url': 'foo'})
202 js = json.loads(result)
204 assert 'icon' not in js
207 def test_search_details_with_address_minimal():
208 search = napi.DetailedResult(napi.SourceTable.PLACEX,
210 napi.Point(1.0, 2.0),
212 napi.AddressLine(place_id=None,
214 category=('bnd', 'note'),
224 result = v1_format.format_result(search, 'json', {})
225 js = json.loads(result)
227 assert js['address'] == [{'localname': '',
235 @pytest.mark.parametrize('field,outfield', [('address_rows', 'address'),
236 ('linked_rows', 'linked_places'),
237 ('parented_rows', 'hierarchy')
239 def test_search_details_with_further_infos(field, outfield):
240 search = napi.DetailedResult(napi.SourceTable.PLACEX,
242 napi.Point(1.0, 2.0))
244 setattr(search, field, [napi.AddressLine(place_id=3498,
245 osm_object=('R', 442),
246 category=('bnd', 'note'),
247 names={'name': 'Trespass'},
248 extratags={'access': 'no',
249 'place_type': 'spec'},
257 result = v1_format.format_result(search, 'json', {})
258 js = json.loads(result)
260 assert js[outfield] == [{'localname': 'Trespass',
264 'place_type': 'spec',
273 def test_search_details_grouped_hierarchy():
274 search = napi.DetailedResult(napi.SourceTable.PLACEX,
276 napi.Point(1.0, 2.0),
277 parented_rows=[napi.AddressLine(
279 osm_object=('R', 442),
280 category=('bnd', 'note'),
281 names={'name': 'Trespass'},
282 extratags={'access': 'no',
283 'place_type': 'spec'},
290 result = v1_format.format_result(search, 'json', {'group_hierarchy': True})
291 js = json.loads(result)
293 assert js['hierarchy'] == {'note': [{'localname': 'Trespass',
297 'place_type': 'spec',
306 def test_search_details_keywords_name():
307 search = napi.DetailedResult(napi.SourceTable.PLACEX,
309 napi.Point(1.0, 2.0),
311 napi.WordInfo(23, 'foo', 'mefoo'),
312 napi.WordInfo(24, 'foo', 'bafoo')])
314 result = v1_format.format_result(search, 'json', {'keywords': True})
315 js = json.loads(result)
317 assert js['keywords'] == {'name': [{'id': 23, 'token': 'foo'},
318 {'id': 24, 'token': 'foo'}],
322 def test_search_details_keywords_address():
323 search = napi.DetailedResult(napi.SourceTable.PLACEX,
325 napi.Point(1.0, 2.0),
327 napi.WordInfo(23, 'foo', 'mefoo'),
328 napi.WordInfo(24, 'foo', 'bafoo')])
330 result = v1_format.format_result(search, 'json', {'keywords': True})
331 js = json.loads(result)
333 assert js['keywords'] == {'address': [{'id': 23, 'token': 'foo'},
334 {'id': 24, 'token': 'foo'}],
338 # admin_level injection into extratags
340 SEARCH_FORMATS = ['json', 'jsonv2', 'geojson', 'geocodejson', 'xml']
343 @pytest.mark.parametrize('fmt', SEARCH_FORMATS)
344 def test_search_extratags_boundary_administrative_injects_admin_level(fmt):
345 search = napi.SearchResult(napi.SourceTable.PLACEX,
346 ('boundary', 'administrative'),
347 napi.Point(1.0, 2.0),
349 extratags={'place': 'city'})
351 raw = v1_format.format_result(napi.SearchResults([search]), fmt,
355 root = ET.fromstring(raw)
356 tags = {tag.attrib['key']: tag.attrib['value']
357 for tag in root.find('.//extratags').findall('tag')}
358 assert tags['admin_level'] == '6'
359 assert tags['place'] == 'city'
361 result = json.loads(raw)
362 if fmt == 'geocodejson':
363 extra = result['features'][0]['properties']['geocoding']['extra']
364 elif fmt == 'geojson':
365 extra = result['features'][0]['properties']['extratags']
367 extra = result[0]['extratags']
369 assert extra['admin_level'] == '6'
370 assert extra['place'] == 'city'
373 @pytest.mark.parametrize('fmt', SEARCH_FORMATS)
374 def test_search_extratags_non_boundary_no_admin_level_injection(fmt):
375 search = napi.SearchResult(napi.SourceTable.PLACEX,
377 napi.Point(1.0, 2.0),
379 extratags={'place': 'city'})
381 raw = v1_format.format_result(napi.SearchResults([search]), fmt,
385 root = ET.fromstring(raw)
386 tags = {tag.attrib['key']: tag.attrib['value']
387 for tag in root.find('.//extratags').findall('tag')}
388 assert 'admin_level' not in tags
389 assert tags['place'] == 'city'
391 result = json.loads(raw)
392 if fmt == 'geocodejson':
393 extra = result['features'][0]['properties']['geocoding']['extra']
394 elif fmt == 'geojson':
395 extra = result['features'][0]['properties']['extratags']
397 extra = result[0]['extratags']
399 assert 'admin_level' not in extra
400 assert extra['place'] == 'city'
403 @pytest.mark.parametrize('fmt', SEARCH_FORMATS)
404 def test_search_extratags_boundary_admin_level_15_no_injection(fmt):
405 search = napi.SearchResult(napi.SourceTable.PLACEX,
406 ('boundary', 'administrative'),
407 napi.Point(1.0, 2.0),
409 extratags={'place': 'city'})
411 raw = v1_format.format_result(napi.SearchResults([search]), fmt,
415 root = ET.fromstring(raw)
416 tags = {tag.attrib['key']: tag.attrib['value']
417 for tag in root.find('.//extratags').findall('tag')}
418 assert 'admin_level' not in tags
419 assert tags['place'] == 'city'
421 result = json.loads(raw)
422 if fmt == 'geocodejson':
423 extra = result['features'][0]['properties']['geocoding']['extra']
424 elif fmt == 'geojson':
425 extra = result['features'][0]['properties']['extratags']
427 extra = result[0]['extratags']
429 assert 'admin_level' not in extra
430 assert extra['place'] == 'city'