]> git.openstreetmap.org Git - nominatim.git/commitdiff
Merge remote-tracking branch 'upstream/master'
authorSarah Hoffmann <lonvia@denofr.de>
Fri, 10 Mar 2023 09:01:43 +0000 (10:01 +0100)
committerSarah Hoffmann <lonvia@denofr.de>
Fri, 10 Mar 2023 09:01:43 +0000 (10:01 +0100)
35 files changed:
CONTRIBUTING.md
SECURITY.md
docs/customize/Import-Styles.md
docs/customize/Tokenizers.md
lib-php/DebugHtml.php
lib-php/PlaceLookup.php
nominatim/api/logging.py
nominatim/data/place_info.py
nominatim/data/place_name.py
nominatim/tokenizer/sanitizers/clean_postcodes.py
nominatim/tokenizer/sanitizers/clean_tiger_tags.py
nominatim/tokenizer/sanitizers/delete_tags.py [new file with mode: 0644]
nominatim/tokenizer/sanitizers/tag_analyzer_by_language.py
test/bdd/api/details/params.feature
test/bdd/api/details/simple.feature
test/bdd/api/reverse/geocodejson.feature [deleted file]
test/bdd/api/reverse/geometry.feature [new file with mode: 0644]
test/bdd/api/reverse/language.feature
test/bdd/api/reverse/params.feature [deleted file]
test/bdd/api/reverse/queries.feature
test/bdd/api/reverse/simple.feature [deleted file]
test/bdd/api/reverse/v1_geocodejson.feature [new file with mode: 0644]
test/bdd/api/reverse/v1_geojson.feature [new file with mode: 0644]
test/bdd/api/reverse/v1_json.feature [new file with mode: 0644]
test/bdd/api/reverse/v1_params.feature [new file with mode: 0644]
test/bdd/api/reverse/v1_xml.feature [new file with mode: 0644]
test/bdd/db/import/interpolation.feature
test/bdd/db/import/linking.feature
test/bdd/db/import/parenting.feature
test/bdd/db/query/interpolation.feature
test/bdd/db/update/linked_places.feature
test/bdd/steps/check_functions.py
test/bdd/steps/http_responses.py
test/bdd/steps/steps_api_queries.py
test/python/tokenizer/sanitizers/test_delete_tags.py [new file with mode: 0644]

index 6d75ce57f1e3a7acf8f36569015d9a69ad8e4c03..8baadb28e4d63bf31ef3230e4ec25a0686026f68 100644 (file)
@@ -69,7 +69,7 @@ Before submitting a pull request make sure that the tests pass:
 
 Nominatim follows semantic versioning. Major releases are done for large changes
 that require (or at least strongly recommend) a reimport of the databases.
-Minor releases can usually be applied to exisiting databases Patch releases
+Minor releases can usually be applied to exisiting databases. Patch releases
 contain bug fixes only and are released from a separate branch where the
 relevant changes are cherry-picked from the master branch.
 
index e8e6fcade75944428466c1f764e2463deebe71e5..d023c1e5b834cfc0fcc4fd74509fc9c0b9eb08a5 100644 (file)
@@ -13,7 +13,6 @@ versions.
 | 4.1.x   | 2024-08-05                          |
 | 4.0.x   | 2023-11-02                          |
 | 3.7.x   | 2023-04-05                          |
-| 3.6.x   | 2022-12-12                          |
 
 ## Reporting a Vulnerability
 
