# 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", }, ], "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' 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() == 2 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() == 2 # 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