]> git.openstreetmap.org Git - nominatim.git/blob - test/python/api/test_result_formatting_v1.py
adapt tests to newly added table constraints
[nominatim.git] / test / python / api / test_result_formatting_v1.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2025 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Tests for formatting results for the V1 API.
9
10 These test only ensure that the Python code is correct.
11 For functional tests see BDD test suite.
12 """
13 import datetime as dt
14 import json
15 import xml.etree.ElementTree as ET
16
17 import pytest
18
19 from nominatim_api.v1.format import dispatch as v1_format
20 import nominatim_api as napi
21
22 STATUS_FORMATS = {'text', 'json'}
23
24 # StatusResult
25
26
27 def test_status_format_list():
28     assert set(v1_format.list_formats(napi.StatusResult)) == STATUS_FORMATS
29
30
31 @pytest.mark.parametrize('fmt', list(STATUS_FORMATS))
32 def test_status_supported(fmt):
33     assert v1_format.supports_format(napi.StatusResult, fmt)
34
35
36 def test_status_unsupported():
37     assert not v1_format.supports_format(napi.StatusResult, 'gagaga')
38
39
40 def test_status_format_text():
41     assert v1_format.format_result(napi.StatusResult(0, 'message here'), 'text', {}) \
42         == 'OK'
43
44
45 def test_status_format_error_text():
46     assert v1_format.format_result(napi.StatusResult(500, 'message here'), 'text', {}) \
47         == 'ERROR: message here'
48
49
50 def test_status_format_json_minimal():
51     status = napi.StatusResult(700, 'Bad format.')
52
53     result = v1_format.format_result(status, 'json', {})
54
55     assert json.loads(result) == {'status': 700,
56                                   'message': 'Bad format.',
57                                   'software_version': napi.__version__}
58
59
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'
64
65     result = v1_format.format_result(status, 'json', {})
66
67     assert json.loads(result) == {'status': 0,
68                                   'message': 'OK',
69                                   'data_updated': '2010-02-07T20:20:03+00:00',
70                                   'software_version': napi.__version__,
71                                   'database_version': '5.6'}
72
73
74 # DetailedResult
75
76 def test_search_details_minimal():
77     search = napi.DetailedResult(napi.SourceTable.PLACEX,
78                                  ('place', 'thing'),
79                                  napi.Point(1.0, 2.0))
80
81     result = v1_format.format_result(search, 'json', {})
82
83     assert json.loads(result) == \
84            {'category': 'place',
85             'type': 'thing',
86             'admin_level': 15,
87             'names': {},
88             'localname': '',
89             'calculated_importance': pytest.approx(0.00001),
90             'rank_address': 30,
91             'rank_search': 30,
92             'isarea': False,
93             'addresstags': {},
94             'extratags': {},
95             'centroid': {'type': 'Point', 'coordinates': [1.0, 2.0]},
96             'geometry': {'type': 'Point', 'coordinates': [1.0, 2.0]},
97             }
98
99
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),
106                   place_id=37563,
107                   parent_place_id=114,
108                   linked_place_id=55693,
109                   osm_object=('W', 442100),
110                   admin_level=14,
111                   names={'name': 'Bank', 'name:fr': 'Banque'},
112                   address={'city': 'Niento', 'housenumber': '  3'},
113                   extratags={'atm': 'yes'},
114                   housenumber='3',
115                   postcode='556 X23',
116                   wikipedia='en:Bank',
117                   rank_address=29,
118                   rank_search=28,
119                   importance=0.0443,
120                   country_code='ll',
121                   indexed_date=import_date
122                   )
123     napi.Locales().localize_results([search])
124
125     result = v1_format.format_result(search, 'json', {})
126
127     assert json.loads(result) == \
128            {'place_id': 37563,
129             'parent_place_id': 114,
130             'osm_type': 'W',
131             'osm_id': 442100,
132             'category': 'amenity',
133             'type': 'bank',
134             'admin_level': 14,
135             'localname': 'Bank',
136             'names': {'name': 'Bank', 'name:fr': 'Banque'},
137             'addresstags': {'city': 'Niento', 'housenumber': '  3'},
138             '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',
146             'rank_address': 29,
147             'rank_search': 28,
148             'isarea': False,
149             'centroid': {'type': 'Point', 'coordinates': [56.947, -87.44]},
150             'geometry': {'type': 'Point', 'coordinates': [56.947, -87.44]},
151             }
152
153
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,
160                                  ('place', 'thing'),
161                                  napi.Point(1.0, 2.0),
162                                  geometry={'type': gtype})
163
164     result = v1_format.format_result(search, 'json', {})
165     js = json.loads(result)
166
167     assert js['geometry'] == {'type': 'Point', 'coordinates': [1.0, 2.0]}
168     assert js['isarea'] == isarea
169
170
171 def test_search_details_with_geometry():
172     search = napi.DetailedResult(
173         napi.SourceTable.PLACEX,
174         ('place', 'thing'),
175         napi.Point(1.0, 2.0),
176         geometry={'geojson': '{"type":"Point","coordinates":[56.947,-87.44]}'})
177
178     result = v1_format.format_result(search, 'json', {})
179     js = json.loads(result)
180
181     assert js['geometry'] == {'type': 'Point', 'coordinates': [56.947, -87.44]}
182     assert js['isarea'] is False
183
184
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))
189
190     result = v1_format.format_result(search, 'json', {'icon_base_url': 'foo'})
191     js = json.loads(result)
192
193     assert js['icon'] == 'foo/food_restaurant.p.20.png'
194
195
196 def test_search_details_with_icon_not_available():
197     search = napi.DetailedResult(napi.SourceTable.PLACEX,
198                                  ('amenity', 'tree'),
199                                  napi.Point(1.0, 2.0))
200
201     result = v1_format.format_result(search, 'json', {'icon_base_url': 'foo'})
202     js = json.loads(result)
203
204     assert 'icon' not in js
205
206
207 def test_search_details_with_address_minimal():
208     search = napi.DetailedResult(napi.SourceTable.PLACEX,
209                                  ('place', 'thing'),
210                                  napi.Point(1.0, 2.0),
211                                  address_rows=[
212                                    napi.AddressLine(place_id=None,
213                                                     osm_object=None,
214                                                     category=('bnd', 'note'),
215                                                     names={},
216                                                     extratags=None,
217                                                     admin_level=None,
218                                                     fromarea=False,
219                                                     isaddress=False,
220                                                     rank_address=10,
221                                                     distance=0.0)
222                                  ])
223
224     result = v1_format.format_result(search, 'json', {})
225     js = json.loads(result)
226
227     assert js['address'] == [{'localname': '',
228                               'class': 'bnd',
229                               'type': 'note',
230                               'rank_address': 10,
231                               'distance': 0.0,
232                               'isaddress': False}]
233
234
235 @pytest.mark.parametrize('field,outfield', [('address_rows', 'address'),
236                                             ('linked_rows', 'linked_places'),
237                                             ('parented_rows', 'hierarchy')
238                                             ])
239 def test_search_details_with_further_infos(field, outfield):
240     search = napi.DetailedResult(napi.SourceTable.PLACEX,
241                                  ('place', 'thing'),
242                                  napi.Point(1.0, 2.0))
243
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'},
250                                              admin_level=4,
251                                              fromarea=True,
252                                              isaddress=True,
253                                              rank_address=10,
254                                              distance=0.034)
255                             ])
256
257     result = v1_format.format_result(search, 'json', {})
258     js = json.loads(result)
259
260     assert js[outfield] == [{'localname': 'Trespass',
261                              'place_id': 3498,
262                              'osm_id': 442,
263                              'osm_type': 'R',
264                              'place_type': 'spec',
265                              'class': 'bnd',
266                              'type': 'note',
267                              'admin_level': 4,
268                              'rank_address': 10,
269                              'distance': 0.034,
270                              'isaddress': True}]
271
272
273 def test_search_details_grouped_hierarchy():
274     search = napi.DetailedResult(napi.SourceTable.PLACEX,
275                                  ('place', 'thing'),
276                                  napi.Point(1.0, 2.0),
277                                  parented_rows=[napi.AddressLine(
278                                     place_id=3498,
279                                     osm_object=('R', 442),
280                                     category=('bnd', 'note'),
281                                     names={'name': 'Trespass'},
282                                     extratags={'access': 'no',
283                                                'place_type': 'spec'},
284                                     admin_level=4,
285                                     fromarea=True,
286                                     isaddress=True,
287                                     rank_address=10,
288                                     distance=0.034)])
289
290     result = v1_format.format_result(search, 'json', {'group_hierarchy': True})
291     js = json.loads(result)
292
293     assert js['hierarchy'] == {'note': [{'localname': 'Trespass',
294                                          'place_id': 3498,
295                                          'osm_id': 442,
296                                          'osm_type': 'R',
297                                          'place_type': 'spec',
298                                          'class': 'bnd',
299                                          'type': 'note',
300                                          'admin_level': 4,
301                                          'rank_address': 10,
302                                          'distance': 0.034,
303                                          'isaddress': True}]}
304
305
306 def test_search_details_keywords_name():
307     search = napi.DetailedResult(napi.SourceTable.PLACEX,
308                                  ('place', 'thing'),
309                                  napi.Point(1.0, 2.0),
310                                  name_keywords=[
311                                      napi.WordInfo(23, 'foo', 'mefoo'),
312                                      napi.WordInfo(24, 'foo', 'bafoo')])
313
314     result = v1_format.format_result(search, 'json', {'keywords': True})
315     js = json.loads(result)
316
317     assert js['keywords'] == {'name': [{'id': 23, 'token': 'foo'},
318                                        {'id': 24, 'token': 'foo'}],
319                               'address': []}
320
321
322 def test_search_details_keywords_address():
323     search = napi.DetailedResult(napi.SourceTable.PLACEX,
324                                  ('place', 'thing'),
325                                  napi.Point(1.0, 2.0),
326                                  address_keywords=[
327                                      napi.WordInfo(23, 'foo', 'mefoo'),
328                                      napi.WordInfo(24, 'foo', 'bafoo')])
329
330     result = v1_format.format_result(search, 'json', {'keywords': True})
331     js = json.loads(result)
332
333     assert js['keywords'] == {'address': [{'id': 23, 'token': 'foo'},
334                                           {'id': 24, 'token': 'foo'}],
335                               'name': []}
336
337
338 # admin_level injection into extratags
339
340 SEARCH_FORMATS = ['json', 'jsonv2', 'geojson', 'geocodejson', 'xml']
341
342
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),
348                                admin_level=6,
349                                extratags={'place': 'city'})
350
351     raw = v1_format.format_result(napi.SearchResults([search]), fmt,
352                                   {'extratags': True})
353
354     if fmt == 'xml':
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'
360     else:
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']
366         else:
367             extra = result[0]['extratags']
368
369         assert extra['admin_level'] == '6'
370         assert extra['place'] == 'city'
371
372
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,
376                                ('place', 'city'),
377                                napi.Point(1.0, 2.0),
378                                admin_level=8,
379                                extratags={'place': 'city'})
380
381     raw = v1_format.format_result(napi.SearchResults([search]), fmt,
382                                   {'extratags': True})
383
384     if fmt == 'xml':
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'
390     else:
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']
396         else:
397             extra = result[0]['extratags']
398
399         assert 'admin_level' not in extra
400         assert extra['place'] == 'city'
401
402
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),
408                                admin_level=15,
409                                extratags={'place': 'city'})
410
411     raw = v1_format.format_result(napi.SearchResults([search]), fmt,
412                                   {'extratags': True})
413
414     if fmt == 'xml':
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'
420     else:
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']
426         else:
427             extra = result[0]['extratags']
428
429         assert 'admin_level' not in extra
430         assert extra['place'] == 'city'