diff --git a/erclient/client.py b/erclient/client.py index 49c69d2..e81b15e 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -1054,6 +1054,93 @@ def get_sources(self, page_size=100): def get_users(self): return self._get('users') + # ── Analyzers ───────────────────────────────────────────────── + + def get_analyzers_spatial(self): + """List spatial analyzers.""" + return self._get('analyzers/spatial') + + def get_analyzers_subject(self): + """List subject analyzers.""" + return self._get('analyzers/subject') + + # ── Choices ─────────────────────────────────────────────────── + + def get_choices(self): + """List all choice sets.""" + return self._get('choices') + + def get_choice(self, choice_id): + """Get a single choice set by ID. + + :param choice_id: UUID of the choice set. + """ + return self._get(f'choices/{choice_id}') + + def download_choice_icons(self): + """Download the choice icons zip file. + + Returns the raw :class:`requests.Response` so the caller can + stream or save the binary content. + """ + return self._get('choices/icons/download', return_response=True) + + # ── Buoy / Gear ────────────────────────────────────────────── + + def get_gear_list(self): + """List all buoy/gear items.""" + return self._get('buoy/gear') + + def get_gear(self, gear_id): + """Get a single gear item by ID. + + :param gear_id: UUID of the gear item. + """ + return self._get(f'buoy/gear/{gear_id}') + + def post_gear(self, gear): + """Create a new gear item. + + :param gear: dict with gear payload. + """ + return self._post('buoy/gear', payload=gear) + + def patch_gear(self, gear_id, gear): + """Update an existing gear item. + + :param gear_id: UUID of the gear item. + :param gear: dict with partial gear payload. + """ + return self._patch(f'buoy/gear/{gear_id}', payload=gear) + + def delete_gear(self, gear_id): + """Delete a gear item. + + :param gear_id: UUID of the gear item. + """ + return self._delete(f'buoy/gear/{gear_id}') + + # ── Reports / Tableau ───────────────────────────────────────── + + def get_sitrep(self): + """Download the situation report (.docx). + + Returns the raw :class:`requests.Response` so the caller can + stream or save the binary content. + """ + return self._get('reports/sitrep.docx', return_response=True) + + def get_tableau_views(self): + """List available Tableau views.""" + return self._get('reports/tableau-views') + + def get_tableau_view(self, view_id): + """Get a single Tableau view by ID. + + :param view_id: UUID of the tableau view. + """ + return self._get(f'reports/tableau-views/{view_id}') + class AsyncERClient(object): """ @@ -1613,6 +1700,93 @@ async def get_feature_group(self, feature_group_id: str): """ return await self._get(f"spatialfeaturegroup/{feature_group_id}", params={}) + # ── Analyzers ───────────────────────────────────────────────── + + async def get_analyzers_spatial(self): + """List spatial analyzers.""" + return await self._get('analyzers/spatial') + + async def get_analyzers_subject(self): + """List subject analyzers.""" + return await self._get('analyzers/subject') + + # ── Choices ─────────────────────────────────────────────────── + + async def get_choices(self): + """List all choice sets.""" + return await self._get('choices') + + async def get_choice(self, choice_id): + """Get a single choice set by ID. + + :param choice_id: UUID of the choice set. + """ + return await self._get(f'choices/{choice_id}') + + async def download_choice_icons(self): + """Download the choice icons zip file. + + Returns the raw :class:`httpx.Response` so the caller can + stream or save the binary content (e.g. response.content). + """ + return await self._get_raw("choices/icons/download") + + # ── Buoy / Gear ────────────────────────────────────────────── + + async def get_gear_list(self): + """List all buoy/gear items.""" + return await self._get('buoy/gear') + + async def get_gear(self, gear_id): + """Get a single gear item by ID. + + :param gear_id: UUID of the gear item. + """ + return await self._get(f'buoy/gear/{gear_id}') + + async def post_gear(self, gear): + """Create a new gear item. + + :param gear: dict with gear payload. + """ + return await self._post('buoy/gear', payload=gear) + + async def patch_gear(self, gear_id, gear): + """Update an existing gear item. + + :param gear_id: UUID of the gear item. + :param gear: dict with partial gear payload. + """ + return await self._patch(f'buoy/gear/{gear_id}', payload=gear) + + async def delete_gear(self, gear_id): + """Delete a gear item. + + :param gear_id: UUID of the gear item. + """ + return await self._delete(f'buoy/gear/{gear_id}') + + # ── Reports / Tableau ───────────────────────────────────────── + + async def get_sitrep(self): + """Download the situation report (.docx). + + Returns the raw :class:`httpx.Response` so the caller can + stream or save the binary content (e.g. response.content). + """ + return await self._get_raw("reports/sitrep.docx") + + async def get_tableau_views(self): + """List available Tableau views.""" + return await self._get('reports/tableau-views') + + async def get_tableau_view(self, view_id): + """Get a single Tableau view by ID. + + :param view_id: UUID of the tableau view. + """ + return await self._get(f'reports/tableau-views/{view_id}') + 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" @@ -1723,6 +1897,40 @@ async def _delete(self, path, params=None, base_url=None): path=path, payload=None, method="DELETE", params=params, base_url=base_url ) + async def _get_raw(self, path, params=None): + """Perform a GET request and return the raw httpx.Response (for binary downloads).""" + try: + auth_headers = await self.auth_headers() + except httpx.HTTPStatusError as e: + self._handle_http_status_error(path, "GET", e) + 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() + return response + 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) + async def _call(self, path, payload, method, params=None, base_url=None): try: auth_headers = await self.auth_headers() diff --git a/tests/async_client/test_analyzers_choices_gear_reports.py b/tests/async_client/test_analyzers_choices_gear_reports.py new file mode 100644 index 0000000..d4863ea --- /dev/null +++ b/tests/async_client/test_analyzers_choices_gear_reports.py @@ -0,0 +1,334 @@ +"""Tests for analyzers, choices, buoy/gear, and reports endpoints (async client).""" + +import httpx +import pytest +import respx + + +GEAR_ID = "aabb1122-3344-5566-7788-99aabbccddee" +CHOICE_ID = "ccdd1122-3344-5566-7788-99aabbccddee" +VIEW_ID = "eeff1122-3344-5566-7788-99aabbccddee" + + +# ── Fixtures ────────────────────────────────────────────────────── + +@pytest.fixture +def analyzers_spatial_response(): + return { + "data": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "name": "Geofence Analyzer", + "type": "geofence", + "is_active": True, + } + ] + } + + +@pytest.fixture +def analyzers_subject_response(): + return { + "data": [ + { + "id": "22222222-2222-2222-2222-222222222222", + "name": "Immobility Analyzer", + "type": "immobility", + "is_active": True, + } + ] + } + + +@pytest.fixture +def choices_list_response(): + return { + "data": [ + {"id": CHOICE_ID, "field_name": "species", "model": "activity.event"}, + ] + } + + +@pytest.fixture +def choice_detail_response(): + return { + "data": { + "id": CHOICE_ID, + "field_name": "species", + "model": "activity.event", + "choices": [ + {"value": "elephant", "display": "Elephant"}, + {"value": "lion", "display": "Lion"}, + ], + } + } + + +@pytest.fixture +def gear_list_response(): + return { + "data": [ + {"id": GEAR_ID, "name": "Test Buoy", "gear_type": "buoy"}, + ] + } + + +@pytest.fixture +def gear_detail_response(): + return { + "data": { + "id": GEAR_ID, + "name": "Test Buoy", + "gear_type": "buoy", + "status": "active", + } + } + + +@pytest.fixture +def gear_created_response(): + return { + "data": { + "id": GEAR_ID, + "name": "New Buoy", + "gear_type": "buoy", + }, + "status": {"code": 201, "message": "Created"}, + } + + +@pytest.fixture +def gear_updated_response(): + return { + "data": { + "id": GEAR_ID, + "name": "Updated Buoy", + "gear_type": "buoy", + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def tableau_views_response(): + return { + "data": [ + {"id": VIEW_ID, "title": "Animal Sightings Dashboard"}, + ] + } + + +@pytest.fixture +def tableau_view_detail_response(): + return { + "data": { + "id": VIEW_ID, + "title": "Animal Sightings Dashboard", + "url": "https://tableau.example.com/view/1", + } + } + + +# ── Analyzer tests ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_analyzers_spatial(er_client, analyzers_spatial_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("analyzers/spatial").respond(httpx.codes.OK, json=analyzers_spatial_response) + + result = await er_client.get_analyzers_spatial() + + assert route.called + assert result == analyzers_spatial_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_analyzers_subject(er_client, analyzers_subject_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("analyzers/subject").respond(httpx.codes.OK, json=analyzers_subject_response) + + result = await er_client.get_analyzers_subject() + + assert route.called + assert result == analyzers_subject_response["data"] + await er_client.close() + + +# ── Choice tests ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_choices(er_client, choices_list_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("choices").respond(httpx.codes.OK, json=choices_list_response) + + result = await er_client.get_choices() + + assert route.called + assert result == choices_list_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_choice(er_client, choice_detail_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get(f"choices/{CHOICE_ID}").respond(httpx.codes.OK, json=choice_detail_response) + + result = await er_client.get_choice(CHOICE_ID) + + assert route.called + assert result == choice_detail_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_download_choice_icons(er_client): + binary_content = b"zip-binary-content" + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("choices/icons/download").respond( + httpx.codes.OK, content=binary_content + ) + + result = await er_client.download_choice_icons() + + assert route.called + assert result.content == binary_content + await er_client.close() + + +# ── Gear CRUD tests ─────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_gear_list(er_client, gear_list_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("buoy/gear").respond(httpx.codes.OK, json=gear_list_response) + + result = await er_client.get_gear_list() + + assert route.called + assert result == gear_list_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_gear(er_client, gear_detail_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json=gear_detail_response) + + result = await er_client.get_gear(GEAR_ID) + + assert route.called + assert result == gear_detail_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_gear(er_client, gear_created_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.post("buoy/gear").respond(httpx.codes.CREATED, json=gear_created_response) + + result = await er_client.post_gear({"name": "New Buoy", "gear_type": "buoy"}) + + assert route.called + assert result == gear_created_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_patch_gear(er_client, gear_updated_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.patch(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json=gear_updated_response) + + result = await er_client.patch_gear(GEAR_ID, {"name": "Updated Buoy"}) + + assert route.called + assert result == gear_updated_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_gear(er_client): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.delete(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.NO_CONTENT) + + # DELETE returns None from _call (no json body on 204) + # The async client raises on non-200 via raise_for_status, but 204 is ok. + # However our _call tries response.json() which will fail on 204. + # Let's mock a 200 with empty body instead: + route = m.delete(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.OK, json={}) + + result = await er_client.delete_gear(GEAR_ID) + + assert route.called + await er_client.close() + + +# ── Reports / Tableau tests ────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_sitrep(er_client): + binary_content = b"docx-binary-content" + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("reports/sitrep.docx").respond( + httpx.codes.OK, content=binary_content + ) + + result = await er_client.get_sitrep() + + assert route.called + assert result.content == binary_content + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_tableau_views(er_client, tableau_views_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get("reports/tableau-views").respond(httpx.codes.OK, json=tableau_views_response) + + result = await er_client.get_tableau_views() + + assert route.called + assert result == tableau_views_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_tableau_view(er_client, tableau_view_detail_response): + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + route = m.get(f"reports/tableau-views/{VIEW_ID}").respond( + httpx.codes.OK, json=tableau_view_detail_response + ) + + result = await er_client.get_tableau_view(VIEW_ID) + + assert route.called + assert result == tableau_view_detail_response["data"] + await er_client.close() + + +# ── Error handling ──────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_gear_not_found(er_client, not_found_response): + from erclient import ERClientNotFound + + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + m.get(f"buoy/gear/{GEAR_ID}").respond(httpx.codes.NOT_FOUND, json=not_found_response) + + with pytest.raises(ERClientNotFound): + await er_client.get_gear(GEAR_ID) + + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_analyzers_forbidden(er_client, forbidden_response): + from erclient import ERClientPermissionDenied + + async with respx.mock(base_url=er_client._api_root("v1.0"), assert_all_called=False) as m: + m.get("analyzers/spatial").respond(httpx.codes.FORBIDDEN, json=forbidden_response) + + with pytest.raises(ERClientPermissionDenied): + await er_client.get_analyzers_spatial() + + await er_client.close() diff --git a/tests/sync_client/test_analyzers_choices_gear_reports.py b/tests/sync_client/test_analyzers_choices_gear_reports.py new file mode 100644 index 0000000..437085f --- /dev/null +++ b/tests/sync_client/test_analyzers_choices_gear_reports.py @@ -0,0 +1,171 @@ +"""Tests for analyzers, choices, buoy/gear, and reports endpoints (sync client).""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +GEAR_ID = "aabb1122-3344-5566-7788-99aabbccddee" +CHOICE_ID = "ccdd1122-3344-5566-7788-99aabbccddee" +VIEW_ID = "eeff1122-3344-5566-7788-99aabbccddee" + + +def _mock_response(json_body, status_code=200): + mock_resp = MagicMock() + mock_resp.ok = status_code < 400 + mock_resp.status_code = status_code + mock_resp.text = json.dumps(json_body) + mock_resp.json.return_value = json_body + return mock_resp + + +# ── Analyzers ───────────────────────────────────────────────────── + +def test_get_analyzers_spatial(er_client): + response = {"data": [{"id": "1", "name": "Geofence"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_analyzers_spatial() + + assert mock_get.called + assert "analyzers/spatial" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_analyzers_subject(er_client): + response = {"data": [{"id": "2", "name": "Immobility"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_analyzers_subject() + + assert mock_get.called + assert "analyzers/subject" in mock_get.call_args[0][0] + assert result == response["data"] + + +# ── Choices ─────────────────────────────────────────────────────── + +def test_get_choices(er_client): + response = {"data": [{"id": CHOICE_ID, "field_name": "species"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_choices() + + assert "choices" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_choice(er_client): + response = {"data": {"id": CHOICE_ID, "field_name": "species"}} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_choice(CHOICE_ID) + + assert f"choices/{CHOICE_ID}" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_download_choice_icons(er_client): + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 200 + mock_resp.text = '{"data": "zip"}' + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = mock_resp + result = er_client.download_choice_icons() + + assert "choices/icons/download" in mock_get.call_args[0][0] + # return_response=True means we get the raw response back + assert result == mock_resp + + +# ── Gear CRUD ───────────────────────────────────────────────────── + +def test_get_gear_list(er_client): + response = {"data": [{"id": GEAR_ID, "name": "Buoy"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_gear_list() + + assert "buoy/gear" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_gear(er_client): + response = {"data": {"id": GEAR_ID, "name": "Buoy"}} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_gear(GEAR_ID) + + assert f"buoy/gear/{GEAR_ID}" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_post_gear(er_client): + response = {"data": {"id": GEAR_ID, "name": "New"}} + with patch.object(er_client._http_session, "post") as mock_post: + mock_post.return_value = _mock_response(response, 201) + result = er_client.post_gear({"name": "New", "gear_type": "buoy"}) + + assert mock_post.called + assert "buoy/gear" in mock_post.call_args[0][0] + assert result == response["data"] + + +def test_patch_gear(er_client): + response = {"data": {"id": GEAR_ID, "name": "Updated"}} + with patch.object(er_client._http_session, "patch") as mock_patch: + mock_patch.return_value = _mock_response(response) + result = er_client.patch_gear(GEAR_ID, {"name": "Updated"}) + + assert f"buoy/gear/{GEAR_ID}" in mock_patch.call_args[0][0] + assert result == response["data"] + + +def test_delete_gear(er_client): + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 204 + with patch.object(er_client._http_session, "delete") as mock_delete: + mock_delete.return_value = mock_resp + result = er_client.delete_gear(GEAR_ID) + + assert f"buoy/gear/{GEAR_ID}" in mock_delete.call_args[0][0] + assert result is True + + +# ── Reports / Tableau ───────────────────────────────────────────── + +def test_get_sitrep(er_client): + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.status_code = 200 + mock_resp.text = b"binary-docx-content" + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = mock_resp + result = er_client.get_sitrep() + + assert "reports/sitrep.docx" in mock_get.call_args[0][0] + # return_response=True => raw response + assert result == mock_resp + + +def test_get_tableau_views(er_client): + response = {"data": [{"id": VIEW_ID, "title": "Dashboard"}]} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_tableau_views() + + assert "reports/tableau-views" in mock_get.call_args[0][0] + assert result == response["data"] + + +def test_get_tableau_view(er_client): + response = {"data": {"id": VIEW_ID, "title": "Dashboard"}} + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(response) + result = er_client.get_tableau_view(VIEW_ID) + + assert f"reports/tableau-views/{VIEW_ID}" in mock_get.call_args[0][0] + assert result == response["data"]