@@ -38,3 +37,4 @@ incident. Announcements will also be published at the
 ## List of Previous Incidents
 
 * 2020-05-04 - [SQL injection issue on /details endpoint](https://lists.openstreetmap.org/pipermail/geocoding/2020-May/002012.html)
+* 2023-02-21 - [cross-site scripting vulnerability](https://nominatim.org/2023/02/21/release-421.html)
index 6085d4e473f0efb4944d2a5eac9c0316f79da733..e96f96e039baaacbf99c7316f698a2a05708430a 100644 (file)
@@ -127,7 +127,7 @@ to the user when requested, but are not used otherwise.
   values. Tags with matching key/value pairs are deleted.
 * __extra_keys__ is a _key match list_ for tags which should be saved into
   extratags
-* __delete_tags__ contains a table of tag keys pointing to a list of tag
+* __extra_tags__ contains a table of tag keys pointing to a list of tag
   values. Tags with matching key/value pairs are moved to extratags.
 
 Key list may contain three kinds of strings:
@@ -193,14 +193,14 @@ Address tags will be used to build up the address of an object.
 `set_address_tags()` takes a table with arbitrary fields pointing to
 _key match lists_. To fields have a special meaning:
 
-__main__ defines
+* __main__: defines
 the tags that make a full address object out of the OSM object. This
 is usually the housenumber or variants thereof. If a main address tag
 appears, then the object will always be included, if necessary with a
 fallback of `place=house`. If the key has a prefix of `addr:` or `is_in:`
 this will be stripped.
 
-__extra__ defines all supplementary tags for addresses, tags like `addr:street`, `addr:city` etc. If the key has a prefix of `addr:` or `is_in:` this will be stripped.
+* __extra__: defines all supplementary tags for addresses, tags like `addr:street`, `addr:city` etc. If the key has a prefix of `addr:` or `is_in:` this will be stripped.
 
 All other fields will be handled as summary fields. If a key matches the
 key match list, then its value will be added to the address tags with the
index 58606c29d0176822ce3364324520b34634a5e242..11c27e38b903ae0683ace099f417ec16b1077bc8 100644 (file)
@@ -102,7 +102,7 @@ Here is an example configuration file:
 ``` yaml
 normalization:
     - ":: lower ()"
-    - "ß > 'ss'" # German szet is unimbigiously equal to double ss
+    - "ß > 'ss'" # German szet is unambiguously equal to double ss
 transliteration:
     - !include /etc/nominatim/icu-rules/extended-unicode-to-asccii.yaml
     - ":: Ascii ()"
@@ -128,7 +128,7 @@ The configuration file contains four sections:
 The normalization and transliteration sections each define a set of
 ICU rules that are applied to the names.
 
-The **normalisation** rules are applied after sanitation. They should remove
+The **normalization** rules are applied after sanitation. They should remove
 any information that is not relevant for search at all. Usual rules to be
 applied here are: lower-casing, removing of special characters, cleanup of
 spaces.
@@ -221,7 +221,13 @@ The following is a list of sanitizers that are shipped with Nominatim.
     rendering:
         heading_level: 6
 
+#### delete-tags
 
+::: nominatim.tokenizer.sanitizers.delete_tags
+    selection:
+        members: False
+    rendering:
+        heading_level: 6
 
 #### Token Analysis
 
index 2207d52915cfcefcb66184d8f6197d72d019af70..7b0cba2d0e569114a90d7f446ac5543717e6a923 100644 (file)
@@ -135,7 +135,7 @@ class Debug
 
     public static function printSQL($sSQL)
     {
-        echo '<p><tt><b>'.date('c').'</b> <font color="#aaa">'.htmlspecialchars($sSQL).'</font></tt></p>'."\n";
+        echo '<p><tt><b>'.date('c').'</b> <font color="#aaa">'.htmlspecialchars($sSQL, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401).'</font></tt></p>'."\n";
     }
 
     private static function outputVar($mVar, $sPreNL)
@@ -183,7 +183,7 @@ class Debug
             $sOut = (string)$mVar;
         }
 
-        echo htmlspecialchars($sOut);
+        echo htmlspecialchars($sOut, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401);
         return strlen($sOut);
     }
 }
index ba4f50bc8d4c6e4030dc068cdf61e5e9ba7eb9f7..76a093c4a98b79170b3884c4f89c3719f60a247e 100644 (file)
@@ -524,12 +524,7 @@ class PlaceLookup
 
         // Get the bounding box and outline polygon
         $sSQL = 'select place_id,0 as numfeatures,st_area(geometry) as area,';
-        if ($fLonReverse != null && $fLatReverse != null) {
-            $sSQL .= ' ST_Y(closest_point) as centrelat,';
-            $sSQL .= ' ST_X(closest_point) as centrelon,';
-        } else {
-            $sSQL .= ' ST_Y(centroid) as centrelat, ST_X(centroid) as centrelon,';
-        }
+        $sSQL .= ' ST_Y(centroid) as centrelat, ST_X(centroid) as centrelon,';
         $sSQL .= ' ST_YMin(geometry) as minlat,ST_YMax(geometry) as maxlat,';
         $sSQL .= ' ST_XMin(geometry) as minlon,ST_XMax(geometry) as maxlon';
         if ($this->bIncludePolygonAsGeoJSON) {
@@ -544,19 +539,21 @@ class PlaceLookup
         if ($this->bIncludePolygonAsText) {
             $sSQL .= ',ST_AsText(geometry) as astext';
         }
+
+        $sSQL .= ' FROM (SELECT place_id';
         if ($fLonReverse != null && $fLatReverse != null) {
-            $sFrom = ' from (SELECT * , CASE WHEN (class = \'highway\') AND (ST_GeometryType(geometry) = \'ST_LineString\') THEN ';
-            $sFrom .=' ST_ClosestPoint(geometry, ST_SetSRID(ST_Point('.$fLatReverse.','.$fLonReverse.'),4326))';
-            $sFrom .=' ELSE centroid END AS closest_point';
-            $sFrom .= ' from placex where place_id = '.$iPlaceID.') as plx';
+            $sSQL .= ',CASE WHEN (class = \'highway\') AND (ST_GeometryType(geometry) = \'ST_LineString\') THEN ';
+            $sSQL .=' ST_ClosestPoint(geometry, ST_SetSRID(ST_Point('.$fLatReverse.','.$fLonReverse.'),4326))';
+            $sSQL .=' ELSE centroid END AS centroid';
         } else {
-            $sFrom = ' from placex where place_id = '.$iPlaceID;
+            $sSQL .= ',centroid';
         }
         if ($this->fPolygonSimplificationThreshold > 0) {
-            $sSQL .= ' from (select place_id,centroid,ST_SimplifyPreserveTopology(geometry,'.$this->fPolygonSimplificationThreshold.') as geometry'.$sFrom.') as plx';
+            $sSQL .= ',ST_SimplifyPreserveTopology(geometry,'.$this->fPolygonSimplificationThreshold.') as geometry';
         } else {
-            $sSQL .= $sFrom;
+            $sSQL .= ',geometry';
         }
+        $sSQL .= ' FROM placex where place_id = '.$iPlaceID.') as plx';
 
         $aPointPolygon = $this->oDB->getRow($sSQL, null, 'Could not get outline');
 
index e9c8847045a7560c3db62278af03eb84113cf0f6..3759ba1b1a3f5c0d25932e6f78536b11202c2a73 100644 (file)
@@ -96,7 +96,7 @@ class HTMLLogger(BaseLogger):
                       .compile(conn.sync_engine, compile_kwargs={"literal_binds": True}))
         if CODE_HIGHLIGHT:
             sqlstr = highlight(sqlstr, PostgresLexer(),
-                               HtmlFormatter(nowrap=True, lineseparator='<br>'))
+                               HtmlFormatter(nowrap=True, lineseparator='<br />'))
             self._write(f'<div class="highlight"><code class="lang-sql">{sqlstr}</code></div>')
         else:
             self._write(f'<code class="lang-sql">{sqlstr}</code>')
index 1bfd512c38e169e318373ff61a04aab3cd122f4f..91e77a588514ca4c2cd457d95286ca49883dd7e0 100644 (file)
@@ -55,7 +55,7 @@ class PlaceInfo:
 
     @property
     def rank_address(self) -> int:
-        """ The [rank address][1] before ant rank correction is applied.
+        """ The [rank address][1] before any rank correction is applied.
 
             [1]: ../customize/Ranking.md#address-rank
         """
index f4c5e0fa30d21b3fb4bfa7e70eb5cf18f90ae7b4..47464e2b323b4c753f7e58e2c433e77dec57ea59 100644 (file)
@@ -21,7 +21,7 @@ class PlaceName:
 
         In addition to that, a name may have arbitrary additional attributes.
         How attributes are used, depends on the sanitizers and token analysers.
-        The exception is is the 'analyzer' attribute. This attribute determines
+        The exception is the 'analyzer' attribute. This attribute determines
         which token analysis module will be used to finalize the treatment of
         names.
     """
index 593f770db9ade7b1e9d8915d6de85d19003d63b5..5eaea3917c7aea9a2e8047f773cd03ac17990d34 100644 (file)
@@ -74,7 +74,7 @@ class _PostcodeSanitizer:
 
 
 def create(config: SanitizerConfig) -> Callable[[ProcessInfo], None]:
-    """ Create a housenumber processing function.
+    """ Create a function that filters postcodes by their officially allowed pattern.
     """
 
     return _PostcodeSanitizer(config)
index 9698a326a9fe2157d2bb7ca237e57ebb8d79846c..8b4d337d5f8b68c1db6c082e08e5330f18185d22 100644 (file)
@@ -41,6 +41,6 @@ def _clean_tiger_county(obj: ProcessInfo) -> None:
 
 
 def create(_: SanitizerConfig) -> Callable[[ProcessInfo], None]:
-    """ Create a housenumber processing function.
+    """ Create a function that preprocesses tags from the TIGER import.
     """
     return _clean_tiger_county
diff --git a/nominatim/tokenizer/sanitizers/delete_tags.py b/nominatim/tokenizer/sanitizers/delete_tags.py
new file mode 100644 (file)
index 0000000..fd35de4
--- /dev/null
@@ -0,0 +1,144 @@
+# SPDX-License-Identifier: GPL-2.0-only\r
+#\r
+# This file is part of Nominatim. (https://nominatim.org)\r
+#\r
+# Copyright (C) 2023 by the Nominatim developer community.\r
+# For a full list of authors see the git log.\r
+"""\r
+Sanitizer which prevents certain tags from getting into the search index.\r
+It remove tags which matches all properties given below.\r
+\r
+\r
+Arguments:\r
+    type: Define which type of tags should be considered for removal.\r
+          There are two types of tags 'name' and 'address' tags.\r
+          Takes a string 'name' or 'address'. (default: 'name')\r
+\r
+    filter-kind: Define which 'kind' of tags should be removed.\r
+                 Takes a string or list of strings where each\r
+                 string is a regular expression. A tag is considered\r
+                 to be a candidate for removal if its 'kind' property\r
+                 fully matches any of the given regular expressions.\r
+                 Note that by default all 'kind' of tags are considered.\r
+\r
+    suffix: Define the 'suffix' property of the tags which should be\r
+            removed. Takes a string or list of strings where each\r
+            string is a regular expression. A tag is considered to be a\r
+            candidate for removal if its 'suffix' property fully\r
+            matches any of the given regular expressions. Note that by\r
+            default tags with any suffix value are considered including\r
+            those which don't have a suffix at all.\r
+\r
+    name: Define the 'name' property corresponding to the 'kind' property\r
+          of the tag. Takes a string or list of strings where each string\r
+          is a regular expression. A tag is considered to be a candidate\r
+          for removal if its name fully matches any of the given regular\r
+          expressions. Note that by default tags with any 'name' are\r
+          considered.\r
+\r
+    country_code: Define the country code of places whose tags should be\r
+                  considered for removed. Takes a string or list of strings\r
+                  where each string is a two-letter lower-case country code.\r
+                  Note that by default tags of places with any country code\r
+                  are considered including those which don't have a country\r
+                  code at all.\r
+\r
+    rank_address: Define the address rank of places whose tags should be\r
+                  considered for removal. Takes a string or list of strings\r
+                  where each string is a number or range of number or the\r
+                  form <from>-<to>.\r
+                  Note that default is '0-30', which means that tags of all\r
+                  places are considered.\r
+                  See https://nominatim.org/release-docs/latest/customize/Ranking/#address-rank\r
+                  to learn more about address rank.\r
+\r
+\r
+"""\r
+from typing import Callable, List, Optional, Pattern, Tuple, Sequence\r
+import re\r
+\r
+from nominatim.tokenizer.sanitizers.base import ProcessInfo\r
+from nominatim.data.place_name import PlaceName\r
+from nominatim.tokenizer.sanitizers.config import SanitizerConfig\r
+\r
+class _TagSanitizer:\r
+\r
+    def __init__(self, config: SanitizerConfig) -> None:\r
+        self.type = config.get('type', 'name')\r
+        self.filter_kind = config.get_filter_kind()\r
+        self.country_codes = config.get_string_list('country_code', [])\r
+        self.allowed_ranks = self._set_allowed_ranks( \\r
+                                            config.get_string_list('rank_address', ['0-30']))\r
+\r
+        self.has_country_code = config.get('country_code', None) is not None\r
+\r
+        suffixregexps = config.get_string_list('suffix', [r'[\s\S]*'])\r
+        self.suffix_regexp = [re.compile(r) for r in suffixregexps]\r
+\r
+        nameregexps = config.get_string_list('name', [r'[\s\S]*'])\r
+        self.name_regexp = [re.compile(r) for r in nameregexps]\r
+\r
+\r
+\r
+    def __call__(self, obj: ProcessInfo) -> None:\r
+        tags = obj.names if self.type == 'name' else obj.address\r
+\r
+        if (not tags or\r
+             self.has_country_code and\r
+              obj.place.country_code not in self.country_codes or\r
+               not self.allowed_ranks[obj.place.rank_address]):\r
+            return\r
+\r
+        filtered_tags: List[PlaceName] = []\r
+\r
+        for tag in tags:\r
+\r
+            if (not self.filter_kind(tag.kind) or\r
+                  not self._matches(tag.suffix, self.suffix_regexp) or\r
+                    not self._matches(tag.name, self.name_regexp)):\r
+                filtered_tags.append(tag)\r
+\r
+\r
+        if self.type == 'name':\r
+            obj.names = filtered_tags\r
+        else:\r
+            obj.address = filtered_tags\r
+\r
+\r
+    def _set_allowed_ranks(self, ranks: Sequence[str]) -> Tuple[bool, ...]:\r
+        """ Returns a tuple of 31 boolean values corresponding to the\r
+            address ranks 0-30. Value at index 'i' is True if rank 'i'\r
+            is present in the ranks or lies in the range of any of the\r
+            ranks provided in the sanitizer configuration, otherwise\r
+            the value is False.\r
+        """\r
+        allowed_ranks = [False] * 31\r
+\r
+        for rank in ranks:\r
+            intvl = [int(x) for x in rank.split('-')]\r
+\r
+            start, end = (intvl[0], intvl[0]) if len(intvl) == 1 else (intvl[0], intvl[1])\r
+\r
+            for i in range(start, end + 1):\r
+                allowed_ranks[i] = True\r
+\r
+\r
+        return tuple(allowed_ranks)\r
+\r
+\r
+    def _matches(self, value: Optional[str], patterns: List[Pattern[str]]) -> bool:\r
+        """ Returns True if the given value fully matches any of the regular\r
+            expression pattern in the list. Otherwise, returns False.\r
+\r
+            Note that if the value is None, it is taken as an empty string.\r
+        """\r
+        target = '' if value is None else value\r
+        return any(r.fullmatch(target) is not None for r in patterns)\r
+\r
+\r
+\r
+def create(config: SanitizerConfig) -> Callable[[ProcessInfo], None]:\r
+    """ Create a function to process removal of certain tags.\r
+    """\r
+\r
+    return _TagSanitizer(config)\r
index 6d6430f034e0c10dfae13555e137b40ccae19484..032b69a8cb6c07dd67e673351e6810bbd334cbf9 100644 (file)
@@ -12,7 +12,7 @@ If a name already has an analyzer tagged, then this is kept.
 Arguments:
 
     filter-kind: Restrict the names the sanitizer should be applied to
-                 to the given tags. The parameter expects a list of
+                 the given tags. The parameter expects a list of
                  regular expressions which are matched against 'kind'.
                  Note that a match against the full string is expected.
     whitelist: Restrict the set of languages that should be tagged.
index 3bb5bf7cbbb987de7492eba341063fff4f629cf4..3d5635de126c6136c575a2f7e860e6f7492d7234 100644 (file)
@@ -7,9 +7,9 @@ Feature: Object details
         Then the result is valid json
         And result has attributes geometry
         And result has not attributes keywords,address,linked_places,parentof
-        And results contain
-            | geometry+type |
-            | Point         |
+        And results contain in field geometry
+            | type  |
+            | Point |
 
     Scenario: JSON Details with pretty printing
         When sending json details query for W297699560
@@ -75,9 +75,9 @@ Feature: Object details
             | 1 |
         Then the result is valid json
         And result has attributes geometry
-        And results contain
-            | geometry+type |
-            | <geometry>    |
+        And results contain in field geometry
+            | type       |
+            | <geometry> |
 
     Examples:
             | osmid      | geometry   |
index 58e5e59eb971b373d74840158fd9f96a368d588a..eed95a7359ef928f1f81d28e6305165eb1e49313 100644 (file)
@@ -103,3 +103,16 @@ Feature: Object details
             | category | type     | admin_level |
             | place    | postcode | 15          |
         And result has not attributes osm_type,osm_id
+
+
+    @v1-api-python-only
+    Scenario Outline: Details debug output returns no errors
+        When sending debug details query for <feature>
+        Then the result is valid html
+
+        Examples:
+          | feature     |
+          | N5484325405 |
+          | W1          |
+          | 112820      |
+          | 112871      |
diff --git a/test/bdd/api/reverse/geocodejson.feature b/test/bdd/api/reverse/geocodejson.feature
deleted file mode 100644 (file)
index 3d6a9b1..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-@APIDB
-Feature: Parameters for Reverse API
-    Testing correctness of geocodejson output.
-
-    Scenario: City housenumber-level address with street
-        When sending geocodejson reverse coordinates 47.1068011,9.52810091
-        Then results contain
-          | housenumber | street    | postcode | city    | country |
-          | 8           | Im Winkel | 9495     | Triesen | Liechtenstein |
-
-    Scenario: Town street-level address with street
-        When sending geocodejson reverse coordinates 47.066,9.504
-          | zoom |
-          | 16 |
-        Then results contain
-          | name    | city    | postcode | country |
-          | Gnetsch | Balzers | 9496     | Liechtenstein |
-
-    Scenario: Poi street-level address with footway
-        When sending geocodejson reverse coordinates 47.0653,9.5007
-        Then results contain
-          | street  | city    | postcode | country |
-          | Burgweg | Balzers | 9496     | Liechtenstein |
-
-    Scenario: City address with suburb
-        When sending geocodejson reverse coordinates 47.146861,9.511771
-        Then results contain
-          | housenumber | street   | district | city  | postcode | country |
-          | 5           | Lochgass | Ebenholz | Vaduz | 9490     | Liechtenstein |
diff --git a/test/bdd/api/reverse/geometry.feature b/test/bdd/api/reverse/geometry.feature
new file mode 100644 (file)
index 0000000..2c14dd5
--- /dev/null
@@ -0,0 +1,44 @@
+@APIDB
+Feature: Geometries for reverse geocoding
+    Tests for returning geometries with reverse
+
+
+    Scenario: Polygons are returned fully by default
+        When sending v1/reverse at 47.13803,9.52264
+          | polygon_text |
+          | 1            |
+        Then results contain
+          | geotext |
+          | POLYGON((9.5225302 47.138066,9.5225348 47.1379282,9.5226142 47.1379294,9.5226143 47.1379257,9.522615 47.137917,9.5226225 47.1379098,9.5226334 47.1379052,9.5226461 47.1379037,9.5226588 47.1379056,9.5226693 47.1379107,9.5226762 47.1379181,9.5226762 47.1379268,9.5226761 47.1379308,9.5227366 47.1379317,9.5227352 47.1379753,9.5227608 47.1379757,9.5227595 47.1380148,9.5227355 47.1380145,9.5227337 47.1380692,9.5225302 47.138066)) |
+
+
+    Scenario: Polygons can be slightly simplified
+        When sending v1/reverse at 47.13803,9.52264
+          | polygon_text | polygon_threshold |
+          | 1            | 0.00001            |
+        Then results contain
+          | geotext |
+          | POLYGON((9.5225302 47.138066,9.5225348 47.1379282,9.5226142 47.1379294,9.5226225 47.1379098,9.5226588 47.1379056,9.5226761 47.1379308,9.5227366 47.1379317,9.5227352 47.1379753,9.5227608 47.1379757,9.5227595 47.1380148,9.5227355 47.1380145,9.5227337 47.1380692,9.5225302 47.138066)) |
+
+
+    Scenario: Polygons can be much simplified
+        When sending v1/reverse at 47.13803,9.52264
+          | polygon_text | polygon_threshold |
+          | 1            | 0.9               |
+        Then results contain
+          | geotext |
+          | POLYGON((9.5225302 47.138066,9.5225348 47.1379282,9.5227608 47.1379757,9.5227337 47.1380692,9.5225302 47.138066)) |
+
+
+    Scenario: For polygons return the centroid as center point
+        When sending v1/reverse at 47.13836,9.52304
+        Then results contain
+          | centroid               |
+          | 9.52271080 47.13818045 |
+
+
+    Scenario: For streets return the closest point as center point
+        When sending v1/reverse at 47.13368,9.52942
+        Then results contain
+          | centroid    |
+          | 9.529431527 47.13368172 |
index 43d1f11b7840ed0da832b0641f6ee6bcda7793f7..e42689f73d12a9aeb0df1bd7e783398e274e93b4 100644 (file)
@@ -2,13 +2,13 @@
 Feature: Localization of reverse search results
 
     Scenario: default language
-        When sending json reverse coordinates 47.14,9.55
+        When sending v1/reverse at 47.14,9.55
         Then result addresses contain
           | ID | country |
           | 0  | Liechtenstein |
 
     Scenario: accept-language parameter
-        When sending json reverse coordinates 47.14,9.55
+        When sending v1/reverse at 47.14,9.55
           | accept-language |
           | ja,en |
         Then result addresses contain
@@ -19,7 +19,7 @@ Feature: Localization of reverse search results
         Given the HTTP header
           | accept-language |
           | fo-ca,fo;q=0.8,en-ca;q=0.5,en;q=0.3 |
-        When sending json reverse coordinates 47.14,9.55
+        When sending v1/reverse at 47.14,9.55
         Then result addresses contain
           | ID | country |
           | 0  | Liktinstein |
@@ -28,7 +28,7 @@ Feature: Localization of reverse search results
         Given the HTTP header
           | accept-language |
           | fo-ca,fo;q=0.8,en-ca;q=0.5,en;q=0.3 |
-        When sending json reverse coordinates 47.14,9.55
+        When sending v1/reverse at 47.14,9.55
           | accept-language |
           | en |
         Then result addresses contain
diff --git a/test/bdd/api/reverse/params.feature b/test/bdd/api/reverse/params.feature
deleted file mode 100644 (file)
index d6ef379..0000000
+++ /dev/null
@@ -1,147 +0,0 @@
-@APIDB
-Feature: Parameters for Reverse API
-    Testing different parameter options for reverse API.
-
-    Scenario Outline: Reverse-geocoding without address
-        When sending <format> reverse coordinates 47.13,9.56
-          | addressdetails |
-          | 0 |
-        Then exactly 1 result is returned
-        And result has not attributes address
-
-    Examples:
-      | format |
-      | json |
-      | jsonv2 |
-      | geojson |
-      | xml |
-
-    Scenario Outline: Coordinates must be floating-point numbers
-        When sending reverse coordinates <coords>
-        Then a HTTP 400 is returned
-
-    Examples:
-      | coords    |
-      | -45.3,;   |
-      | gkjd,50   |
-
-    Scenario Outline: Zoom levels between 4 and 18 are allowed
-        When sending reverse coordinates 47.14122383,9.52169581334
-          | zoom |
-          | <zoom> |
-        Then exactly 1 result is returned
-        And result addresses contain
-          | country_code |
-          | li |
-
-    Examples:
-      | zoom |
-      | 4 |
-      | 5 |
-      | 6 |
-      | 7 |
-      | 8 |
-      | 9 |
-      | 10 |
-      | 11 |
-      | 12 |
-      | 13 |
-      | 14 |
-      | 15 |
-      | 16 |
-      | 17 |
-      | 18 |
-
-    Scenario: Non-numerical zoom levels return an error
-        When sending reverse coordinates 47.14122383,9.52169581334
-          | zoom |
-          | adfe |
-        Then a HTTP 400 is returned
-
-    Scenario Outline: Reverse Geocoding with extratags
-        When sending <format> reverse coordinates 47.1395013150811,9.522098077031046
-          | extratags |
-          | 1 |
-        Then result 0 has attributes extratags
-
-    Examples:
-        | format |
-        | xml |
-        | json |
-        | jsonv2 |
-        | geojson |
-
-    Scenario Outline: Reverse Geocoding with namedetails
-        When sending <format> reverse coordinates 47.1395013150811,9.522098077031046
-          | namedetails |
-          | 1 |
-        Then result 0 has attributes namedetails
-
-    Examples:
-        | format |
-        | xml |
-        | json |
-        | jsonv2 |
-        | geojson |
-
-    Scenario Outline: Reverse Geocoding contains TEXT geometry
-        When sending <format> reverse coordinates 47.165989816710066,9.515774846076965
-          | polygon_text |
-          | 1 |
-        Then result 0 has attributes <response_attribute>
-
-    Examples:
-        | format   | response_attribute |
-        | xml      | geotext |
-        | json     | geotext |
-        | jsonv2   | geotext |
-
-    Scenario Outline: Reverse Geocoding contains SVG geometry
-        When sending <format> reverse coordinates 47.165989816710066,9.515774846076965
-          | polygon_svg |
-          | 1 |
-        Then result 0 has attributes <response_attribute>
-
-    Examples:
-        | format   | response_attribute |
-        | xml      | geosvg |
-        | json     | svg |
-        | jsonv2   | svg |
-
-    Scenario Outline: Reverse Geocoding contains KML geometry
-        When sending <format> reverse coordinates 47.165989816710066,9.515774846076965
-          | polygon_kml |
-          | 1 |
-        Then result 0 has attributes <response_attribute>
-
-    Examples:
-        | format   | response_attribute |
-        | xml      | geokml |
-        | json     | geokml |
-        | jsonv2   | geokml |
-
-    Scenario Outline: Reverse Geocoding contains GEOJSON geometry
-        When sending <format> reverse coordinates 47.165989816710066,9.515774846076965
-          | polygon_geojson |
-          | 1 |
-        Then result 0 has attributes <response_attribute>
-
-    Examples:
-        | format   | response_attribute |
-        | xml      | geojson |
-        | json     | geojson |
-        | jsonv2   | geojson |
-        | geojson  | geojson |
-
-    Scenario Outline: Reverse Geocoding in geojson format contains no non-geojson geometry
-        When sending geojson reverse coordinates 47.165989816710066,9.515774846076965
-          | polygon_text | polygon_svg | polygon_geokml |
-          | 1            | 1           | 1              |
-        Then result 0 has not attributes <response_attribute>
-
-    Examples:
-        | response_attribute |
-        | geotext            |
-        | polygonpoints      |
-        | svg                |
-        | geokml             |
index a2b0f033739e7088fd843874ae77a92a87aee206..d51378d6443dab6e2a0254dc7a23bf969daba2b6 100644 (file)
@@ -2,19 +2,35 @@
 Feature: Reverse geocoding
     Testing the reverse function
 
+    Scenario Outline: Simple reverse-geocoding with no results
+        When sending v1/reverse at <lat>,<lon>
+        Then exactly 0 results are returned
+
+    Examples:
+     | lat      | lon |
+     | 0.0      | 0.0 |
+     | -34.830  | -56.105 |
+     | 45.174   | -103.072 |
+     | 21.156   | -12.2744 |
+     | 91.3     | 0.4    |
+     | -700     | 0.4    |
+     | 0.2      | 324.44 |
+     | 0.2      | -180.4 |
+
+
     @Tiger
     Scenario: TIGER house number
-        When sending jsonv2 reverse coordinates 32.4752389363,-86.4810198619
+        When sending v1/reverse at 32.4752389363,-86.4810198619
         Then results contain
-          | osm_type | category | type |
-          | way      | place    | house |
+          | category | type |
+          | place    | house |
         And result addresses contain
           | house_number | road                | postcode | country_code |
           | 707          | Upper Kingston Road | 36067    | us |
 
     @Tiger
     Scenario: No TIGER house number for zoom < 18
-        When sending jsonv2 reverse coordinates 32.4752389363,-86.4810198619
+        When sending v1/reverse at 32.4752389363,-86.4810198619
           | zoom |
           | 17 |
         Then results contain
@@ -25,7 +41,7 @@ Feature: Reverse geocoding
           | Upper Kingston Road | 30607    | us |
 
     Scenario: Interpolated house number
-        When sending jsonv2 reverse coordinates 47.118533,9.57056562
+        When sending v1/reverse at 47.118533,9.57056562
         Then results contain
           | osm_type | category | type |
           | way      | place    | house |
@@ -34,20 +50,20 @@ Feature: Reverse geocoding
           | 1019         | Grosssteg |
 
     Scenario: Address with non-numerical house number
-        When sending jsonv2 reverse coordinates 47.107465,9.52838521614
+        When sending v1/reverse at 47.107465,9.52838521614
         Then result addresses contain
           | house_number | road |
           | 39A/B        | Dorfstrasse |
 
 
     Scenario: Address with numerical house number
-        When sending jsonv2 reverse coordinates 47.168440329479594,9.511551699184338
+        When sending v1/reverse at 47.168440329479594,9.511551699184338
         Then result addresses contain
           | house_number | road |
           | 6            | Schmedgässle |
 
     Scenario Outline: Zoom levels below 5 result in country
-        When sending jsonv2 reverse coordinates 47.16,9.51
+        When sending v1/reverse at 47.16,9.51
          | zoom |
          | <zoom> |
         Then results contain
@@ -63,7 +79,7 @@ Feature: Reverse geocoding
          | 4    |
 
     Scenario: When on a street, the closest interpolation is shown
-        When sending jsonv2 reverse coordinates 47.118457166193245,9.570678289621355
+        When sending v1/reverse at 47.118457166193245,9.570678289621355
          | zoom |
          | 18 |
         Then results contain
@@ -72,7 +88,7 @@ Feature: Reverse geocoding
 
     # github 2214
     Scenario: Interpolations do not override house numbers when they are closer
-        When sending jsonv2 reverse coordinates 47.11778,9.57255
+        When sending v1/reverse at 47.11778,9.57255
          | zoom |
          | 18 |
         Then results contain
@@ -80,7 +96,7 @@ Feature: Reverse geocoding
          | 5, Grosssteg, Steg, Triesenberg, Oberland, 9497, Liechtenstein |
 
     Scenario: Interpolations do not override house numbers when they are closer (2)
-        When sending jsonv2 reverse coordinates 47.11834,9.57167
+        When sending v1/reverse at 47.11834,9.57167
          | zoom |
          | 18 |
         Then results contain
@@ -88,7 +104,7 @@ Feature: Reverse geocoding
          | 3, Grosssteg, Sücka, Triesenberg, Oberland, 9497, Liechtenstein |
 
     Scenario: When on a street with zoom 18, the closest housenumber is returned
-        When sending jsonv2 reverse coordinates 47.11755503977281,9.572722250405036
+        When sending v1/reverse at 47.11755503977281,9.572722250405036
          | zoom |
          | 18 |
         Then result addresses contain
diff --git a/test/bdd/api/reverse/simple.feature b/test/bdd/api/reverse/simple.feature
deleted file mode 100644 (file)
index 4da311e..0000000
+++ /dev/null
@@ -1,137 +0,0 @@
-@APIDB
-Feature: Simple Reverse Tests
-    Simple tests for internal server errors and response format.
-
-    Scenario Outline: Simple reverse-geocoding
-        When sending reverse coordinates <lat>,<lon>
-        Then the result is valid xml
-        When sending xml reverse coordinates <lat>,<lon>
-        Then the result is valid xml
-        When sending json reverse coordinates <lat>,<lon>
-        Then the result is valid json
-        When sending jsonv2 reverse coordinates <lat>,<lon>
-        Then the result is valid json
-        When sending geojson reverse coordinates <lat>,<lon>
-        Then the result is valid geojson
-
-    Examples:
-     | lat      | lon |
-     | 0.0      | 0.0 |
-     | -34.830  | -56.105 |
-     | 45.174   | -103.072 |
-     | 21.156   | -12.2744 |
-
-    Scenario Outline: Testing different parameters
-        When sending reverse coordinates 53.603,10.041
-          | param       | value   |
-          | <parameter> | <value> |
-        Then the result is valid xml
-        When sending xml reverse coordinates 53.603,10.041
-          | param       | value   |
-          | <parameter> | <value> |
-        Then the result is valid xml
-        When sending json reverse coordinates 53.603,10.041
-          | param       | value   |
-          | <parameter> | <value> |
-        Then the result is valid json
-        When sending jsonv2 reverse coordinates 53.603,10.041
-          | param       | value   |
-          | <parameter> | <value> |
-        Then the result is valid json
-        When sending geojson reverse coordinates 53.603,10.041
-          | param       | value   |
-          | <parameter> | <value> |
-        Then the result is valid geojson
-        When sending geocodejson reverse coordinates 53.603,10.041
-          | param       | value   |
-          | <parameter> | <value> |
-        Then the result is valid geocodejson
-
-    Examples:
-     | parameter        | value |
-     | polygon_text     | 1 |
-     | polygon_text     | 0 |
-     | polygon_kml      | 1 |
-     | polygon_kml      | 0 |
-     | polygon_geojson  | 1 |
-     | polygon_geojson  | 0 |
-     | polygon_svg      | 1 |
-     | polygon_svg      | 0 |
-
-    Scenario Outline: Wrapping of legal jsonp requests
-        When sending <format> reverse coordinates 67.3245,0.456
-        | json_callback |
-        | foo |
-        Then the result is valid <outformat>
-
-    Examples:
-      | format | outformat |
-      | json | json |
-      | jsonv2 | json |
-      | geojson | geojson |
-
-    Scenario Outline: Boundingbox is returned
-        When sending <format> reverse coordinates 47.11,9.57
-          | zoom |
-          | 8 |
-        Then result has bounding box in 47,48,9,10
-
-    Examples:
-      | format |
-      | json |
-      | jsonv2 |
-      | geojson |
-      | xml |
-
-    Scenario Outline: Reverse-geocoding with zoom
-        When sending <format> reverse coordinates 47.11,9.57
-          | zoom |
-          | 10 |
-        Then exactly 1 result is returned
-
-    Examples:
-      | format |
-      | json |
-      | jsonv2 |
-      | geojson |
-      | xml |
-
-    Scenario: Missing lon parameter
-        When sending reverse coordinates 52.52,
-        Then a HTTP 400 is returned
-
-    Scenario: Missing lat parameter
-        When sending reverse coordinates ,52.52
-        Then a HTTP 400 is returned
-
-    Scenario: Missing osm_id parameter
-        When sending reverse coordinates ,
-          | osm_type |
-          | N |
-        Then a HTTP 400 is returned
-
-    Scenario: Missing osm_type parameter
-        When sending reverse coordinates ,
-          | osm_id |
-          | 3498564 |
-        Then a HTTP 400 is returned
-
-    Scenario Outline: Bad format for lat or lon
-        When sending reverse coordinates ,
-          | lat   | lon   |
-          | <lat> | <lon> |
-        Then a HTTP 400 is returned
-
-    Examples:
-     | lat      | lon |
-     | 48.9660  | 8,4482 |
-     | 48,9660  | 8.4482 |
-     | 48,9660  | 8,4482 |
-     | 48.966.0 | 8.4482 |
-     | 48.966   | 8.448.2 |
-     | Nan      | 8.448 |
-     | 48.966   | Nan |
-
-     Scenario: Reverse Debug output returns no errors
-        When sending debug reverse coordinates 47.11,9.57
-        Then a HTTP 200 is returned
diff --git a/test/bdd/api/reverse/v1_geocodejson.feature b/test/bdd/api/reverse/v1_geocodejson.feature
new file mode 100644 (file)
index 0000000..b7ea035
--- /dev/null
@@ -0,0 +1,106 @@
+@APIDB
+Feature: Geocodejson for Reverse API
+    Testing correctness of geocodejson output (API version v1).
+
+    Scenario Outline: Simple OSM result
+        When sending v1/reverse at 47.066,9.504 with format geocodejson
+          | addressdetails |
+          | <has_address>  |
+        Then result has attributes place_id, accuracy
+        And result has <attributes> country,postcode,county,city,district,street,housenumber, admin
+        Then results contain
+          | osm_type | osm_id     | osm_key | osm_value | type  |
+          | node     | 6522627624 | shop    | bakery    | house |
+        And results contain
+          | name                  | label |
+          | Dorfbäckerei Herrmann | Dorfbäckerei Herrmann, 29, Gnetsch, Mäls, Balzers, Oberland, 9496, Liechtenstein |
+        And results contain in field geojson
+          | type  | coordinates             |
+          | Point | [9.5036065, 47.0660892] |
+        And results contain in field __geocoding
+          | version | licence | attribution |
+          | 0.1.0   | ODbL    | Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright |
+
+        Examples:
+          | has_address | attributes     |
+          | 1           | attributes     |
+          | 0           | not attributes |
+
+
+    Scenario: City housenumber-level address with street
+        When sending v1/reverse at 47.1068011,9.52810091 with format geocodejson
+        Then results contain
+          | housenumber | street    | postcode | city    | country |
+          | 8           | Im Winkel | 9495     | Triesen | Liechtenstein |
+         And results contain in field admin
+          | level6   | level8  |
+          | Oberland | Triesen |
+
+
+    Scenario: Town street-level address with street
+        When sending v1/reverse at 47.066,9.504 with format geocodejson
+          | zoom |
+          | 16 |
+        Then results contain
+          | name    | city    | postcode | country |
+          | Gnetsch | Balzers | 9496     | Liechtenstein |
+
+
+    Scenario: Poi street-level address with footway
+        When sending v1/reverse at 47.06515,9.50083 with format geocodejson
+        Then results contain
+          | street  | city    | postcode | country |
+          | Burgweg | Balzers | 9496     | Liechtenstein |
+
+
+    Scenario: City address with suburb
+        When sending v1/reverse at 47.146861,9.511771 with format geocodejson
+        Then results contain
+          | housenumber | street   | district | city  | postcode | country |
+          | 5           | Lochgass | Ebenholz | Vaduz | 9490     | Liechtenstein |
+
+
+    @Tiger
+    Scenario: Tiger address
+        When sending v1/reverse at 32.4752389363,-86.4810198619 with format geocodejson
+        Then results contain
+         | osm_type | osm_id    | osm_key | osm_value | type  |
+         | way      | 396009653 | place   | house     | house |
+        And results contain
+         | housenumber | street              | city       | county         | postcode | country       |
+         | 707         | Upper Kingston Road | Prattville | Autauga County | 36067    | United States |
+
+
+    Scenario: Interpolation address
+        When sending v1/reverse at 47.118533,9.57056562 with format geocodejson
+        Then results contain
+          | osm_type | osm_id | osm_key | osm_value | type  |
+          | way      | 1      | place   | house     | house |
+        And results contain
+          | label |
+          | 1019, Grosssteg, Sücka, Triesenberg, Oberland, 9497, Liechtenstein |
+        And result has not attributes name
+
+
+    Scenario: Line geometry output is supported
+        When sending v1/reverse at 47.06597,9.50467 with format geocodejson
+          | param           | value |
+          | polygon_geojson | 1     |
+        Then results contain in field geojson
+          | type       |
+          | LineString |
+
+
+    Scenario Outline: Only geojson polygons are supported
+        When sending v1/reverse at 47.06597,9.50467 with format geocodejson
+          | param   | value |
+          | <param> | 1     |
+        Then results contain in field geojson
+          | type  |
+          | Point |
+
+        Examples:
+          | param |
+          | polygon_text |
+          | polygon_svg  |
+          | polygon_kml  |
diff --git a/test/bdd/api/reverse/v1_geojson.feature b/test/bdd/api/reverse/v1_geojson.feature
new file mode 100644 (file)
index 0000000..8acf067
--- /dev/null
@@ -0,0 +1,72 @@
+@APIDB
+Feature: Geojson for Reverse API
+    Testing correctness of geojson output (API version v1).
+
+    Scenario Outline: Simple OSM result
+        When sending v1/reverse at 47.066,9.504 with format geojson
+          | addressdetails |
+          | <has_address>  |
+        Then result has attributes place_id, importance, __licence
+        And result has <attributes> address
+        And results contain
+          | osm_type | osm_id     | place_rank | category | type    | addresstype |
+          | node     | 6522627624 | 30         | shop     | bakery  | shop        |
+        And results contain
+          | name                  | display_name |
+          | Dorfbäckerei Herrmann | Dorfbäckerei Herrmann, 29, Gnetsch, Mäls, Balzers, Oberland, 9496, Liechtenstein |
+        And results contain
+          | boundingbox |
+          | [47.0660392, 47.0661392, 9.5035565, 9.5036565] |
+        And results contain in field geojson
+          | type  | coordinates |
+          | Point | [9.5036065, 47.0660892] |
+
+        Examples:
+          | has_address | attributes     |
+          | 1           | attributes     |
+          | 0           | not attributes |
+
+
+    @Tiger
+    Scenario: Tiger address
+        When sending v1/reverse at 32.4752389363,-86.4810198619 with format geojson
+        Then results contain
+         | osm_type | osm_id    | category | type  | addresstype  | place_rank |
+         | way      | 396009653 | place    | house | place        | 30         |
+
+
+    Scenario: Interpolation address
+        When sending v1/reverse at 47.118533,9.57056562 with format geojson
+        Then results contain
+          | osm_type | osm_id | place_rank | category | type    | addresstype |
+          | way      | 1      | 30         | place    | house   | place       |
+        And results contain
+          | boundingbox |
+          | [47.118495392, 47.118595392, 9.57049676, 9.57059676] |
+        And results contain
+          | display_name |
+          | 1019, Grosssteg, Sücka, Triesenberg, Oberland, 9497, Liechtenstein |
+
+
+    Scenario: Line geometry output is supported
+        When sending v1/reverse at 47.06597,9.50467 with format geojson
+          | param           | value |
+          | polygon_geojson | 1     |
+        Then results contain in field geojson
+          | type       |
+          | LineString |
+
+
+    Scenario Outline: Only geojson polygons are supported
+        When sending v1/reverse at 47.06597,9.50467 with format geojson
+          | param   | value |
+          | <param> | 1     |
+        Then results contain in field geojson
+          | type  |
+          | Point |
+
+        Examples:
+          | param |
+          | polygon_text |
+          | polygon_svg  |
+          | polygon_kml  |
diff --git a/test/bdd/api/reverse/v1_json.feature b/test/bdd/api/reverse/v1_json.feature
new file mode 100644 (file)
index 0000000..155e02b
--- /dev/null
@@ -0,0 +1,129 @@
+@APIDB
+Feature: Json output for Reverse API
+    Testing correctness of json and jsonv2 output (API version v1).
+
+    Scenario Outline: OSM result with and without addresses
+        When sending v1/reverse at 47.066,9.504 with format json
+          | addressdetails |
+          | <has_address>  |
+        Then result has <attributes> address
+        When sending v1/reverse at 47.066,9.504 with format jsonv2
+          | addressdetails |
+          | <has_address>  |
+        Then result has <attributes> address
+
+        Examples:
+          | has_address | attributes     |
+          | 1           | attributes     |
+          | 0           | not attributes |
+
+    Scenario Outline: Siple OSM result
+        When sending v1/reverse at 47.066,9.504 with format <format>
+        Then result has attributes place_id
+        And results contain
+          | licence |
+          | Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright |
+        And results contain
+          | osm_type | osm_id     |
+          | node     | 6522627624 |
+        And results contain
+          | centroid             | boundingbox |
+          | 9.5036065 47.0660892 | ['47.0660392', '47.0661392', '9.5035565', '9.5036565'] |
+        And results contain
+          | display_name |
+          | Dorfbäckerei Herrmann, 29, Gnetsch, Mäls, Balzers, Oberland, 9496, Liechtenstein |
+        And result has not attributes namedetails,extratags
+
+        Examples:
+          | format |
+          | json   |
+          | jsonv2 |
+
+    Scenario: Extra attributes of jsonv2 result
+        When sending v1/reverse at 47.066,9.504 with format jsonv2
+        Then result has attributes importance
+        Then results contain
+          | category | type   | name                  | place_rank | addresstype |
+          | shop     | bakery | Dorfbäckerei Herrmann | 30         | shop        |
+
+
+    @Tiger
+    Scenario: Tiger address
+        When sending v1/reverse at 32.4752389363,-86.4810198619 with format jsonv2
+        Then results contain
+         | osm_type | osm_id    | category | type  | addresstype  |
+         | way      | 396009653 | place    | house | place        |
+
+
+    Scenario Outline: Interpolation address
+        When sending v1/reverse at 47.118533,9.57056562 with format <format>
+        Then results contain
+          | osm_type | osm_id |
+          | way      | 1      |
+        And results contain
+          | centroid                | boundingbox |
+          | 9.57054676 47.118545392 | ['47.118495392', '47.118595392', '9.57049676', '9.57059676'] |
+        And results contain
+          | display_name |
+          | 1019, Grosssteg, Sücka, Triesenberg, Oberland, 9497, Liechtenstein |
+
+        Examples:
+          | format |
+          | json   |
+          | jsonv2 |
+
+
+    Scenario Outline: Output of geojson
+       When sending v1/reverse at 47.06597,9.50467 with format <format>
+          | param           | value |
+          | polygon_geojson | 1     |
+       Then results contain in field geojson
+          | type       | coordinates |
+          | LineString | [[9.5039353, 47.0657546], [9.5040437, 47.0657781], [9.5040808, 47.065787], [9.5054298, 47.0661407]] |
+
+       Examples:
+          | format |
+          | json   |
+          | jsonv2 |
+
+
+    Scenario Outline: Output of WKT
+       When sending v1/reverse at 47.06597,9.50467 with format <format>
+          | param        | value |
+          | polygon_text | 1     |
+       Then results contain
+          | geotext |
+          | LINESTRING(9.5039353 47.0657546,9.5040437 47.0657781,9.5040808 47.065787,9.5054298 47.0661407) |
+
+       Examples:
+          | format |
+          | json   |
+          | jsonv2 |
+
+
+    Scenario Outline: Output of SVG
+       When sending v1/reverse at 47.06597,9.50467 with format <format>
+          | param       | value |
+          | polygon_svg | 1     |
+       Then results contain
+          | svg |
+          | M 9.5039353 -47.0657546 L 9.5040437 -47.0657781 9.5040808 -47.065787 9.5054298 -47.0661407 |
+
+       Examples:
+          | format |
+          | json   |
+          | jsonv2 |
+
+
+    Scenario Outline: Output of KML
+       When sending v1/reverse at 47.06597,9.50467 with format <format>
+          | param       | value |
+          | polygon_kml | 1     |
+       Then results contain
+          | geokml |
+          | ^<LineString><coordinates>9.5039\d*,47.0657\d* 9.5040\d*,47.0657\d* 9.5040\d*,47.065\d* 9.5054\d*,47.0661\d*</coordinates></LineString> |
+
+       Examples:
+          | format |
+          | json   |
+          | jsonv2 |
diff --git a/test/bdd/api/reverse/v1_params.feature b/test/bdd/api/reverse/v1_params.feature
new file mode 100644 (file)
index 0000000..70a6505
--- /dev/null
@@ -0,0 +1,220 @@
+@APIDB
+Feature: v1/reverse Parameter Tests
+    Tests for parameter inputs for the v1 reverse endpoint.
+    This file contains mostly bad parameter input. Valid parameters
+    are tested in the format tests.
+
+    Scenario: Bad format
+        When sending v1/reverse at 47.14122383,9.52169581334 with format sdf
+        Then a HTTP 400 is returned
+
+    Scenario: Missing lon parameter
+        When sending v1/reverse at 52.52,
+        Then a HTTP 400 is returned
+
+
+    Scenario: Missing lat parameter
+        When sending v1/reverse at ,52.52
+        Then a HTTP 400 is returned
+
+    @v1-api-php-only
+    Scenario: Missing osm_id parameter
+        When sending v1/reverse at ,
+          | osm_type |
+          | N |
+        Then a HTTP 400 is returned
+
+    @v1-api-php-only
+    Scenario: Missing osm_type parameter
+        When sending v1/reverse at ,
+          | osm_id |
+          | 3498564 |
+        Then a HTTP 400 is returned
+
+
+    Scenario Outline: Bad format for lat or lon
+        When sending v1/reverse at ,
+          | lat   | lon   |
+          | <lat> | <lon> |
+        Then a HTTP 400 is returned
+
+        Examples:
+          | lat      | lon |
+          | 48.9660  | 8,4482 |
+          | 48,9660  | 8.4482 |
+          | 48,9660  | 8,4482 |
+          | 48.966.0 | 8.4482 |
+          | 48.966   | 8.448.2 |
+          | Nan      | 8.448  |
+          | 48.966   | Nan    |
+          | Inf      | 5.6    |
+          | 5.6      | -Inf   |
+          | <script></script> | 3.4 |
+          | 3.4 | <script></script> |
+          | -45.3    | ;      |
+          | gkjd     | 50     |
+
+
+    Scenario: Non-numerical zoom levels return an error
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | zoom |
+          | adfe |
+        Then a HTTP 400 is returned
+
+
+    Scenario Outline: Truthy values for boolean parameters
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | addressdetails |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes address
+
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | extratags |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes extratags
+
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | namedetails |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes namedetails
+
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | polygon_geojson |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes geojson
+
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | polygon_kml |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes geokml
+
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | polygon_svg |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes svg
+
+        When sending v1/reverse at 47.14122383,9.52169581334
+          | polygon_text |
+          | <value> |
+        Then exactly 1 result is returned
+        And result has attributes geotext
+
+        Examples:
+          | value |
+          | yes   |
+          | no    |
+          | -1    |
+          | 100   |
+          | false |
+          | 00    |
+
+
+    Scenario: Only one geometry can be requested
+        When sending v1/reverse at 47.165989816710066,9.515774846076965
+          | polygon_text | polygon_svg |
+          | 1            | 1           |
+        Then a HTTP 400 is returned
+
+
+    Scenario Outline: Wrapping of legal jsonp requests
+        When sending v1/reverse at 67.3245,0.456 with format <format>
+          | json_callback |
+          | foo |
+        Then the result is valid <outformat>
+
+        Examples:
+          | format      | outformat   |
+          | json        | json        |
+          | jsonv2      | json        |
+          | geojson     | geojson     |
+          | geocodejson | geocodejson |
+
+
+    Scenario Outline: Illegal jsonp are not allowed
+        When sending v1/reverse at 47.165989816710066,9.515774846076965
+          | param        | value |
+          |json_callback | <data> |
+        Then a HTTP 400 is returned
+
+        Examples:
+          | data |
+          | 1asd |
+          | bar(foo) |
+          | XXX['bad'] |
+          | foo; evil |
+
+
+    @v1-api-python-only
+    Scenario Outline: Reverse debug mode produces valid HTML
+        When sending v1/reverse at , with format debug
+          | lat   | lon   |
+          | <lat> | <lon> |
+        Then the result is valid html
+
+        Examples:
+          | lat      | lon     |
+          | 0.0      | 0.0     |
+          | 47.06645 | 9.56601 |
+          | 47.14081 | 9.52267 |
+
+
+    Scenario Outline: Full address display for city housenumber-level address with street
+        When sending v1/reverse at 47.1068011,9.52810091 with format <format>
+        Then address of result 0 is
+          | type           | value     |
+          | house_number   | 8         |
+          | road           | Im Winkel |
+          | neighbourhood  | Oberdorf  |
+          | village        | Triesen   |
+          | ISO3166-2-lvl8 | LI-09     |
+          | county         | Oberland  |
+          | postcode       | 9495      |
+          | country        | Liechtenstein |
+          | country_code   | li        |
+
+        Examples:
+          | format  |
+          | json    |
+          | jsonv2  |
+          | geojson |
+          | xml     |
+
+
+    Scenario Outline: Results with name details
+        When sending v1/reverse at 47.14052,9.52202 with format <format>
+          | zoom | namedetails |
+          | 14   | 1           |
+        Then results contain in field namedetails
+          | name     |
+          | Ebenholz |
+
+        Examples:
+          | format  |
+          | json    |
+          | jsonv2  |
+          | xml     |
+          | geojson |
+
+
+    Scenario Outline: Results with extratags
+        When sending v1/reverse at 47.14052,9.52202 with format <format>
+          | zoom | extratags |
+          | 14   | 1         |
+        Then results contain in field extratags
+          | wikidata |
+          | Q4529531 |
+
+        Examples:
+          | format |
+          | json   |
+          | jsonv2 |
+          | xml    |
+          | geojson |
+
+
diff --git a/test/bdd/api/reverse/v1_xml.feature b/test/bdd/api/reverse/v1_xml.feature
new file mode 100644 (file)
index 0000000..fd6e150
--- /dev/null
@@ -0,0 +1,87 @@
+@APIDB
+Feature: XML output for Reverse API
+    Testing correctness of xml output (API version v1).
+
+    Scenario Outline: OSM result with and without addresses
+        When sending v1/reverse at 47.066,9.504 with format xml
+          | addressdetails |
+          | <has_address>  |
+        Then result has attributes place_id
+        Then result has <attributes> address
+        And results contain
+          | osm_type | osm_id     | place_rank | address_rank |
+          | node     | 6522627624 | 30         | 30           |
+        And results contain
+          | centroid             | boundingbox |
+          | 9.5036065 47.0660892 | 47.0660392,47.0661392,9.5035565,9.5036565 |
+        And results contain
+          | ref                   | display_name |
+          | Dorfbäckerei Herrmann | Dorfbäckerei Herrmann, 29, Gnetsch, Mäls, Balzers, Oberland, 9496, Liechtenstein |
+
+        Examples:
+          | has_address | attributes     |
+          | 1           | attributes     |
+          | 0           | not attributes |
+
+
+    @Tiger
+    Scenario: Tiger address
+        When sending v1/reverse at 32.4752389363,-86.4810198619 with format xml
+        Then results contain
+         | osm_type | osm_id    | place_rank  | address_rank |
+         | way      | 396009653 | 30          | 30           |
+        And results contain
+          | centroid                     | boundingbox |
+          | -86.4808553258 32.4753580256 | ^32.475308025\d*,32.475408025\d*,-86.480905325\d*,-86.480805325\d* |
+        And results contain
+          | display_name |
+          | 707, Upper Kingston Road, Upper Kingston, Prattville, Autauga County, 36067, United States |
+
+
+    Scenario: Interpolation address
+        When sending v1/reverse at 47.118533,9.57056562 with format xml
+        Then results contain
+          | osm_type | osm_id | place_rank | address_rank |
+          | way      | 1      | 30         | 30           |
+        And results contain
+          | centroid                | boundingbox |
+          | 9.57054676 47.118545392 | 47.118495392,47.118595392,9.57049676,9.57059676 |
+        And results contain
+          | display_name |
+          | 1019, Grosssteg, Sücka, Triesenberg, Oberland, 9497, Liechtenstein |
+
+
+    Scenario: Output of geojson
+       When sending v1/reverse at 47.06597,9.50467 with format xml
+          | param           | value |
+          | polygon_geojson | 1     |
+       Then results contain
+          | geojson |
+          | {"type":"LineString","coordinates":[[9.5039353,47.0657546],[9.5040437,47.0657781],[9.5040808,47.065787],[9.5054298,47.0661407]]}  |
+
+
+    Scenario: Output of WKT
+       When sending v1/reverse at 47.06597,9.50467 with format xml
+          | param        | value |
+          | polygon_text | 1     |
+       Then results contain
+          | geotext |
+          | LINESTRING(9.5039353 47.0657546,9.5040437 47.0657781,9.5040808 47.065787,9.5054298 47.0661407) |
+
+
+    Scenario: Output of SVG
+       When sending v1/reverse at 47.06597,9.50467 with format xml
+          | param       | value |
+          | polygon_svg | 1     |
+       Then results contain
+          | geosvg |
+          | M 9.5039353 -47.0657546 L 9.5040437 -47.0657781 9.5040808 -47.065787 9.5054298 -47.0661407 |
+
+
+    Scenario: Output of KML
+       When sending v1/reverse at 47.06597,9.50467 with format xml
+          | param       | value |
+          | polygon_kml | 1     |
+       Then results contain
+          | geokml |
+          | ^<geokml><LineString><coordinates>9.5039\d*,47.0657\d* 9.5040\d*,47.0657\d* 9.5040\d*,47.065\d* 9.5054\d*,47.0661\d*</coordinates></LineString></geokml> |
index 56ca4cc16f9961266c47397c6e50639f23294c46..0b939f593b4649803985ac42cab6fd9c5f5c83e8 100644 (file)
@@ -273,12 +273,12 @@ Feature: Import of address interpolations
           | W3              | 14    | 14 |
         When sending search query "16 Cloud Street"
         Then results contain
-         | ID | osm_type | osm_id |
-         | 0  | N        | 4 |
+         | ID | osm |
+         | 0  | N |
         When sending search query "14 Cloud Street"
         Then results contain
-         | ID | osm_type | osm_id |
-         | 0  | W        | 11 |
+         | ID | osm |
+         | 0  | W11 |
 
     Scenario: addr:street on housenumber way
         Given the grid
@@ -318,12 +318,12 @@ Feature: Import of address interpolations
           | W3              | 14    | 14 |
         When sending search query "16 Cloud Street"
         Then results contain
-         | ID | osm_type | osm_id |
-         | 0  | N        | 4 |
+         | ID | osm |
+         | 0  | N |
         When sending search query "14 Cloud Street"
         Then results contain
-         | ID | osm_type | osm_id |
-         | 0  | W        | 11 |
+         | ID | osm |
+         | 0  | W11 |
 
     Scenario: Geometry of points and way don't match (github #253)
         Given the places
@@ -399,10 +399,10 @@ Feature: Import of address interpolations
         Then W1 expands to interpolation
           | start | end | geometry |
           | 2     | 8   | 10,11 |
-        When sending jsonv2 reverse coordinates 1,1
+        When sending v1/reverse at 1,1
         Then results contain
-          | ID | osm_type | osm_id | type  | display_name |
-          | 0  | node     | 1      | house | 0 |
+          | ID | osm | type  | display_name |
+          | 0  | N1  | house | 0 |
 
     Scenario: Parenting of interpolation with additional tags
         Given the grid
index 0fb3f76dbb2365d429c2a6ab275d6b22386925b0..dfa4b77c1a6019475004182683065baedfd0e8ac 100644 (file)
@@ -55,8 +55,8 @@ Feature: Linking of places
          | R23    | -   |
         When sending search query "rhein"
         Then results contain
-         | osm_type |
-         | R |
+         | osm |
+         | R13 |
 
     Scenario: Relations are not linked when in waterway relations
         Given the grid
@@ -81,9 +81,9 @@ Feature: Linking of places
          | R2     | - |
         When sending search query "rhein"
         Then results contain
-          | ID | osm_type |
-          |  0 | R |
-          |  1 | W |
+          | ID | osm |
+          |  0 | R |
+          |  1 | W |
 
 
     Scenario: Empty waterway relations are handled correctly
@@ -138,8 +138,8 @@ Feature: Linking of places
          | W2     | R1 |
         When sending search query "rhein2"
         Then results contain
-         | osm_type |
-         | W |
+         | osm |
+         | W |
 
     # github #573
     Scenario: Boundaries should only be linked to places
@@ -205,14 +205,14 @@ Feature: Linking of places
          | city |
          | Berlin |
         Then results contain
-          | ID | osm_type | osm_id |
-          |  0 | R | 13 |
+          | ID | osm |
+          |  0 | R13 |
         When sending search query ""
          | state |
          | Berlin |
         Then results contain
-          | ID | osm_type | osm_id |
-          |  0 | R | 13 |
+          | ID | osm |
+          |  0 | R13 |
 
 
     Scenario: Boundaries without place tags only link against same admin level
@@ -237,14 +237,14 @@ Feature: Linking of places
          | state |
          | Berlin |
         Then results contain
-          | ID | osm_type | osm_id |
-          |  0 | R | 13 |
+          | ID | osm |
+          |  0 | R13 |
         When sending search query ""
          | city |
          | Berlin |
         Then results contain
-          | ID | osm_type | osm_id |
-          |  0 | N | 2 |
+          | ID | osm |
+          |  0 | N |
 
     # github #1352
     Scenario: Do not use linked centroid when it is outside the area
index 5de3fde6bc8c90a0ced565fc6b8924ac08e4a6a5..c349b69f63ee1b1b03053189cf078e02e584148f 100644 (file)
@@ -23,12 +23,12 @@ Feature: Parenting of objects
          | N2     | W1 |
         When sending search query "4 galoo"
         Then results contain
-         | ID | osm_type | osm_id | display_name |
-         | 0  | N        | 1      | 4, galoo, 12345, Deutschland |
+         | ID | osm | display_name |
+         | 0  | N1  | 4, galoo, 12345, Deutschland |
         When sending search query "5 galoo"
         Then results contain
-         | ID | osm_type | osm_id | display_name |
-         | 0  | N        | 2      | 5, galoo, 99999, Deutschland |
+         | ID | osm | display_name |
+         | 0  | N2  | 5, galoo, 99999, Deutschland |
 
     Scenario: Address without tags, closest street
         Given the grid
index 602ac434a2dd6741171d600f3525f8fc3751d4da..600de718c613f14952861f1ba8da40e8102122e0 100644 (file)
@@ -23,7 +23,7 @@ Feature: Query of address interpolations
           | id | nodes |
           | 1  | 1,3   |
         When importing
-        When sending jsonv2 reverse point 2
+        When sending v1/reverse N2
         Then results contain
           | ID | display_name |
           | 0  | 3, Nickway   |
@@ -43,12 +43,12 @@ Feature: Query of address interpolations
         And the places
           | osm | class | type  | housenr | geometry |
           | N1  | place | house | 2       | 1        |
-          | N3  | place | house | 16      | 3        |
+          | N3  | place | house | 18      | 3        |
         And the ways
           | id | nodes |
           | 1  | 1,3   |
         When importing
-        When sending jsonv2 reverse point 2
+        When sending v1/reverse N2
         Then results contain
           | ID | display_name | centroid |
           | 0  | 10, Nickway  | 2 |
index c277e8bd1e69c47f0f256dcecadcdff8030c4996..539d928543e5942f3d3647ded9a3be010b3df24d 100644 (file)
@@ -44,8 +44,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | R |
+         | osm |
+         | R1 |
         When updating places
          | osm | class    | type           | name   | admin | geometry |
          | R1  | boundary | administrative | foobar | 8     | (10,11,12,13,10) |
@@ -56,8 +56,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | N |
+         | osm |
+         | N1 |
 
     Scenario: Add linked place when linking relation is removed
         Given the 0.1 grid
@@ -75,8 +75,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | R |
+         | osm |
+         | R1 |
         When marking for delete R1
         Then placex contains
          | object | linked_place_id |
@@ -85,8 +85,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | N |
+         | osm |
+         | N1 |
 
     Scenario: Remove linked place when linking relation is added
         Given the 0.1 grid
@@ -101,8 +101,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | N |
+         | osm |
+         | N1 |
         When updating places
          | osm | class    | type           | name   | admin | geometry |
          | R1  | boundary | administrative | foo    | 8     | (10,11,12,13,10) |
@@ -113,8 +113,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | R |
+         | osm |
+         | R1 |
 
     Scenario: Remove linked place when linking relation is renamed
         Given the 0.1 grid
@@ -132,8 +132,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | N |
+         | osm |
+         | N1 |
         When updating places
          | osm | class    | type           | name   | admin | geometry |
          | R1  | boundary | administrative | foo    | 8     | (10,11,12,13,10) |
@@ -144,8 +144,8 @@ Feature: Updates of linked places
          | dups |
          | 1    |
         Then results contain
-         | osm_type |
-         | R |
+         | osm |
+         | R1 |
 
     Scenario: Update linking relation when linkee name is updated
         Given the 0.1 grid
index f214a88627f4fed870c06d2515cd2b9e4f6b4465..58d6c1f2a481ddf61453df633d90e725ef457036 100644 (file)
@@ -2,11 +2,14 @@
 #
 # This file is part of Nominatim. (https://nominatim.org)
 #
-# Copyright (C) 2022 by the Nominatim developer community.
+# Copyright (C) 2023 by the Nominatim developer community.
 # For a full list of authors see the git log.
 """
 Collection of assertion functions used for the steps.
 """
+import json
+import math
+import re
 
 class Almost:
     """ Compares a float value with a certain jitter.
@@ -18,6 +21,51 @@ class Almost:
     def __eq__(self, other):
         return abs(other - self.value) < self.offset
 
+
+OSM_TYPE = {'N' : 'node', 'W' : 'way', 'R' : 'relation',
+            'n' : 'node', 'w' : 'way', 'r' : 'relation',
+            'node' : 'n', 'way' : 'w', 'relation' : 'r'}
+
+
+class OsmType:
+    """ Compares an OSM type, accepting both N/R/W and node/way/relation.
+    """
+
+    def __init__(self, value):
+        self.value = value
+
+
+    def __eq__(self, other):
+        return other == self.value or other == OSM_TYPE[self.value]
+
+
+    def __str__(self):
+        return f"{self.value} or {OSM_TYPE[self.value]}"
+
+
+class Field:
+    """ Generic comparator for fields, which looks at the type of the
+        value compared.
+    """
+    def __init__(self, value):
+        self.value = value
+
+    def __eq__(self, other):
+        if isinstance(self.value, float):
+            return math.isclose(self.value, float(other))
+
+        if self.value.startswith('^'):
+            return re.fullmatch(self.value, other)
+
+        if isinstance(other, dict):
+            return other == eval('{' + self.value + '}')
+
+        return str(self.value) == str(other)
+
+    def __str__(self):
+        return str(self.value)
+
+
 class Bbox:
     """ Comparator for bounding boxes.
     """
@@ -41,3 +89,24 @@ class Bbox:
 
     def __str__(self):
         return str(self.coord)
+
+
+
+def check_for_attributes(obj, attrs, presence='present'):
+    """ Check that the object has the given attributes. 'attrs' is a
+        string with a comma-separated list of attributes. If 'presence'
+        is set to 'absent' then the function checks that the attributes do
+        not exist for the object
+    """
+    def _dump_json():
+        return json.dumps(obj, sort_keys=True, indent=2, ensure_ascii=False)
+
+    for attr in attrs.split(','):
+        attr = attr.strip()
+        if presence == 'absent':
+            assert attr not in obj, \
+                   f"Unexpected attribute {attr}. Full response:\n{_dump_json()}"
+        else:
+            assert attr in obj, \
+                   f"No attribute '{attr}'. Full response:\n{_dump_json()}"
+
index b493f013293fbaf749fc7b9d0b95ba92ee12fbfb..22fcb01018b4a92b382aadf5cc77bb05a50b5b7c 100644 (file)
@@ -7,43 +7,11 @@
 """
 Classes wrapping HTTP responses from the Nominatim API.
 """
-from collections import OrderedDict
 import re
 import json
 import xml.etree.ElementTree as ET
 
-from check_functions import Almost
-
-OSM_TYPE = {'N' : 'node', 'W' : 'way', 'R' : 'relation',
-            'n' : 'node', 'w' : 'way', 'r' : 'relation',
-            'node' : 'n', 'way' : 'w', 'relation' : 'r'}
-
-def _geojson_result_to_json_result(geojson_result):
-    result = geojson_result['properties']
-    result['geojson'] = geojson_result['geometry']
-    if 'bbox' in geojson_result:
-        # bbox is  minlon, minlat, maxlon, maxlat
-        # boundingbox is minlat, maxlat, minlon, maxlon
-        result['boundingbox'] = [geojson_result['bbox'][1],
-                                 geojson_result['bbox'][3],
-                                 geojson_result['bbox'][0],
-                                 geojson_result['bbox'][2]]
-    return result
-
-class BadRowValueAssert:
-    """ Lazily formatted message for failures to find a field content.
-    """
-
-    def __init__(self, response, idx, field, value):
-        self.idx = idx
-        self.field = field
-        self.value = value
-        self.row = response.result[idx]
-
-    def __str__(self):
-        return "\nBad value for row {} field '{}'. Expected: {}, got: {}.\nFull row: {}"""\
-                   .format(self.idx, self.field, self.value,
-                           self.row[self.field], json.dumps(self.row, indent=4))
+from check_functions import Almost, OsmType, Field, check_for_attributes
 
 
 class GenericResponse:
@@ -70,63 +38,54 @@ class GenericResponse:
         else:
             code = m.group(2)
             self.header['json_func'] = m.group(1)
-        self.result = json.JSONDecoder(object_pairs_hook=OrderedDict).decode(code)
-        if isinstance(self.result, OrderedDict):
+        self.result = json.JSONDecoder().decode(code)
+        if isinstance(self.result, dict):
             if 'error' in self.result:
                 self.result = []
             else:
                 self.result = [self.result]
 
+
     def _parse_geojson(self):
         self._parse_json()
         if self.result:
-            self.result = list(map(_geojson_result_to_json_result, self.result[0]['features']))
+            geojson = self.result[0]
+            # check for valid geojson
+            check_for_attributes(geojson, 'type,features')
+            assert geojson['type'] == 'FeatureCollection'
+            assert isinstance(geojson['features'], list)
+
+            self.result = []
+            for result in geojson['features']:
+                check_for_attributes(result, 'type,properties,geometry')
+                assert result['type'] == 'Feature'
+                new = result['properties']
+                check_for_attributes(new, 'geojson', 'absent')
+                new['geojson'] = result['geometry']
+                if 'bbox' in result:
+                    check_for_attributes(new, 'boundingbox', 'absent')
+                    # bbox is  minlon, minlat, maxlon, maxlat
+                    # boundingbox is minlat, maxlat, minlon, maxlon
+                    new['boundingbox'] = [result['bbox'][1],
+                                          result['bbox'][3],
+                                          result['bbox'][0],
+                                          result['bbox'][2]]
+                for k, v in geojson.items():
+                    if k not in ('type', 'features'):
+                        check_for_attributes(new, '__' + k, 'absent')
+                        new['__' + k] = v
+                self.result.append(new)
+
 
     def _parse_geocodejson(self):
         self._parse_geojson()
-        if self.result is not None:
-            self.result = [r['geocoding'] for r in self.result]
-
-    def assert_field(self, idx, field, value):
-        """ Check that result row `idx` has a field `field` with value `value`.
-            Float numbers are matched approximately. When the expected value
-            starts with a carat, regular expression matching is used.
-        """
-        assert field in self.result[idx], \
-               "Result row {} has no field '{}'.\nFull row: {}"\
-                   .format(idx, field, json.dumps(self.result[idx], indent=4))
-
-        if isinstance(value, float):
-            assert Almost(value) == float(self.result[idx][field]), \
-                   BadRowValueAssert(self, idx, field, value)
-        elif value.startswith("^"):
-            assert re.fullmatch(value, self.result[idx][field]), \
-                   BadRowValueAssert(self, idx, field, value)
-        elif isinstance(self.result[idx][field], OrderedDict):
-            assert self.result[idx][field] == eval('{' + value + '}'), \
-                   BadRowValueAssert(self, idx, field, value)
-        else:
-            assert str(self.result[idx][field]) == str(value), \
-                   BadRowValueAssert(self, idx, field, value)
-
-
-    def assert_subfield(self, idx, path, value):
-        assert path
-
-        field = self.result[idx]
-        for p in path:
-            assert isinstance(field, OrderedDict)
-            assert p in field
-            field = field[p]
-
-        if isinstance(value, float):
-            assert Almost(value) == float(field)
-        elif value.startswith("^"):
-            assert re.fullmatch(value, field)
-        elif isinstance(field, OrderedDict):
-            assert field, eval('{' + value + '}')
-        else:
-            assert str(field) == str(value)
+        if self.result:
+            for r in self.result:
+                assert set(r.keys()) == {'geocoding', 'geojson', '__geocoding'}, \
+                       f"Unexpected keys in result: {r.keys()}"
+                check_for_attributes(r['geocoding'], 'geojson', 'absent')
+                inner = r.pop('geocoding')
+                r.update(inner)
 
 
     def assert_address_field(self, idx, field, value):
@@ -139,20 +98,13 @@ class GenericResponse:
             todo = [int(idx)]
 
         for idx in todo:
-            assert 'address' in self.result[idx], \
-                   "Result row {} has no field 'address'.\nFull row: {}"\
-                       .format(idx, json.dumps(self.result[idx], indent=4))
+            self.check_row(idx, 'address' in self.result[idx], "No field 'address'")
 
             address = self.result[idx]['address']
-            assert field in address, \
-                   "Result row {} has no field '{}' in address.\nFull address: {}"\
-                       .format(idx, field, json.dumps(address, indent=4))
+            self.check_row_field(idx, field, value, base=address)
 
-            assert address[field] == value, \
-                   "\nBad value for row {} field '{}' in address. Expected: {}, got: {}.\nFull address: {}"""\
-                       .format(idx, field, value, address[field], json.dumps(address, indent=4))
 
-    def match_row(self, row, context=None):
+    def match_row(self, row, context=None, field=None):
         """ Match the result fields against the given behave table row.
         """
         if 'ID' in row.headings:
@@ -161,19 +113,20 @@ class GenericResponse:
             todo = range(len(self.result))
 
         for i in todo:
+            subdict = self.result[i]
+            if field is not None:
+                for key in field.split('.'):
+                    self.check_row(i, key in subdict, f"Missing subfield {key}")
+                    subdict = subdict[key]
+                    self.check_row(i, isinstance(subdict, dict),
+                                   f"Subfield {key} not a dict")
+
             for name, value in zip(row.headings, row.cells):
                 if name == 'ID':
                     pass
                 elif name == 'osm':
-                    assert 'osm_type' in self.result[i], \
-                           "Result row {} has no field 'osm_type'.\nFull row: {}"\
-                               .format(i, json.dumps(self.result[i], indent=4))
-                    assert self.result[i]['osm_type'] in (OSM_TYPE[value[0]], value[0]), \
-                           BadRowValueAssert(self, i, 'osm_type', value)
-                    self.assert_field(i, 'osm_id', value[1:])
-                elif name == 'osm_type':
-                    assert self.result[i]['osm_type'] in (OSM_TYPE[value[0]], value[0]), \
-                           BadRowValueAssert(self, i, 'osm_type', value)
+                    self.check_row_field(i, 'osm_type', OsmType(value[0]), base=subdict)
+                    self.check_row_field(i, 'osm_id', Field(value[1:]), base=subdict)
                 elif name == 'centroid':
                     if ' ' in value:
                         lon, lat = value.split(' ')
@@ -181,15 +134,43 @@ class GenericResponse:
                         lon, lat = context.osm.grid_node(int(value))
                     else:
                         raise RuntimeError("Context needed when using grid coordinates")
-                    self.assert_field(i, 'lat', float(lat))
-                    self.assert_field(i, 'lon', float(lon))
-                elif '+' in name:
-                    self.assert_subfield(i, name.split('+'), value)
+                    self.check_row_field(i, 'lat', Field(float(lat)), base=subdict)
+                    self.check_row_field(i, 'lon', Field(float(lon)), base=subdict)
                 else:
-                    self.assert_field(i, name, value)
+                    self.check_row_field(i, name, Field(value), base=subdict)
+
+
+    def check_row(self, idx, check, msg):
+        """ Assert for the condition 'check' and print 'msg' on fail together
+            with the contents of the failing result.
+        """
+        class _RowError:
+            def __init__(self, row):
+                self.row = row
+
+            def __str__(self):
+                return f"{msg}. Full row {idx}:\n" \
+                       + json.dumps(self.row, indent=4, ensure_ascii=False)
+
+        assert check, _RowError(self.result[idx])
+
+
+    def check_row_field(self, idx, field, expected, base=None):
+        """ Check field 'field' of result 'idx' for the expected value
+            and print a meaningful error if the condition fails.
+            When 'base' is set to a dictionary, then the field is checked
+            in that base. The error message will still report the contents
+            of the full result.
+        """
+        if base is None:
+            base = self.result[idx]
+
+        self.check_row(idx, field in base, f"No field '{field}'")
+        value = base[field]
+
+        self.check_row(idx, expected == value,
+                       f"\nBad value for field '{field}'. Expected: {expected}, got: {value}")
 
-    def property_list(self, prop):
-        return [x[prop] for x in self.result]
 
 
 class SearchResponse(GenericResponse):
@@ -240,24 +221,33 @@ class ReverseResponse(GenericResponse):
             if child.tag == 'result':
                 assert not self.result, "More than one result in reverse result"
                 self.result.append(dict(child.attrib))
+                check_for_attributes(self.result[0], 'display_name', 'absent')
+                self.result[0]['display_name'] = child.text
             elif child.tag == 'addressparts':
+                assert 'address' not in self.result[0], "More than one address in result"
                 address = {}
                 for sub in child:
+                    assert len(sub) == 0, f"Address element '{sub.tag}' has subelements"
                     address[sub.tag] = sub.text
                 self.result[0]['address'] = address
             elif child.tag == 'extratags':
+                assert 'extratags' not in self.result[0], "More than one extratags in result"
                 self.result[0]['extratags'] = {}
                 for tag in child:
+                    assert len(tag) == 0, f"Extratags element '{tag.attrib['key']}' has subelements"
                     self.result[0]['extratags'][tag.attrib['key']] = tag.attrib['value']
             elif child.tag == 'namedetails':
+                assert 'namedetails' not in self.result[0], "More than one namedetails in result"
                 self.result[0]['namedetails'] = {}
                 for tag in child:
+                    assert len(tag) == 0, f"Namedetails element '{tag.attrib['desc']}' has subelements"
                     self.result[0]['namedetails'][tag.attrib['desc']] = tag.text
             elif child.tag == 'geokml':
-                self.result[0][child.tag] = True
+                assert 'geokml' not in self.result[0], "More than one geokml in result"
+                self.result[0]['geokml'] = ET.tostring(child, encoding='unicode')
             else:
                 assert child.tag == 'error', \
-                       "Unknown XML tag {} on page: {}".format(child.tag, self.page)
+                       f"Unknown XML tag {child.tag} on page: {self.page}"
 
 
 class StatusResponse(GenericResponse):
index 1df1d523375665c7b1d1274b22c19610c8f7d09d..1c6fac693f9d494c0d6b308155c86f1d612d3acc 100644 (file)
@@ -15,11 +15,12 @@ import os
 import re
 import logging
 import asyncio
+import xml.etree.ElementTree as ET
 from urllib.parse import urlencode
 
 from utils import run_script
 from http_responses import GenericResponse, SearchResponse, ReverseResponse, StatusResponse
-from check_functions import Bbox
+from check_functions import Bbox, check_for_attributes
 from table_compare import NominatimID
 
 LOG = logging.getLogger(__name__)
@@ -48,6 +49,15 @@ BASE_SERVER_ENV = {
 }
 
 
+def make_todo_list(context, result_id):
+    if result_id is None:
+        context.execute_steps("then at least 1 result is returned")
+        return range(len(context.response.result))
+
+    context.execute_steps(f"then more than {result_id}results are returned")
+    return (int(result_id.strip()), )
+
+
 def compare(operator, op1, op2):
     if operator == 'less than':
         return op1 < op2
@@ -60,12 +70,16 @@ def compare(operator, op1, op2):
     elif operator == 'at most':
         return op1 <= op2
     else:
-        raise Exception("unknown operator '%s'" % operator)
+        raise ValueError(f"Unknown operator '{operator}'")
 
 
 def send_api_query(endpoint, params, fmt, context):
-    if fmt is not None and fmt.strip() != 'debug':
-        params['format'] = fmt.strip()
+    if fmt is not None:
+        if fmt.strip() == 'debug':
+            params['debug'] = '1'
+        else:
+            params['format'] = fmt.strip()
+
     if context.table:
         if context.table.headings[0] == 'param':
             for line in context.table:
@@ -88,11 +102,11 @@ def send_api_query_php(endpoint, params, context):
     env = dict(BASE_SERVER_ENV)
     env['QUERY_STRING'] = urlencode(params)
 
-    env['SCRIPT_NAME'] = '/%s.php' % endpoint
-    env['REQUEST_URI'] = '%s?%s' % (env['SCRIPT_NAME'], env['QUERY_STRING'])
+    env['SCRIPT_NAME'] = f'/{endpoint}.php'
+    env['REQUEST_URI'] = f"{env['SCRIPT_NAME']}?{env['QUERY_STRING']}"
     env['CONTEXT_DOCUMENT_ROOT'] = os.path.join(context.nominatim.website_dir.name, 'website')
     env['SCRIPT_FILENAME'] = os.path.join(env['CONTEXT_DOCUMENT_ROOT'],
-                                          '%s.php' % endpoint)
+                                          f'{endpoint}.php')
 
     LOG.debug("Environment:" + json.dumps(env, sort_keys=True, indent=2))
 
@@ -104,7 +118,7 @@ def send_api_query_php(endpoint, params, context):
         env['XDEBUG_MODE'] = 'coverage'
         env['COV_SCRIPT_FILENAME'] = env['SCRIPT_FILENAME']
         env['COV_PHP_DIR'] = context.nominatim.src_dir
-        env['COV_TEST_NAME'] = '%s:%s' % (context.scenario.filename, context.scenario.line)
+        env['COV_TEST_NAME'] = f"{context.scenario.filename}:{context.scenario.line}"
         env['SCRIPT_FILENAME'] = \
                 os.path.join(os.path.split(__file__)[0], 'cgi-with-coverage.php')
         cmd.append(env['SCRIPT_FILENAME'])
@@ -113,11 +127,11 @@ def send_api_query_php(endpoint, params, context):
         cmd.append(env['SCRIPT_FILENAME'])
 
     for k,v in params.items():
-        cmd.append("%s=%s" % (k, v))
+        cmd.append(f"{k}={v}")
 
     outp, err = run_script(cmd, cwd=context.nominatim.website_dir.name, env=env)
 
-    assert len(err) == 0, "Unexpected PHP error: %s" % (err)
+    assert len(err) == 0, f"Unexpected PHP error: {err}"
 
     if outp.startswith('Status: '):
         status = int(outp[8:11])
@@ -145,41 +159,37 @@ def website_search_request(context, fmt, query, addr):
         params['q'] = query
     if addr is not None:
         params['addressdetails'] = '1'
-    if fmt and fmt.strip() == 'debug':
-        params['debug'] = '1'
 
     outp, status = send_api_query('search', params, fmt, context)
 
     context.response = SearchResponse(outp, fmt or 'json', status)
 
-@when(u'sending (?P<fmt>\S+ )?reverse coordinates (?P<lat>.+)?,(?P<lon>.+)?')
-def website_reverse_request(context, fmt, lat, lon):
+
+@when('sending v1/reverse at (?P<lat>[\d.-]*),(?P<lon>[\d.-]*)(?: with format (?P<fmt>.+))?')
+def api_endpoint_v1_reverse(context, lat, lon, fmt):
     params = {}
     if lat is not None:
         params['lat'] = lat
     if lon is not None:
         params['lon'] = lon
-    if fmt and fmt.strip() == 'debug':
-        params['debug'] = '1'
+    if fmt is None:
+        fmt = 'jsonv2'
+    elif fmt == "''":
+        fmt = None
 
     outp, status = send_api_query('reverse', params, fmt, context)
-
     context.response = ReverseResponse(outp, fmt or 'xml', status)
 
-@when(u'sending (?P<fmt>\S+ )?reverse point (?P<nodeid>.+)')
-def website_reverse_request(context, fmt, nodeid):
+
+@when('sending v1/reverse N(?P<nodeid>\d+)(?: with format (?P<fmt>.+))?')
+def api_endpoint_v1_reverse_from_node(context, nodeid, fmt):
     params = {}
-    if fmt and fmt.strip() == 'debug':
-        params['debug'] = '1'
     params['lon'], params['lat'] = (f'{c:f}' for c in context.osm.grid_node(int(nodeid)))
 
-
     outp, status = send_api_query('reverse', params, fmt, context)
-
     context.response = ReverseResponse(outp, fmt or 'xml', status)
 
 
-
 @when(u'sending (?P<fmt>\S+ )?details query for (?P<query>.*)')
 def website_details_request(context, fmt, query):
     params = {}
@@ -211,15 +221,15 @@ def website_status_request(context, fmt):
 
 @step(u'(?P<operator>less than|more than|exactly|at least|at most) (?P<number>\d+) results? (?:is|are) returned')
 def validate_result_number(context, operator, number):
-    assert context.response.errorcode == 200
+    context.execute_steps("Then a HTTP 200 is returned")
     numres = len(context.response.result)
     assert compare(operator, numres, int(number)), \
-        "Bad number of results: expected {} {}, got {}.".format(operator, number, numres)
+           f"Bad number of results: expected {operator} {number}, got {numres}."
 
 @then(u'a HTTP (?P<status>\d+) is returned')
 def check_http_return_status(context, status):
     assert context.response.errorcode == int(status), \
-           "Return HTTP status is {}.".format(context.response.errorcode)
+           f"Return HTTP status is {context.response.errorcode}."
 
 @then(u'the page contents equals "(?P<text>.+)"')
 def check_page_content_equals(context, text):
@@ -228,7 +238,19 @@ def check_page_content_equals(context, text):
 @then(u'the result is valid (?P<fmt>\w+)')
 def step_impl(context, fmt):
     context.execute_steps("Then a HTTP 200 is returned")
-    assert context.response.format == fmt
+    if fmt.strip() == 'html':
+        try:
+            tree = ET.fromstring(context.response.page)
+        except Exception as ex:
+            assert False, f"Could not parse page:\n{context.response.page}"
+
+        assert tree.tag == 'html'
+        body = tree.find('./body')
+        assert body is not None
+        assert body.find('.//script') is None
+    else:
+        assert context.response.format == fmt
+
 
 @then(u'a (?P<fmt>\w+) user error is returned')
 def check_page_error(context, fmt):
@@ -243,49 +265,31 @@ def check_page_error(context, fmt):
 @then(u'result header contains')
 def check_header_attr(context):
     for line in context.table:
-        assert re.fullmatch(line['value'], context.response.header[line['attr']]) is not None, \
-               "attribute '%s': expected: '%s', got '%s'" % (
-                    line['attr'], line['value'],
-                    context.response.header[line['attr']])
+        value = context.response.header[line['attr']]
+        assert re.fullmatch(line['value'], value) is not None, \
+               f"Attribute '{line['attr']}': expected: '{line['value']}', got '{value}'"
+
 
 @then(u'result header has (?P<neg>not )?attributes (?P<attrs>.*)')
 def check_header_no_attr(context, neg, attrs):
-    for attr in attrs.split(','):
-        if neg:
-            assert attr not in context.response.header, \
-                   "Unexpected attribute {}. Full response:\n{}".format(
-                       attr, json.dumps(context.response.header, sort_keys=True, indent=2))
-        else:
-            assert attr in context.response.header, \
-                   "No attribute {}. Full response:\n{}".format(
-                       attr, json.dumps(context.response.header, sort_keys=True, indent=2))
+    check_for_attributes(context.response.header, attrs,
+                         'absent' if neg else 'present')
 
-@then(u'results contain')
-def step_impl(context):
+
+@then(u'results contain(?: in field (?P<field>.*))?')
+def step_impl(context, field):
     context.execute_steps("then at least 1 result is returned")
 
     for line in context.table:
-        context.response.match_row(line, context=context)
+        context.response.match_row(line, context=context, field=field)
+
 
 @then(u'result (?P<lid>\d+ )?has (?P<neg>not )?attributes (?P<attrs>.*)')
 def validate_attributes(context, lid, neg, attrs):
-    if lid is None:
-        idx = range(len(context.response.result))
-        context.execute_steps("then at least 1 result is returned")
-    else:
-        idx = [int(lid.strip())]
-        context.execute_steps("then more than %sresults are returned" % lid)
-
-    for i in idx:
-        for attr in attrs.split(','):
-            if neg:
-                assert attr not in context.response.result[i],\
-                       "Unexpected attribute {}. Full response:\n{}".format(
-                           attr, json.dumps(context.response.result[i], sort_keys=True, indent=2))
-            else:
-                assert attr in context.response.result[i], \
-                       "No attribute {}. Full response:\n{}".format(
-                           attr, json.dumps(context.response.result[i], sort_keys=True, indent=2))
+    for i in make_todo_list(context, lid):
+        check_for_attributes(context.response.result[i], attrs,
+                             'absent' if neg else 'present')
+
 
 @then(u'result addresses contain')
 def step_impl(context):
@@ -300,7 +304,7 @@ def step_impl(context):
 
 @then(u'address of result (?P<lid>\d+) has(?P<neg> no)? types (?P<attrs>.*)')
 def check_address(context, lid, neg, attrs):
-    context.execute_steps("then more than %s results are returned" % lid)
+    context.execute_steps(f"then more than {lid} results are returned")
 
     addr_parts = context.response.result[int(lid)]['address']
 
@@ -312,7 +316,7 @@ def check_address(context, lid, neg, attrs):
 
 @then(u'address of result (?P<lid>\d+) (?P<complete>is|contains)')
 def check_address(context, lid, complete):
-    context.execute_steps("then more than %s results are returned" % lid)
+    context.execute_steps(f"then more than {lid} results are returned")
 
     lid = int(lid)
     addr_parts = dict(context.response.result[lid]['address'])
@@ -322,38 +326,30 @@ def check_address(context, lid, complete):
         del addr_parts[line['type']]
 
     if complete == 'is':
-        assert len(addr_parts) == 0, "Additional address parts found: %s" % str(addr_parts)
+        assert len(addr_parts) == 0, f"Additional address parts found: {addr_parts!s}"
 
-@then(u'result (?P<lid>\d+ )?has bounding box in (?P<coords>[\d,.-]+)')
-def step_impl(context, lid, coords):
-    if lid is None:
-        context.execute_steps("then at least 1 result is returned")
-        bboxes = context.response.property_list('boundingbox')
-    else:
-        context.execute_steps("then more than {}results are returned".format(lid))
-        bboxes = [context.response.result[int(lid)]['boundingbox']]
 
+@then(u'result (?P<lid>\d+ )?has bounding box in (?P<coords>[\d,.-]+)')
+def check_bounding_box_in_area(context, lid, coords):
     expected = Bbox(coords)
 
-    for bbox in bboxes:
-        assert bbox in expected, "Bbox {} is not contained in {}.".format(bbox, expected)
+    for idx in make_todo_list(context, lid):
+        res = context.response.result[idx]
+        check_for_attributes(res, 'boundingbox')
+        context.response.check_row(idx, res['boundingbox'] in expected,
+                                   f"Bbox is not contained in {expected}")
 
-@then(u'result (?P<lid>\d+ )?has centroid in (?P<coords>[\d,.-]+)')
-def step_impl(context, lid, coords):
-    if lid is None:
-        context.execute_steps("then at least 1 result is returned")
-        centroids = zip(context.response.property_list('lon'),
-                        context.response.property_list('lat'))
-    else:
-        context.execute_steps("then more than %sresults are returned".format(lid))
-        res = context.response.result[int(lid)]
-        centroids = [(res['lon'], res['lat'])]
 
+@then(u'result (?P<lid>\d+ )?has centroid in (?P<coords>[\d,.-]+)')
+def check_centroid_in_area(context, lid, coords):
     expected = Bbox(coords)
 
-    for centroid in centroids:
-        assert centroid in expected,\
-               "Centroid {} is not inside {}.".format(centroid, expected)
+    for idx in make_todo_list(context, lid):
+        res = context.response.result[idx]
+        check_for_attributes(res, 'lat,lon')
+        context.response.check_row(idx, (res['lon'], res['lat']) in expected,
+                                   f"Centroid is not inside {expected}")
+
 
 @then(u'there are(?P<neg> no)? duplicates')
 def check_for_duplicates(context, neg):
@@ -370,6 +366,7 @@ def check_for_duplicates(context, neg):
         resarr.add(dup)
 
     if neg:
-        assert not has_dupe, "Found duplicate for %s" % (dup, )
+        assert not has_dupe, f"Found duplicate for {dup}"
     else:
         assert has_dupe, "No duplicates found"
+
diff --git a/test/python/tokenizer/sanitizers/test_delete_tags.py b/test/python/tokenizer/sanitizers/test_delete_tags.py
new file mode 100644 (file)
index 0000000..f9ccc2f
--- /dev/null
@@ -0,0 +1,327 @@
+# SPDX-License-Identifier: GPL-2.0-only\r
+#\r
+# This file is part of Nominatim. (https://nominatim.org)\r
+#\r
+# Copyright (C) 2023 by the Nominatim developer community.\r
+# For a full list of authors see the git log.\r
+"""\r
+Tests for the sanitizer that normalizes housenumbers.\r
+"""\r
+import pytest\r
+\r
+\r
+from nominatim.data.place_info import PlaceInfo\r
+from nominatim.tokenizer.place_sanitizer import PlaceSanitizer\r
+\r
+\r
+class TestWithDefault:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, type, **kwargs):\r
+\r
+        place = PlaceInfo({type: {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {'step': 'delete-tags'}\r
+\r
+        name, address = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return {\r
+                'name': sorted([(p.name, p.kind, p.suffix or '') for p in name]),\r
+                'address': sorted([(p.name, p.kind, p.suffix or '') for p in address])\r
+            }\r
+\r
+\r
+    def test_on_name(self):\r
+        res = self.run_sanitizer_on('name', name='foo', ref='bar', ref_abc='baz')\r
+\r
+        assert res.get('name') == []\r
+\r
+    def test_on_address(self):\r
+        res = self.run_sanitizer_on('address', name='foo', ref='bar', ref_abc='baz')\r
+\r
+        assert res.get('address') == [('bar', 'ref', ''), ('baz', 'ref', 'abc'),\r
+                                        ('foo', 'name', '')]\r
+\r
+\r
+class TestTypeField:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, type, **kwargs):\r
+\r
+        place = PlaceInfo({'name': {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {\r
+                        'step': 'delete-tags',\r
+                        'type': type,\r
+                    }\r
+\r
+        name, _ = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return sorted([(p.name, p.kind, p.suffix or '') for p in name])\r
+\r
+    def test_name_type(self):\r
+        res = self.run_sanitizer_on('name', name='foo', ref='bar', ref_abc='baz')\r
+\r
+        assert res == []\r
+\r
+    def test_address_type(self):\r
+        res = self.run_sanitizer_on('address', name='foo', ref='bar', ref_abc='baz')\r
+\r
+        assert res == [('bar', 'ref', ''), ('baz', 'ref', 'abc'),\r
+                        ('foo', 'name', '')]\r
+\r
+class TestFilterKind:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, filt, **kwargs):\r
+\r
+        place = PlaceInfo({'name': {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {\r
+                        'step': 'delete-tags',\r
+                        'filter-kind': filt,\r
+                    }\r
+\r
+        name, _ = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return sorted([(p.name, p.kind, p.suffix or '') for p in name])\r
+\r
+    def test_single_exact_name(self):\r
+        res = self.run_sanitizer_on(['name'], ref='foo', name='foo',\r
+                                    name_abc='bar', ref_abc='bar')\r
+\r
+        assert res == [('bar', 'ref', 'abc'), ('foo', 'ref', '')]\r
+\r
+\r
+    def test_single_pattern(self):\r
+        res = self.run_sanitizer_on(['.*name'],\r
+                                    name_fr='foo', ref_fr='foo', namexx_fr='bar',\r
+                                    shortname_fr='bar', name='bar')\r
+\r
+        assert res == [('bar', 'namexx', 'fr'), ('foo', 'ref', 'fr')]\r
+\r
+\r
+    def test_multiple_patterns(self):\r
+        res = self.run_sanitizer_on(['.*name', 'ref'],\r
+                                    name_fr='foo', ref_fr='foo', oldref_fr='foo',\r
+                                    namexx_fr='bar', shortname_fr='baz', name='baz')\r
+\r
+        assert res == [('bar', 'namexx', 'fr'), ('foo', 'oldref', 'fr')]\r
+\r
+\r
+class TestRankAddress:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, rank_addr, **kwargs):\r
+\r
+        place = PlaceInfo({'name': {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {\r
+                        'step': 'delete-tags',\r
+                        'rank_address': rank_addr\r
+                    }\r
+\r
+        name, _ = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return sorted([(p.name, p.kind, p.suffix or '') for p in name])\r
+\r
+\r
+    def test_single_rank(self):\r
+        res = self.run_sanitizer_on('30', name='foo', ref='bar')\r
+\r
+        assert res == []\r
+\r
+    def test_single_rank_fail(self):\r
+        res = self.run_sanitizer_on('28', name='foo', ref='bar')\r
+\r
+        assert res == [('bar', 'ref', ''), ('foo', 'name', '')]\r
+\r
+    def test_ranged_rank_pass(self):\r
+        res = self.run_sanitizer_on('26-30', name='foo', ref='bar')\r
+\r
+        assert res == []\r
+\r
+    def test_ranged_rank_fail(self):\r
+        res = self.run_sanitizer_on('26-29', name='foo', ref='bar')\r
+\r
+        assert res == [('bar', 'ref', ''), ('foo', 'name', '')]\r
+\r
+    def test_mixed_rank_pass(self):\r
+        res = self.run_sanitizer_on(['4', '20-28', '30', '10-12'], name='foo', ref='bar')\r
+\r
+        assert res == []\r
+\r
+    def test_mixed_rank_fail(self):\r
+        res = self.run_sanitizer_on(['4-8', '10', '26-29', '18'], name='foo', ref='bar')\r
+\r
+        assert res == [('bar', 'ref', ''), ('foo', 'name', '')]\r
+\r
+\r
+class TestSuffix:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, suffix, **kwargs):\r
+\r
+        place = PlaceInfo({'name': {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {\r
+                        'step': 'delete-tags',\r
+                        'suffix': suffix,\r
+                    }\r
+\r
+        name, _ = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return sorted([(p.name, p.kind, p.suffix or '') for p in name])\r
+\r
+\r
+    def test_single_suffix(self):\r
+        res = self.run_sanitizer_on('abc', name='foo', name_abc='foo',\r
+                                 name_pqr='bar', ref='bar', ref_abc='baz')\r
+\r
+        assert res == [('bar', 'name', 'pqr'), ('bar', 'ref', ''), ('foo', 'name', '')]\r
+\r
+    def test_multiple_suffix(self):\r
+        res = self.run_sanitizer_on(['abc.*', 'pqr'], name='foo', name_abcxx='foo',\r
+                                 ref_pqr='bar', name_pqrxx='baz')\r
+\r
+        assert res == [('baz', 'name', 'pqrxx'), ('foo', 'name', '')]\r
+\r
+\r
+\r
+class TestCountryCodes:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, country_code, **kwargs):\r
+\r
+        place = PlaceInfo({'name': {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {\r
+                        'step': 'delete-tags',\r
+                        'country_code': country_code,\r
+                    }\r
+\r
+        name, _ = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return sorted([(p.name, p.kind) for p in name])\r
+\r
+\r
+    def test_single_country_code_pass(self):\r
+        res = self.run_sanitizer_on('de', name='foo', ref='bar')\r
+\r
+        assert res == []\r
+\r
+    def test_single_country_code_fail(self):\r
+        res = self.run_sanitizer_on('in', name='foo', ref='bar')\r
+\r
+        assert res == [('bar', 'ref'), ('foo', 'name')]\r
+\r
+    def test_empty_country_code_list(self):\r
+        res = self.run_sanitizer_on([], name='foo', ref='bar')\r
+\r
+        assert res == [('bar', 'ref'), ('foo', 'name')]\r
+\r
+    def test_multiple_country_code_pass(self):\r
+        res = self.run_sanitizer_on(['in', 'de', 'fr'], name='foo', ref='bar')\r
+\r
+        assert res == []\r
+\r
+    def test_multiple_country_code_fail(self):\r
+        res = self.run_sanitizer_on(['in', 'au', 'fr'], name='foo', ref='bar')\r
+\r
+        assert res == [('bar', 'ref'), ('foo', 'name')]\r
+\r
+class TestAllParameters:\r
+\r
+    @pytest.fixture(autouse=True)\r
+    def setup_country(self, def_config):\r
+        self.config = def_config\r
+\r
+    def run_sanitizer_on(self, country_code, rank_addr, suffix, **kwargs):\r
+\r
+        place = PlaceInfo({'name': {k.replace('_', ':'): v for k, v in kwargs.items()},\r
+                            'country_code': 'de', 'rank_address': 30})\r
+\r
+        sanitizer_args = {\r
+                        'step': 'delete-tags',\r
+                        'type': 'name',\r
+                        'filter-kind': ['name', 'ref'],\r
+                        'country_code': country_code,\r
+                        'rank_address': rank_addr,\r
+                        'suffix': suffix,\r
+                        'name': r'[\s\S]*',\r
+                    }\r
+\r
+        name, _ = PlaceSanitizer([sanitizer_args],\r
+                                    self.config).process_names(place)\r
+\r
+        return sorted([(p.name, p.kind, p.suffix or '') for p in name])\r
+\r
+\r
+    def test_string_arguments_pass(self):\r
+        res = self.run_sanitizer_on('de', '25-30', r'[\s\S]*',\r
+                                    name='foo', ref='foo', name_abc='bar', ref_abc='baz')\r
+\r
+        assert res == []\r
+\r
+    def test_string_arguments_fail(self):\r
+        res = self.run_sanitizer_on('in', '25-30', r'[\s\S]*',\r
+                                    name='foo', ref='foo', name_abc='bar', ref_abc='baz')\r
+\r
+        assert res == [('bar', 'name', 'abc'), ('baz', 'ref', 'abc'),\r
+                       ('foo', 'name', ''), ('foo', 'ref', '')]\r
+\r
+    def test_list_arguments_pass(self):\r
+        res = self.run_sanitizer_on(['de', 'in'], ['20-28', '30'], [r'abc.*', r'[\s\S]*'],\r
+                                    name='foo', ref_abc='foo', name_abcxx='bar', ref_pqr='baz')\r
+\r
+        assert res == []\r
+\r
+    def test_list_arguments_fail(self):\r
+        res = self.run_sanitizer_on(['de', 'in'], ['14', '20-29'], [r'abc.*', r'pqr'],\r
+                                    name='foo', ref_abc='foo', name_abcxx='bar', ref_pqr='baz')\r
+\r
+        assert res == [('bar', 'name', 'abcxx'), ('baz', 'ref', 'pqr'),\r
+                       ('foo', 'name', ''), ('foo', 'ref', 'abc')]\r
+\r
+    def test_mix_arguments_pass(self):\r
+        res = self.run_sanitizer_on('de', ['10', '20-28', '30'], r'[\s\S]*',\r
+                                    name='foo', ref_abc='foo', name_abcxx='bar', ref_pqr='baz')\r
+\r
+        assert res == []\r
+\r
+    def test_mix_arguments_fail(self):\r
+        res = self.run_sanitizer_on(['de', 'in'], ['10', '20-28', '30'], r'abc.*',\r
+                                    name='foo', ref='foo', name_pqr='bar', ref_pqr='baz')\r
+\r
+        assert res == [('bar', 'name', 'pqr'), ('baz', 'ref', 'pqr'),\r
+                       ('foo', 'name', ''), ('foo', 'ref', '')]
\ No newline at end of file