# passerelle - uniform access to multiple data sources and services # Copyright (C) 2021 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import datetime import json from unittest import mock import pytest from httmock import HTTMock, remember_called, response, urlmatch from requests.exceptions import ConnectionError, HTTPError import tests.utils from passerelle.apps.photon.models import AddressCacheModel, Photon CONTENT = { 'features': [ { 'geometry': {'coordinates': [4.8522272, 45.7587414], 'type': 'Point'}, 'properties': { 'city': 'Lyon 3ème Arrondissement', 'country': 'France', 'housenumber': '208', 'osm_id': 154419, 'osm_key': 'place', 'osm_type': 'N', 'osm_value': 'house', 'postcode': '69003', 'street': 'Rue Garibaldi', 'type': 'house', }, 'type': 'Feature', }, { 'geometry': {'coordinates': [4.8522681, 45.7585214], 'type': 'Point'}, 'properties': { 'city': 'Lyon 3ème Arrondissement', 'country': 'France', 'housenumber': '208bis', 'osm_id': 153400, 'osm_key': 'place', 'osm_type': 'N', 'osm_value': 'house', 'postcode': '69003', 'street': 'Rue Garibaldi', 'type': 'house', }, 'type': 'Feature', }, { 'geometry': {'coordinates': [4.8796, 45.7665], 'type': 'Point'}, 'type': 'Feature', 'properties': { 'osm_id': 1235833, 'country': 'France', 'city': 'Villeurbanne', 'countrycode': 'FR', 'postcode': '69601', 'type': 'house', 'osm_type': 'N', 'osm_key': 'amenity', 'housenumber': '2', 'street': 'Place Docteur Lazare Goujon', 'extra': {'metropole': 'true', 'espace_public': 'true'}, 'osm_value': 'townhall', 'name': 'Hôtel de Ville de Villeurbanne', }, }, ], 'type': 'FeatureCollection', } FAKED_CONTENT = json.dumps(CONTENT) @urlmatch(netloc='^example.net$', path='^/path/api/$') @remember_called def photon_search(url, request): return response(200, CONTENT, request=request) @urlmatch(netloc='^example.net$', path='^/path/reverse/$') @remember_called def photon_reverse(url, request): return response(200, CONTENT, request=request) @pytest.fixture def mock_photon_search(): with HTTMock(photon_search): yield photon_search @pytest.fixture def mock_photon_reverse(): with HTTMock(photon_reverse): yield photon_reverse @pytest.fixture def photon(db): return tests.utils.setup_access_rights( Photon.objects.create( slug='test', service_url='http://example.net/path/', ) ) @mock.patch('passerelle.utils.Request.get') def test_photon_search(mocked_get, app, photon): endpoint = tests.utils.generic_endpoint_url('photon', 'search', slug=photon.slug) mocked_get.return_value = tests.utils.FakedResponse(content=FAKED_CONTENT, status_code=200) resp = app.get(endpoint, params={'q': 'plop'}, status=200) assert mocked_get.call_args[0][0].startswith('http://example.net/path/api/?') data = resp.json[0] assert data['lat'] == '45.7587414' assert data['lon'] == '4.8522272' assert data['display_name'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' @mock.patch('passerelle.utils.Request.get') def test_photon_search_get_extra_properties(mocked_get, app, photon): endpoint = tests.utils.generic_endpoint_url('photon', 'search', slug=photon.slug) mocked_get.return_value = tests.utils.FakedResponse(content=FAKED_CONTENT, status_code=200) resp = app.get(endpoint, params={'q': 'plop'}, status=200) assert mocked_get.call_args[0][0].startswith('http://example.net/path/api/?') data = resp.json[2] assert ( data['display_name'] == 'Hôtel de Ville de Villeurbanne, 2, Place Docteur Lazare Goujon 69601 Villeurbanne' ) assert data['extra'] == { 'countrycode': 'FR', 'extra': {'espace_public': 'true', 'metropole': 'true'}, 'osm_id': 1235833, 'osm_key': 'amenity', 'osm_type': 'N', 'osm_value': 'townhall', 'type': 'house', } def test_photon_search_qs(app, photon, mock_photon_search): resp = app.get('/photon/%s/search?q=plop' % photon.slug) assert 'display_name' in resp.json[0] def test_photon_search_qs_zipcode(app, photon, mock_photon_search): resp = app.get('/photon/%s/search?q=plop&zipcode=69003' % photon.slug) assert 'display_name' in resp.json[0] @mock.patch('passerelle.utils.Request.get') def test_photon_search_qs_lat_lon(mocked_get, app, photon): app.get('/photon/%s/search?q=plop&lat=0&lon=1' % photon.slug) assert 'lat=0' in mocked_get.call_args[0][0] assert 'lon=1' in mocked_get.call_args[0][0] @mock.patch('passerelle.utils.Request.get') def test_photon_search_qs_viewbox(mocked_get, app, photon): app.get('/photon/%s/search?q=plop&viewbox=4.830,45.753,4.831,45.754&bounded=1' % photon.slug) assert ( mocked_get.call_args[0][0] == 'http://example.net/path/api/?q=plop&limit=1&lang=fr&bbox=4.830%2C45.753%2C4.831%2C45.754' ) def test_photon_search_qs_empty(app, photon, mock_photon_search): resp = app.get('/photon/%s/search?q=' % photon.slug) assert len(resp.json) == 0 def test_photon_search_qs_parameters_error(app, photon, mock_photon_search): resp = app.get('/photon/%s/search' % photon.slug, status=400) assert resp.json['err'] == 1 assert resp.json['err_class'] == 'passerelle.views.WrongParameter' assert resp.json['err_desc'] == "missing parameters: 'q'." @mock.patch('passerelle.utils.Request.get') def test_photon_api_timeout(mocked_get, app, photon): mocked_get.side_effect = ConnectionError('Remote end closed connection without response') resp = app.get('/photon/%s/search' % photon.slug, params={'q': 'plop'}) assert resp.status_code == 200 assert resp.json['err'] == 1 assert ( resp.json['err_desc'] == 'failed to get http://example.net/path/api/?q=plop&limit=1&lang=fr: Remote end closed connection without response' ) @mock.patch('passerelle.utils.Request.get') def test_photon_api_error(mocked_get, app, photon): def raise_for_status(): raise HTTPError('400 Client Error: Bad Request for url: xxx') response = tests.utils.FakedResponse(content=json.dumps({'title': 'error'}), status_code=400) response.raise_for_status = raise_for_status mocked_get.return_value = response resp = app.get('/photon/%s/search' % photon.slug, params={'q': 'plop'}, status=200) assert resp.json['err'] == 1 assert ( resp.json['err_desc'] == 'failed to get http://example.net/path/api/?q=plop&limit=1&lang=fr: 400 Client Error: Bad Request for url: xxx' ) @mock.patch('passerelle.utils.Request.get') def test_photon_reverse_path(mocked_get, app, photon): mocked_get.return_value = tests.utils.FakedResponse(content=json.dumps({'features': []}), status_code=200) app.get('/photon/%s/reverse?lon=4.8522272&lat=45.7587414' % photon.slug) assert mocked_get.call_args[0][0].startswith('http://example.net/path/reverse/?') def test_photon_reverse(app, photon, mock_photon_reverse): resp = app.get('/photon/%s/reverse?lon=4.8522272&lat=45.7587414' % photon.slug) data = resp.json assert 'display_name' in data assert data['address']['city'] == 'Lyon 3ème Arrondissement' assert data['address']['postcode'] == '69003' assert data['address']['house_number'] == '208' @mock.patch('passerelle.utils.Request.get') def test_photon_addresses(mocked_get, app, photon): endpoint = tests.utils.generic_endpoint_url('photon', 'addresses', slug=photon.slug) assert endpoint == '/photon/test/addresses' mocked_get.return_value = tests.utils.FakedResponse(content=FAKED_CONTENT, status_code=200) resp = app.get(endpoint, params={'q': 'plop'}, status=200) data = resp.json['data'][0] assert data['lat'] == '45.7587414' assert data['lon'] == '4.8522272' assert data['display_name'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' assert data['id'] == '2644ca352fdfef476b3f83ee99f0e8e5' assert data['address']['city'] == 'Lyon 3ème Arrondissement' assert data['address']['postcode'] == '69003' assert data['address']['road'] == 'Rue Garibaldi' @mock.patch('passerelle.utils.Request.get') def test_photon_addresses_qs_page_limit(mocked_get, app, photon): resp = app.get('/photon/%s/addresses?q=plop&page_limit=1' % photon.slug) assert 'limit=1' in mocked_get.call_args[0][0] resp = app.get('/photon/%s/addresses?q=plop&page_limit=100' % photon.slug) assert 'limit=20' in mocked_get.call_args[0][0] resp = app.get('/photon/%s/addresses?q=plop&page_limit=blabla' % photon.slug, status=400) assert 'invalid value' in resp.json['err_desc'] def test_photon_addresses_qs_zipcode(app, photon, mock_photon_search): resp = app.get('/photon/%s/addresses?q=plop&zipcode=69003' % photon.slug) assert 'display_name' in resp.json['data'][0] @mock.patch('passerelle.utils.Request.get') def test_photon_addresses_qs_coordinates(mocked_get, app, photon): photon.latitude = 1.2 photon.longitude = 2.1 photon.save() app.get('/photon/%s/addresses?q=plop' % photon.slug) assert 'lat=%s' % photon.latitude in mocked_get.call_args[0][0] assert 'lon=%s' % photon.longitude in mocked_get.call_args[0][0] app.get('/photon/%s/addresses?q=plop&lat=42&lon=43' % photon.slug) assert 'lat=42' in mocked_get.call_args[0][0] assert 'lon=43' in mocked_get.call_args[0][0] def test_photon_addresses_cache(app, photon, mock_photon_search): resp = app.get('/photon/%s/addresses?q=plop' % photon.slug) assert mock_photon_search.call['count'] == 1 data = resp.json['data'][0] assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' api_id = data['id'] assert AddressCacheModel.objects.filter(api_id=api_id).exists() assert AddressCacheModel.objects.count() == 3 resp = app.get('/photon/%s/addresses?id=%s' % (photon.slug, api_id)) assert mock_photon_search.call['count'] == 1 # no new call assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' assert 'address' in data resp = app.get('/photon/%s/addresses?q=plop' % photon.slug) assert AddressCacheModel.objects.count() == 3 # no new object has been created def test_photon_addresses_cache_err(app, photon, mock_photon_search): resp = app.get('/photon/%s/addresses?id=%s' % (photon.slug, 'wrong_id')) assert mock_photon_search.call['count'] == 0 assert 'err' in resp.json @mock.patch('passerelle.utils.Request.get') def test_photon_addresses_data_change(mocked_get, app, photon): endpoint = tests.utils.generic_endpoint_url('photon', 'addresses', slug=photon.slug) mocked_get.return_value = tests.utils.FakedResponse(content=FAKED_CONTENT, status_code=200) # one user selects an address resp = app.get(endpoint, params={'q': 'plop'}, status=200) data = resp.json['data'][0] address_id, address_text = data['id'], data['text'] # another requests the same while upstream data has been updated new_content = json.loads(FAKED_CONTENT) new_content['features'][0]['properties']['housenumber'] = 'changed' mocked_get.return_value = tests.utils.FakedResponse(content=json.dumps(new_content), status_code=200) resp = app.get(endpoint, params={'q': 'plop'}, status=200) # first user saves the form, data should not have changed resp = app.get(endpoint, params={'id': address_id}, status=200) assert resp.json['data'][0]['text'] == address_text # when cache is cleared, we get the updated data AddressCacheModel.objects.all().delete() resp = app.get(endpoint, params={'q': 'plop'}, status=200) assert 'changed' in resp.json['data'][0]['text'] def test_photon_reverse_cache(app, photon, freezer, mock_photon_reverse, mock_photon_search): assert AddressCacheModel.objects.count() == 0 resp = app.get('/photon/%s/reverse?lon=4.8522272&lat=45.7587414' % photon.slug) assert mock_photon_reverse.call['count'] == 1 data = resp.json assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' api_id = data['id'] assert AddressCacheModel.objects.filter(api_id=api_id).exists() assert AddressCacheModel.objects.count() == 1 first_timestamp = AddressCacheModel.objects.get().timestamp resp = app.get('/photon/%s/addresses?id=%s' % (photon.slug, api_id)) assert mock_photon_search.call['count'] == 0 assert data['text'] == '208, Rue Garibaldi 69003 Lyon 3ème Arrondissement' assert 'address' in data # check caching timestamp update freezer.move_to(datetime.timedelta(hours=1, seconds=1)) resp = app.get('/photon/%s/reverse?lon=4.8522272&lat=45.7587414' % photon.slug) assert mock_photon_reverse.call['count'] == 2 assert AddressCacheModel.objects.get().timestamp > first_timestamp @mock.patch('passerelle.utils.Request.get') def test_photon_non_json(mocked_get, app, photon): response = tests.utils.FakedResponse(content=b'xxx', status_code=200) mocked_get.return_value = response resp = app.get('/photon/%s/search' % photon.slug, params={'q': 'plop'}, status=200) assert resp.json['err'] == 1 assert resp.json['err_desc'] == "invalid photon response (b'xxx')" resp = app.get('/photon/%s/reverse' % photon.slug, params={'lat': '0', 'lon': '0'}, status=200) assert resp.json['err'] == 1 assert resp.json['err_desc'] == "invalid photon response (b'xxx')" @pytest.mark.parametrize( 'endpoint', ['/photon/test/addresses', '/photon/test/search', '/photon/test/reverse'] ) @pytest.mark.parametrize( 'content', [ '', '{"features": ""}', '{"features": null}', '{"features": [null]}', '{"features": [{}]}', '{"features": [{"properties": null, "geometry": null}]}', '{"features": [{"properties": {}, "geometry": {}}]}', '{"features": [{"properties": {}, "geometry": {"type": ""}}]}', '{"features": [{"properties": {}, "geometry": {"type": "", "coordinates": null}}]}', '{"features": [{"properties": {}, "geometry": {"type": "", "coordinates": [42]}}]}', ], ) @mock.patch('passerelle.utils.Request.get') def test_photon_bad_geojson_response(mocked_get, content, endpoint, app, photon): mocked_get.return_value = tests.utils.FakedResponse(content=content, status_code=200) resp = app.get(endpoint, params={'q': 'plop', 'lat': 48, 'lon': 2}) assert resp.json['err'] == 1