From 1ef1483164b11eaeb1a67a70f15df38f816c3af9 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:04:10 -0800 Subject: [PATCH 1/2] feat: add async get_events_export() and _get_raw() helper (ERA-12670) Add get_events_export(filter=None) to AsyncERClient for parity with the existing sync method. Because the endpoint returns a CSV file rather than JSON, a new _get_raw() helper is introduced that returns the raw httpx.Response without attempting JSON parsing while preserving the standard error-handling pipeline. Also adds comprehensive tests for both the sync and async get_events_export() methods covering success, filter forwarding, URL construction, and error cases (401, 403, 404, 500). Co-authored-by: Cursor --- erclient/client.py | 57 +++++ tests/async_client/test_get_events_export.py | 210 +++++++++++++++++++ tests/sync_client/__init__.py | 0 tests/sync_client/conftest.py | 21 ++ tests/sync_client/test_get_events_export.py | 135 ++++++++++++ 5 files changed, 423 insertions(+) create mode 100644 tests/async_client/test_get_events_export.py create mode 100644 tests/sync_client/__init__.py create mode 100644 tests/sync_client/conftest.py create mode 100644 tests/sync_client/test_get_events_export.py diff --git a/erclient/client.py b/erclient/client.py index 02bbbc0..89849ed 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -1163,6 +1163,20 @@ async def get_observations(self, **kwargs): async for observation in self._get_data(endpoint='observations', params=params, batch_size=batch_size): yield observation + async def get_events_export(self, filter=None): + """Download events as a CSV export. + + :param filter: Optional JSON-encoded filter string passed as a query + parameter. When *None* no filter is applied. + :return: The raw ``httpx.Response`` whose body contains the CSV + payload. This matches the sync client behaviour where the caller + can stream or read the body at will. + """ + params = {} + if filter: + params['filter'] = filter + return await self._get_raw('activity/events/export/', params=params) + async def post_camera_trap_report(self, camera_trap_payload, file=None): camera_trap_report_path = f'sensors/camera-trap/{self.provider_key}/status/' @@ -1426,6 +1440,49 @@ async def _get_data(self, endpoint, params, batch_size=0): async def _get(self, path, params=None): return await self._call(path=path, payload=None, method="GET", params=params) + async def _get_raw(self, path, params=None): + """Issue a GET and return the raw httpx.Response (no JSON parsing). + + Useful for endpoints that return non-JSON payloads such as CSV file + downloads. Error handling mirrors ``_call``. + """ + try: + auth_headers = await self.auth_headers() + except httpx.HTTPStatusError as e: + self._handle_http_status_error(path, "GET", e) + else: + params = params or {} + headers = { + 'User-Agent': self.user_agent, + **auth_headers + } + try: + response = await self._http_session.request( + "GET", + self._er_url(path), + params=params, + headers=headers, + ) + response.raise_for_status() + except httpx.RequestError as e: + reason = str(e) + self.logger.error( + 'Request to ER failed', + extra=dict( + provider_key=self.provider_key, + service=self.service_root, + path=path, + status_code=None, + reason=reason, + text="", + ), + ) + raise ERClientException(f'Request to ER failed: {reason}') + except httpx.HTTPStatusError as e: + self._handle_http_status_error(path, "GET", e) + else: + return response + async def _post(self, path, payload, params=None): return await self._call(path, payload, "POST", params) diff --git a/tests/async_client/test_get_events_export.py b/tests/async_client/test_get_events_export.py new file mode 100644 index 0000000..8bb225a --- /dev/null +++ b/tests/async_client/test_get_events_export.py @@ -0,0 +1,210 @@ +import json + +import httpx +import pytest +import respx + +from erclient.er_errors import ( + ERClientBadCredentials, + ERClientException, + ERClientNotFound, + ERClientPermissionDenied, +) + +CSV_BODY = ( + "Report_Type,Report_Id,Title\n" + "wildlife_sighting_rep,1001,Elephant Sighting\n" + "wildlife_sighting_rep,1002,Lion Sighting\n" +) + + +# -- Success cases ----------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_events_export_returns_raw_response(er_client): + """The method must return the raw httpx.Response so callers can stream + or save the CSV body.""" + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.OK, + content=CSV_BODY.encode(), + headers={"Content-Type": "text/csv"}, + ) + + response = await er_client.get_events_export() + + assert isinstance(response, httpx.Response) + assert response.status_code == 200 + assert response.text == CSV_BODY + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_with_filter(er_client): + """When a filter is supplied it should be forwarded as a query parameter.""" + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.OK, + content=CSV_BODY.encode(), + headers={"Content-Type": "text/csv"}, + ) + + event_filter = json.dumps( + {"date_range": {"lower": "2024-01-01T00:00:00-06:00"}} + ) + response = await er_client.get_events_export(filter=event_filter) + + assert response.status_code == 200 + # Verify the filter was passed as a param + request = route.calls.last.request + assert "filter" in str(request.url) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_without_filter(er_client): + """Without a filter no 'filter' query param should be present.""" + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.OK, + content=b"", + headers={"Content-Type": "text/csv"}, + ) + + response = await er_client.get_events_export() + + request = route.calls.last.request + assert "filter" not in str(request.url) + assert response.status_code == 200 + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_content_type_is_csv(er_client): + """Confirm the response preserves the Content-Type header.""" + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.OK, + content=CSV_BODY.encode(), + headers={ + "Content-Type": "text/csv", + "Content-Disposition": "attachment; filename=events_export.csv", + }, + ) + + response = await er_client.get_events_export() + + assert "text/csv" in response.headers["content-type"] + assert "events_export.csv" in response.headers["content-disposition"] + await er_client.close() + + +# -- Error cases -------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_events_export_not_found(er_client): + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, + json={"status": {"code": 404, "detail": "Not found"}}, + ) + + with pytest.raises(ERClientNotFound): + await er_client.get_events_export() + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_forbidden(er_client): + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, + json={ + "status": { + "code": 403, + "detail": "You do not have permission to perform this action.", + } + }, + ) + + with pytest.raises(ERClientPermissionDenied): + await er_client.get_events_export() + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_bad_credentials(er_client): + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.UNAUTHORIZED, + json={ + "status": { + "code": 401, + "detail": "Authentication credentials were not provided.", + } + }, + ) + + with pytest.raises(ERClientBadCredentials): + await er_client.get_events_export() + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_server_error(er_client): + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.INTERNAL_SERVER_ERROR, + json={"detail": "Internal server error"}, + ) + + with pytest.raises(ERClientException): + await er_client.get_events_export() + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_export_url_construction(er_client): + """The request should target {service_root}/activity/events/export/.""" + async with respx.mock( + base_url=er_client.service_root, assert_all_called=False + ) as respx_mock: + route = respx_mock.get("activity/events/export/") + route.return_value = httpx.Response( + httpx.codes.OK, + content=CSV_BODY.encode(), + headers={"Content-Type": "text/csv"}, + ) + + await er_client.get_events_export() + + request = route.calls.last.request + expected_base = f"{er_client.service_root}/activity/events/export/" + assert str(request.url).startswith(expected_base) + await er_client.close() diff --git a/tests/sync_client/__init__.py b/tests/sync_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sync_client/conftest.py b/tests/sync_client/conftest.py new file mode 100644 index 0000000..6d9d6da --- /dev/null +++ b/tests/sync_client/conftest.py @@ -0,0 +1,21 @@ +import pytest + +from erclient.client import ERClient + + +@pytest.fixture +def er_server_info(): + return { + "service_root": "https://fake-site.erdomain.org/api/v1.0", + "username": "test", + "password": "test", + "token": "1110c87681cd1d12ad07c2d0f57d15d6079ae5d8", + "token_url": "https://fake-auth.erdomain.org/oauth2/token", + "client_id": "das_web_client", + "provider_key": "testintegration", + } + + +@pytest.fixture +def er_client(er_server_info): + return ERClient(**er_server_info) diff --git a/tests/sync_client/test_get_events_export.py b/tests/sync_client/test_get_events_export.py new file mode 100644 index 0000000..2763b6d --- /dev/null +++ b/tests/sync_client/test_get_events_export.py @@ -0,0 +1,135 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from erclient.er_errors import ( + ERClientBadCredentials, + ERClientException, + ERClientNotFound, + ERClientPermissionDenied, +) + +CSV_BODY = ( + "Report_Type,Report_Id,Title\n" + "wildlife_sighting_rep,1001,Elephant Sighting\n" + "wildlife_sighting_rep,1002,Lion Sighting\n" +) + + +def _mock_response(status_code=200, text=CSV_BODY, ok=True, headers=None): + response = MagicMock() + response.status_code = status_code + response.ok = ok + response.text = text + response.headers = headers or {"Content-Type": "text/csv"} + response.url = "https://fake-site.erdomain.org/api/v1.0/activity/events/export/" + return response + + +# -- Success cases ----------------------------------------------------------- + +def test_get_events_export_returns_raw_response(er_client): + """The sync method should return the raw requests.Response.""" + mock_resp = _mock_response() + with patch.object(er_client._http_session, "get", return_value=mock_resp): + result = er_client.get_events_export() + + assert result is mock_resp + assert result.status_code == 200 + assert result.text == CSV_BODY + + +def test_get_events_export_with_filter(er_client): + """When a filter is supplied it should be forwarded as a param.""" + mock_resp = _mock_response() + event_filter = json.dumps( + {"date_range": {"lower": "2024-01-01T00:00:00-06:00"}} + ) + with patch.object( + er_client._http_session, "get", return_value=mock_resp + ) as mock_get: + er_client.get_events_export(filter=event_filter) + + _, kwargs = mock_get.call_args + assert kwargs["params"] == {"filter": event_filter} + + +def test_get_events_export_without_filter(er_client): + """Without a filter no 'filter' param should be present.""" + mock_resp = _mock_response() + with patch.object( + er_client._http_session, "get", return_value=mock_resp + ) as mock_get: + er_client.get_events_export() + + _, kwargs = mock_get.call_args + assert kwargs["params"] is None + + +def test_get_events_export_url_construction(er_client): + """The request URL should end with activity/events/export/.""" + mock_resp = _mock_response() + with patch.object( + er_client._http_session, "get", return_value=mock_resp + ) as mock_get: + er_client.get_events_export() + + call_args = mock_get.call_args + url = call_args[0][0] # positional arg + assert url.endswith("activity/events/export/") + assert url.startswith(er_client.service_root) + + +# -- Error cases -------------------------------------------------------------- + +def test_get_events_export_not_found(er_client): + mock_resp = _mock_response(status_code=404, ok=False, text='{"status":{"code":404}}') + with patch.object(er_client._http_session, "get", return_value=mock_resp): + with pytest.raises(ERClientNotFound): + er_client.get_events_export() + + +def test_get_events_export_forbidden(er_client): + mock_resp = _mock_response( + status_code=403, + ok=False, + text='{"status":{"code":403,"detail":"You do not have permission"}}', + ) + with patch.object(er_client._http_session, "get", return_value=mock_resp): + with pytest.raises(ERClientPermissionDenied): + er_client.get_events_export() + + +def test_get_events_export_bad_credentials(er_client): + mock_resp = _mock_response( + status_code=401, + ok=False, + text='{"status":{"code":401,"detail":"Authentication credentials were not provided."}}', + ) + with patch.object(er_client._http_session, "get", return_value=mock_resp): + with pytest.raises(ERClientBadCredentials): + er_client.get_events_export() + + +def test_get_events_export_server_error(er_client): + """Server errors should eventually raise ERClientException after retries.""" + mock_resp = _mock_response( + status_code=500, + ok=False, + text='{"detail":"Internal server error"}', + ) + with patch.object(er_client._http_session, "get", return_value=mock_resp): + with pytest.raises(ERClientException): + er_client.get_events_export() + + +def test_get_events_export_empty_csv(er_client): + """An empty CSV body (headers only) should still return the raw response.""" + empty_csv = "Report_Type,Report_Id,Title\n" + mock_resp = _mock_response(text=empty_csv) + with patch.object(er_client._http_session, "get", return_value=mock_resp): + result = er_client.get_events_export() + + assert result.text == empty_csv + assert result.status_code == 200 From 4d0f937360949e233bf861e44df7dea35afd4a89 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:22:14 -0800 Subject: [PATCH 2/2] feat: add GeoJSON, KML, and vector tile endpoints (ERA-12686) Use _get_raw (from PR #33) for raw GET responses instead of adding _get_response; rebased onto ERA-12670/async-events-export. Co-authored-by: Cursor --- erclient/client.py | 190 ++++++++++++++ tests/async_client/test_geojson_kml_tiles.py | 252 +++++++++++++++++++ tests/sync_client/test_geojson_kml_tiles.py | 239 ++++++++++++++++++ 3 files changed, 681 insertions(+) create mode 100644 tests/async_client/test_geojson_kml_tiles.py create mode 100644 tests/sync_client/test_geojson_kml_tiles.py diff --git a/erclient/client.py b/erclient/client.py index 89849ed..b44a5e4 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -987,6 +987,102 @@ def get_sources(self, page_size=100): def get_users(self): return self._get('users') + # -- GeoJSON endpoints -- + + def get_events_geojson(self, **kwargs): + """ + Get events as a GeoJSON FeatureCollection. + + Accepts the same filter kwargs as get_events (state, event_type, filter, etc.). + :return: GeoJSON FeatureCollection dict + """ + params = dict((k, v) for k, v in kwargs.items() if k in + ('state', 'page_size', 'page', 'event_type', 'filter', + 'include_notes', 'include_related_events', 'include_files', + 'include_details', 'updated_since', 'include_updates', + 'oldest_update_date', 'event_ids')) + return self._get('activity/events/geojson', params=params) + + def get_subjects_geojson(self, **kwargs): + """ + Get subjects as a GeoJSON FeatureCollection. + + :param subject_group: filter by subject group + :param include_inactive: include inactive subjects + :return: GeoJSON FeatureCollection dict + """ + params = dict((k, v) for k, v in kwargs.items() if k in + ('subject_group', 'include_inactive')) + return self._get('subjects/geojson', params=params) + + # -- KML endpoints -- + + def get_subjects_kml(self, start=None, end=None, include_inactive=None): + """ + Download the subjects KML/KMZ document. + + :param start: start date string (YYYY-MM-DD) + :param end: end date string (YYYY-MM-DD) + :param include_inactive: include inactive subjects + :return: Response object with binary KMZ content + """ + p = {} + if start is not None: + p['start'] = start + if end is not None: + p['end'] = end + if include_inactive is not None: + p['include_inactive'] = include_inactive + return self._get('subjects/kml', params=p, return_response=True) + + def get_subject_kml(self, subject_id, start=None, end=None): + """ + Download the KML/KMZ document for a single subject. + + :param subject_id: UUID of the subject + :param start: start date string (YYYY-MM-DD) + :param end: end date string (YYYY-MM-DD) + :return: Response object with binary KMZ content + """ + p = {} + if start is not None: + p['start'] = start + if end is not None: + p['end'] = end + return self._get(f'subject/{subject_id}/kml', params=p, return_response=True) + + # -- Vector tile endpoints -- + + def get_observation_segment_tiles(self, z, x, y, **kwargs): + """ + Get observation segment vector tiles (MVT/PBF). + + :param z: zoom level + :param x: tile x coordinate + :param y: tile y coordinate + :return: Response object with binary protobuf content + """ + return self._get( + f'observations/segments/tiles/{z}/{x}/{y}.pbf', + params=kwargs or None, + return_response=True, + ) + + def get_spatial_feature_tiles(self, z, x, y, **kwargs): + """ + Get spatial feature vector tiles (MVT/PBF). + + :param z: zoom level + :param x: tile x coordinate + :param y: tile y coordinate + :return: Response object with binary protobuf content + """ + return self._get( + f'spatialfeatures/tiles/{z}/{x}/{y}.pbf', + params=kwargs or None, + return_response=True, + ) + class AsyncERClient(object): """ @@ -1407,6 +1503,100 @@ async def get_feature_group(self, feature_group_id: str): """ return await self._get(f"spatialfeaturegroup/{feature_group_id}", params={}) + # -- GeoJSON endpoints -- + + async def get_events_geojson(self, **kwargs): + """ + Get events as a GeoJSON FeatureCollection. + + Accepts the same filter kwargs as get_events (state, event_type, filter, etc.). + :return: GeoJSON FeatureCollection dict + """ + params = {k: v for k, v in kwargs.items() if k in + ('state', 'page_size', 'page', 'event_type', 'filter', + 'include_notes', 'include_related_events', 'include_files', + 'include_details', 'updated_since', 'include_updates', + 'oldest_update_date', 'event_ids')} + return await self._get('activity/events/geojson', params=params) + + async def get_subjects_geojson(self, **kwargs): + """ + Get subjects as a GeoJSON FeatureCollection. + + :param subject_group: filter by subject group + :param include_inactive: include inactive subjects + :return: GeoJSON FeatureCollection dict + """ + params = {k: v for k, v in kwargs.items() if k in + ('subject_group', 'include_inactive')} + return await self._get('subjects/geojson', params=params) + + # -- KML endpoints -- + + async def get_subjects_kml(self, start=None, end=None, include_inactive=None): + """ + Download the subjects KML/KMZ document. + + :param start: start date string (YYYY-MM-DD) + :param end: end date string (YYYY-MM-DD) + :param include_inactive: include inactive subjects + :return: httpx.Response with binary KMZ content + """ + p = {} + if start is not None: + p['start'] = start + if end is not None: + p['end'] = end + if include_inactive is not None: + p['include_inactive'] = include_inactive + return await self._get_raw('subjects/kml', params=p) + + async def get_subject_kml(self, subject_id, start=None, end=None): + """ + Download the KML/KMZ document for a single subject. + + :param subject_id: UUID of the subject + :param start: start date string (YYYY-MM-DD) + :param end: end date string (YYYY-MM-DD) + :return: httpx.Response with binary KMZ content + """ + p = {} + if start is not None: + p['start'] = start + if end is not None: + p['end'] = end + return await self._get_raw(f'subject/{subject_id}/kml', params=p) + + # -- Vector tile endpoints -- + + async def get_observation_segment_tiles(self, z, x, y, **kwargs): + """ + Get observation segment vector tiles (MVT/PBF). + + :param z: zoom level + :param x: tile x coordinate + :param y: tile y coordinate + :return: httpx.Response with binary protobuf content + """ + return await self._get_raw( + f'observations/segments/tiles/{z}/{x}/{y}.pbf', + params=kwargs or None, + ) + + async def get_spatial_feature_tiles(self, z, x, y, **kwargs): + """ + Get spatial feature vector tiles (MVT/PBF). + + :param z: zoom level + :param x: tile x coordinate + :param y: tile y coordinate + :return: httpx.Response with binary protobuf content + """ + return await self._get_raw( + f'spatialfeatures/tiles/{z}/{x}/{y}.pbf', + params=kwargs or None, + ) + async def _get_data(self, endpoint, params, batch_size=0): if "page" not in params: # Use cursor paginator unless the user has specified a page params["use_cursor"] = "true" diff --git a/tests/async_client/test_geojson_kml_tiles.py b/tests/async_client/test_geojson_kml_tiles.py new file mode 100644 index 0000000..5d2a56a --- /dev/null +++ b/tests/async_client/test_geojson_kml_tiles.py @@ -0,0 +1,252 @@ +"""Tests for GeoJSON, KML, and vector tile endpoints in the async AsyncERClient.""" +import httpx +import pytest +import respx + + +SERVICE_ROOT = "https://fake-site.erdomain.org/api/v1.0" +SUBJECT_ID = "aaaa1111-2222-3333-4444-bbbbccccdddd" + + +# --- Fixtures --- + +@pytest.fixture +def events_geojson_response(): + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-122.359, 47.686]}, + "properties": {"event_type": "rainfall_rep", "title": "Rainfall"}, + } + ], + } + + +@pytest.fixture +def subjects_geojson_response(): + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [36.79, -1.29]}, + "properties": {"name": "Lion A", "subject_subtype": "lion"}, + } + ], + } + + +# --- GeoJSON tests --- + +@pytest.mark.asyncio +async def test_get_events_geojson(er_client, events_geojson_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/activity/events/geojson" + ).respond(httpx.codes.OK, json=events_geojson_response) + + result = await er_client.get_events_geojson() + assert route.called + assert result == events_geojson_response + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_events_geojson_with_params(er_client, events_geojson_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/activity/events/geojson" + ).respond(httpx.codes.OK, json=events_geojson_response) + + result = await er_client.get_events_geojson( + state="active", event_type="rainfall_rep", page_size=10 + ) + assert route.called + req = route.calls[0].request + assert "state=active" in str(req.url) + assert "event_type=rainfall_rep" in str(req.url) + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_subjects_geojson(er_client, subjects_geojson_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/subjects/geojson" + ).respond(httpx.codes.OK, json=subjects_geojson_response) + + result = await er_client.get_subjects_geojson() + assert route.called + assert result == subjects_geojson_response + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_subjects_geojson_with_params(er_client, subjects_geojson_response): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/subjects/geojson" + ).respond(httpx.codes.OK, json=subjects_geojson_response) + + result = await er_client.get_subjects_geojson( + include_inactive=True, subject_group="abc" + ) + assert route.called + req = route.calls[0].request + assert "include_inactive=True" in str(req.url) or "include_inactive=true" in str(req.url) + await er_client.close() + + +# --- KML tests --- + +@pytest.mark.asyncio +async def test_get_subjects_kml(er_client): + kmz_bytes = b"PK\x03\x04mock-kmz-data" + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/subjects/kml" + ).respond(httpx.codes.OK, content=kmz_bytes) + + result = await er_client.get_subjects_kml() + assert route.called + assert isinstance(result, httpx.Response) + assert result.content == kmz_bytes + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_subjects_kml_with_dates(er_client): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/subjects/kml" + ).respond(httpx.codes.OK, content=b"KMZ") + + await er_client.get_subjects_kml(start="2024-01-01", end="2024-06-01") + assert route.called + req = route.calls[0].request + assert "start=2024-01-01" in str(req.url) + assert "end=2024-06-01" in str(req.url) + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_subject_kml(er_client): + kmz_bytes = b"PK\x03\x04mock-kmz-subject" + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/subject/{SUBJECT_ID}/kml" + ).respond(httpx.codes.OK, content=kmz_bytes) + + result = await er_client.get_subject_kml(subject_id=SUBJECT_ID) + assert route.called + assert isinstance(result, httpx.Response) + assert result.content == kmz_bytes + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_subject_kml_with_dates(er_client): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/subject/{SUBJECT_ID}/kml" + ).respond(httpx.codes.OK, content=b"KMZ") + + await er_client.get_subject_kml( + subject_id=SUBJECT_ID, start="2024-03-01", end="2024-09-01" + ) + assert route.called + req = route.calls[0].request + assert "start=2024-03-01" in str(req.url) + assert "end=2024-09-01" in str(req.url) + await er_client.close() + + +# --- Vector tile tests --- + +@pytest.mark.asyncio +async def test_get_observation_segment_tiles(er_client): + pbf_bytes = b"\x1a\x02\x00\x00mock-pbf" + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/observations/segments/tiles/10/512/512.pbf" + ).respond(httpx.codes.OK, content=pbf_bytes) + + result = await er_client.get_observation_segment_tiles(z=10, x=512, y=512) + assert route.called + assert isinstance(result, httpx.Response) + assert result.content == pbf_bytes + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_spatial_feature_tiles(er_client): + pbf_bytes = b"\x1a\x02\x00\x00mock-spatial-pbf" + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/spatialfeatures/tiles/8/128/256.pbf" + ).respond(httpx.codes.OK, content=pbf_bytes) + + result = await er_client.get_spatial_feature_tiles(z=8, x=128, y=256) + assert route.called + assert isinstance(result, httpx.Response) + assert result.content == pbf_bytes + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_observation_segment_tiles_with_params(er_client): + async with respx.mock(assert_all_called=False) as respx_mock: + route = respx_mock.get( + f"{SERVICE_ROOT}/observations/segments/tiles/10/512/512.pbf" + ).respond(httpx.codes.OK, content=b"PBF") + + await er_client.get_observation_segment_tiles( + z=10, x=512, y=512, show_excluded="true" + ) + assert route.called + req = route.calls[0].request + assert "show_excluded=true" in str(req.url) + await er_client.close() + + +# --- Error handling tests --- + +@pytest.mark.asyncio +async def test_get_events_geojson_not_found(er_client): + from erclient.er_errors import ERClientNotFound + async with respx.mock(assert_all_called=False) as respx_mock: + respx_mock.get( + f"{SERVICE_ROOT}/activity/events/geojson" + ).respond(httpx.codes.NOT_FOUND, json={"status": {"code": 404}}) + + with pytest.raises(ERClientNotFound): + await er_client.get_events_geojson() + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_subjects_kml_not_found(er_client): + from erclient.er_errors import ERClientNotFound + async with respx.mock(assert_all_called=False) as respx_mock: + respx_mock.get( + f"{SERVICE_ROOT}/subjects/kml" + ).respond(httpx.codes.NOT_FOUND, json={"status": {"code": 404}}) + + with pytest.raises(ERClientNotFound): + await er_client.get_subjects_kml() + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_tiles_not_found(er_client): + from erclient.er_errors import ERClientNotFound + async with respx.mock(assert_all_called=False) as respx_mock: + respx_mock.get( + f"{SERVICE_ROOT}/observations/segments/tiles/10/512/512.pbf" + ).respond(httpx.codes.NOT_FOUND, json={"status": {"code": 404}}) + + with pytest.raises(ERClientNotFound): + await er_client.get_observation_segment_tiles(z=10, x=512, y=512) + await er_client.close() diff --git a/tests/sync_client/test_geojson_kml_tiles.py b/tests/sync_client/test_geojson_kml_tiles.py new file mode 100644 index 0000000..ca8ff24 --- /dev/null +++ b/tests/sync_client/test_geojson_kml_tiles.py @@ -0,0 +1,239 @@ +"""Tests for GeoJSON, KML, and vector tile endpoints in the sync ERClient.""" +import json +from unittest.mock import patch, MagicMock + +import pytest + + +SERVICE_ROOT = "https://fake-site.erdomain.org/api/v1.0" +SUBJECT_ID = "aaaa1111-2222-3333-4444-bbbbccccdddd" + + +def _mock_response(json_data=None, status_code=200, content=b"", ok=True): + """Helper to build a mock requests.Response.""" + resp = MagicMock() + resp.ok = ok + resp.status_code = status_code + resp.text = json.dumps(json_data) if json_data is not None else "" + resp.content = content + resp.json.return_value = json_data + return resp + + +# --- Fixtures --- + +@pytest.fixture +def events_geojson_response(): + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-122.359, 47.686]}, + "properties": {"event_type": "rainfall_rep", "title": "Rainfall"}, + } + ], + } + + +@pytest.fixture +def subjects_geojson_response(): + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [36.79, -1.29]}, + "properties": {"name": "Lion A", "subject_subtype": "lion"}, + } + ], + } + + +# --- GeoJSON tests --- + +def test_get_events_geojson(er_client, events_geojson_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(events_geojson_response) + result = er_client.get_events_geojson() + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert "activity/events/geojson" in call_url + assert result == events_geojson_response + + +def test_get_events_geojson_with_params(er_client, events_geojson_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(events_geojson_response) + result = er_client.get_events_geojson( + state="active", event_type="rainfall_rep", page_size=10 + ) + + call_kwargs = mock_get.call_args + params = call_kwargs[1].get("params") or call_kwargs.kwargs.get("params") + assert params["state"] == "active" + assert params["event_type"] == "rainfall_rep" + assert params["page_size"] == 10 + + +def test_get_subjects_geojson(er_client, subjects_geojson_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(subjects_geojson_response) + result = er_client.get_subjects_geojson() + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert "subjects/geojson" in call_url + assert result == subjects_geojson_response + + +def test_get_subjects_geojson_with_params(er_client, subjects_geojson_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(subjects_geojson_response) + result = er_client.get_subjects_geojson( + include_inactive=True, subject_group="abc" + ) + + call_kwargs = mock_get.call_args + params = call_kwargs[1].get("params") or call_kwargs.kwargs.get("params") + assert params["include_inactive"] is True + assert params["subject_group"] == "abc" + + +# --- KML tests --- + +def test_get_subjects_kml(er_client): + binary_content = b"PK\x03\x04..." # mock KMZ bytes + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=binary_content, ok=True) + # return_response=True means _get returns the raw response + result = er_client.get_subjects_kml() + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert "subjects/kml" in call_url + # Returns the response object directly + assert result.content == binary_content + + +def test_get_subjects_kml_with_dates(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"KMZ", ok=True) + er_client.get_subjects_kml(start="2024-01-01", end="2024-06-01") + + call_kwargs = mock_get.call_args + params = call_kwargs[1].get("params") or call_kwargs.kwargs.get("params") + assert params["start"] == "2024-01-01" + assert params["end"] == "2024-06-01" + + +def test_get_subject_kml(er_client): + binary_content = b"PK\x03\x04..." + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=binary_content, ok=True) + result = er_client.get_subject_kml(subject_id=SUBJECT_ID) + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert f"subject/{SUBJECT_ID}/kml" in call_url + assert result.content == binary_content + + +def test_get_subject_kml_with_dates(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"KMZ", ok=True) + er_client.get_subject_kml( + subject_id=SUBJECT_ID, start="2024-03-01", end="2024-09-01" + ) + + call_kwargs = mock_get.call_args + params = call_kwargs[1].get("params") or call_kwargs.kwargs.get("params") + assert params["start"] == "2024-03-01" + assert params["end"] == "2024-09-01" + + +# --- Vector tile tests --- + +def test_get_observation_segment_tiles(er_client): + pbf_content = b"\x1a\x02\x00\x00" # mock PBF bytes + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=pbf_content, ok=True) + result = er_client.get_observation_segment_tiles(z=10, x=512, y=512) + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert "observations/segments/tiles/10/512/512.pbf" in call_url + assert result.content == pbf_content + + +def test_get_spatial_feature_tiles(er_client): + pbf_content = b"\x1a\x02\x00\x00" + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=pbf_content, ok=True) + result = er_client.get_spatial_feature_tiles(z=8, x=128, y=256) + + assert mock_get.called + call_url = mock_get.call_args[0][0] + assert "spatialfeatures/tiles/8/128/256.pbf" in call_url + assert result.content == pbf_content + + +def test_get_observation_segment_tiles_with_params(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"PBF", ok=True) + er_client.get_observation_segment_tiles( + z=10, x=512, y=512, show_excluded="true" + ) + + call_kwargs = mock_get.call_args + params = call_kwargs[1].get("params") or call_kwargs.kwargs.get("params") + assert params["show_excluded"] == "true" + + +# --- URL construction tests --- + +def test_geojson_url_construction(er_client): + """Verify the full URL is correctly assembled.""" + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response({"type": "FeatureCollection", "features": []}) + er_client.get_events_geojson() + + call_url = mock_get.call_args[0][0] + assert call_url == f"{SERVICE_ROOT}/activity/events/geojson" + + +def test_kml_url_construction(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"", ok=True) + er_client.get_subjects_kml() + + call_url = mock_get.call_args[0][0] + assert call_url == f"{SERVICE_ROOT}/subjects/kml" + + +def test_subject_kml_url_construction(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"", ok=True) + er_client.get_subject_kml(subject_id=SUBJECT_ID) + + call_url = mock_get.call_args[0][0] + assert call_url == f"{SERVICE_ROOT}/subject/{SUBJECT_ID}/kml" + + +def test_tiles_url_construction(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"", ok=True) + er_client.get_observation_segment_tiles(z=10, x=512, y=512) + + call_url = mock_get.call_args[0][0] + assert call_url == f"{SERVICE_ROOT}/observations/segments/tiles/10/512/512.pbf" + + +def test_spatial_tiles_url_construction(er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(content=b"", ok=True) + er_client.get_spatial_feature_tiles(z=8, x=128, y=256) + + call_url = mock_get.call_args[0][0] + assert call_url == f"{SERVICE_ROOT}/spatialfeatures/tiles/8/128/256.pbf"