diff --git a/erclient/client.py b/erclient/client.py index 49c69d2..1419c55 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -449,6 +449,37 @@ def post_event_note(self, event_id, notes): return created + # ---- Event sub-resources ---- + + def get_event_geometry(self, event_id): + """ + Get the geometry for an event. + + :param event_id: The event UUID + :return: Geometry data (GeoJSON) + """ + return self._get(f'activity/event/{event_id}/geometry') + + def post_event_state(self, event_id, state): + """ + Transition an event to a new state. + + :param event_id: The event UUID + :param state: State payload dict (e.g., {"state": "active"}) + :return: Updated event state data + """ + self.logger.debug(f'Posting state for event {event_id}') + return self._post(f'activity/event/{event_id}/state', payload=state) + + def get_event_segments(self, event_id): + """ + Get the patrol segments linked to an event. + + :param event_id: The event UUID + :return: List of patrol segment data + """ + return self._get(f'activity/event/{event_id}/segments') + def get_me(self): """ Get details for the 'me', the current ER user. @@ -1613,6 +1644,37 @@ async def get_feature_group(self, feature_group_id: str): """ return await self._get(f"spatialfeaturegroup/{feature_group_id}", params={}) + # ---- Event sub-resources ---- + + async def get_event_geometry(self, event_id): + """ + Get the geometry for an event. + + :param event_id: The event UUID + :return: Geometry data (GeoJSON) + """ + return await self._get(f'activity/event/{event_id}/geometry') + + async def post_event_state(self, event_id, state): + """ + Transition an event to a new state. + + :param event_id: The event UUID + :param state: State payload dict (e.g., {"state": "active"}) + :return: Updated event state data + """ + self.logger.debug(f'Posting state for event {event_id}') + return await self._post(f'activity/event/{event_id}/state', payload=state) + + async def get_event_segments(self, event_id): + """ + Get the patrol segments linked to an event. + + :param event_id: The event UUID + :return: List of patrol segment data + """ + return await self._get(f'activity/event/{event_id}/segments') + 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_event_subresources.py b/tests/async_client/test_event_subresources.py new file mode 100644 index 0000000..09d24fa --- /dev/null +++ b/tests/async_client/test_event_subresources.py @@ -0,0 +1,278 @@ +import httpx +import pytest +import respx + +from erclient import ( + ERClientException, + ERClientNotFound, + ERClientPermissionDenied, + ERClientBadRequest, +) + +EVENT_ID = "e1e2e3e4-f5f6-7890-abcd-aabbccddeeff" + + +# ---- Fixtures ---- + +@pytest.fixture +def event_geometry_response(): + return { + "data": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [36.79, -1.29], + [36.80, -1.29], + [36.80, -1.30], + [36.79, -1.30], + [36.79, -1.29], + ] + ], + }, + "properties": {}, + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def event_state_response(): + return { + "data": { + "id": EVENT_ID, + "state": "active", + "updated_at": "2025-01-15T10:30:00Z", + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def event_segments_response(): + return { + "data": [ + { + "id": "seg-11111111-2222-3333-4444-555555555555", + "patrol": "pat-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "time_range": { + "start_time": "2025-01-15T08:00:00Z", + "end_time": "2025-01-15T16:00:00Z", + }, + "leader": {"username": "ranger1"}, + }, + ], + "status": {"code": 200, "message": "OK"}, + } + + +# ---- GET event geometry ---- + +@pytest.mark.asyncio +async def test_get_event_geometry_success(er_client, event_geometry_response): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/geometry") + route.return_value = httpx.Response( + httpx.codes.OK, json=event_geometry_response + ) + response = await er_client.get_event_geometry(EVENT_ID) + assert route.called + assert response == event_geometry_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_event_geometry_not_found(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/geometry") + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, + json={"status": {"code": 404, "detail": "Not found"}}, + ) + with pytest.raises(ERClientNotFound): + await er_client.get_event_geometry(EVENT_ID) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_event_geometry_forbidden(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/geometry") + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, + json={"status": {"code": 403, "message": "Forbidden"}}, + ) + with pytest.raises(ERClientPermissionDenied): + await er_client.get_event_geometry(EVENT_ID) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_event_geometry_no_geometry(er_client): + """Event exists but has no geometry attached.""" + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/geometry") + route.return_value = httpx.Response( + httpx.codes.OK, + json={"data": None, "status": {"code": 200, "message": "OK"}}, + ) + response = await er_client.get_event_geometry(EVENT_ID) + assert route.called + # data is None, which is falsy -- should return the full json since data is falsy + await er_client.close() + + +# ---- POST event state ---- + +@pytest.mark.asyncio +async def test_post_event_state_success(er_client, event_state_response): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post(f"activity/event/{EVENT_ID}/state") + route.return_value = httpx.Response( + httpx.codes.OK, json=event_state_response + ) + response = await er_client.post_event_state( + EVENT_ID, {"state": "active"} + ) + assert route.called + assert response == event_state_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_event_state_not_found(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post(f"activity/event/{EVENT_ID}/state") + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, + json={"status": {"code": 404, "detail": "Not found"}}, + ) + with pytest.raises(ERClientNotFound): + await er_client.post_event_state(EVENT_ID, {"state": "active"}) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_event_state_bad_request(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post(f"activity/event/{EVENT_ID}/state") + route.return_value = httpx.Response( + httpx.codes.BAD_REQUEST, + json={"status": {"code": 400, "detail": "Invalid state transition"}}, + ) + with pytest.raises(ERClientBadRequest): + await er_client.post_event_state(EVENT_ID, {"state": "invalid"}) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_event_state_forbidden(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post(f"activity/event/{EVENT_ID}/state") + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, + json={"status": {"code": 403, "message": "Forbidden"}}, + ) + with pytest.raises(ERClientPermissionDenied): + await er_client.post_event_state(EVENT_ID, {"state": "active"}) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_post_event_state_timeout(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.post(f"activity/event/{EVENT_ID}/state") + route.side_effect = httpx.ConnectTimeout + with pytest.raises(ERClientException): + await er_client.post_event_state(EVENT_ID, {"state": "active"}) + assert route.called + await er_client.close() + + +# ---- GET event segments ---- + +@pytest.mark.asyncio +async def test_get_event_segments_success(er_client, event_segments_response): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/segments") + route.return_value = httpx.Response( + httpx.codes.OK, json=event_segments_response + ) + response = await er_client.get_event_segments(EVENT_ID) + assert route.called + assert response == event_segments_response["data"] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_event_segments_empty(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/segments") + route.return_value = httpx.Response( + httpx.codes.OK, + json={"data": [], "status": {"code": 200, "message": "OK"}}, + ) + response = await er_client.get_event_segments(EVENT_ID) + assert route.called + assert response == [] + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_event_segments_not_found(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/segments") + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, + json={"status": {"code": 404, "detail": "Not found"}}, + ) + with pytest.raises(ERClientNotFound): + await er_client.get_event_segments(EVENT_ID) + assert route.called + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_event_segments_forbidden(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"activity/event/{EVENT_ID}/segments") + route.return_value = httpx.Response( + httpx.codes.FORBIDDEN, + json={"status": {"code": 403, "message": "Forbidden"}}, + ) + with pytest.raises(ERClientPermissionDenied): + await er_client.get_event_segments(EVENT_ID) + assert route.called + await er_client.close() diff --git a/tests/sync_client/test_event_subresources.py b/tests/sync_client/test_event_subresources.py new file mode 100644 index 0000000..766073d --- /dev/null +++ b/tests/sync_client/test_event_subresources.py @@ -0,0 +1,156 @@ +import json +from unittest.mock import patch + +import pytest + +from erclient import ( + ERClientException, + ERClientNotFound, + ERClientPermissionDenied, +) +from tests.sync_client.conftest import _mock_response + +EVENT_ID = "e1e2e3e4-f5f6-7890-abcd-aabbccddeeff" + + +# ---- Fixtures ---- + +@pytest.fixture +def event_geometry_response(): + return { + "data": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [36.79, -1.29], + [36.80, -1.29], + [36.80, -1.30], + [36.79, -1.30], + [36.79, -1.29], + ] + ], + }, + "properties": {}, + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def event_state_response(): + return { + "data": { + "id": EVENT_ID, + "state": "active", + "updated_at": "2025-01-15T10:30:00Z", + }, + "status": {"code": 200, "message": "OK"}, + } + + +@pytest.fixture +def event_segments_response(): + return { + "data": [ + { + "id": "seg-11111111-2222-3333-4444-555555555555", + "patrol": "pat-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "time_range": { + "start_time": "2025-01-15T08:00:00Z", + "end_time": "2025-01-15T16:00:00Z", + }, + "leader": {"username": "ranger1"}, + }, + ], + "status": {"code": 200, "message": "OK"}, + } + + +# ---- GET event geometry ---- + +class TestGetEventGeometry: + def test_success(self, er_client, event_geometry_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(200, event_geometry_response) + result = er_client.get_event_geometry(EVENT_ID) + assert mock_get.called + assert result == event_geometry_response["data"] + call_url = mock_get.call_args[0][0] + assert f"activity/event/{EVENT_ID}/geometry" in call_url + + def test_not_found(self, er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(404) + with pytest.raises(ERClientNotFound): + er_client.get_event_geometry(EVENT_ID) + + def test_forbidden(self, er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response( + 403, {"status": {"detail": "Forbidden"}} + ) + with pytest.raises(ERClientPermissionDenied): + er_client.get_event_geometry(EVENT_ID) + + +# ---- POST event state ---- + +class TestPostEventState: + def test_success(self, er_client, event_state_response): + with patch.object(er_client._http_session, "post") as mock_post: + mock_post.return_value = _mock_response(200, event_state_response) + result = er_client.post_event_state(EVENT_ID, {"state": "active"}) + assert mock_post.called + assert result == event_state_response["data"] + + def test_not_found(self, er_client): + with patch.object(er_client._http_session, "post") as mock_post: + mock_post.return_value = _mock_response(404) + with pytest.raises(ERClientNotFound): + er_client.post_event_state(EVENT_ID, {"state": "active"}) + + def test_forbidden(self, er_client): + with patch.object(er_client._http_session, "post") as mock_post: + mock_post.return_value = _mock_response( + 403, {"status": {"detail": "Forbidden"}} + ) + with pytest.raises(ERClientPermissionDenied): + er_client.post_event_state(EVENT_ID, {"state": "active"}) + + +# ---- GET event segments ---- + +class TestGetEventSegments: + def test_success(self, er_client, event_segments_response): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(200, event_segments_response) + result = er_client.get_event_segments(EVENT_ID) + assert mock_get.called + assert result == event_segments_response["data"] + call_url = mock_get.call_args[0][0] + assert f"activity/event/{EVENT_ID}/segments" in call_url + + def test_empty(self, er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response( + 200, {"data": [], "status": {"code": 200, "message": "OK"}} + ) + result = er_client.get_event_segments(EVENT_ID) + assert mock_get.called + assert result == [] + + def test_not_found(self, er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response(404) + with pytest.raises(ERClientNotFound): + er_client.get_event_segments(EVENT_ID) + + def test_forbidden(self, er_client): + with patch.object(er_client._http_session, "get") as mock_get: + mock_get.return_value = _mock_response( + 403, {"status": {"detail": "Forbidden"}} + ) + with pytest.raises(ERClientPermissionDenied): + er_client.get_event_segments(EVENT_ID)