From 629bde6402ded38d14f3f5fd728e6b0d7a60115b Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:13:24 -0800 Subject: [PATCH 1/2] Add event geometry, state, and segments methods to both clients Implements event sub-resource endpoints for both ERClient and AsyncERClient: - get_event_geometry(event_id): GET activity/event/{id}/geometry - post_event_state(event_id, state): POST activity/event/{id}/state - get_event_segments(event_id): GET activity/event/{id}/segments Test coverage: - Async tests using respx: success, not found, forbidden, bad request, timeout, empty results - Sync tests using unittest.mock: success, not found, forbidden, empty results ERA-12684 Co-authored-by: Cursor --- erclient/client.py | 62 ++++ tests/async_client/test_event_subresources.py | 278 ++++++++++++++++++ tests/sync_client/__init__.py | 0 tests/sync_client/conftest.py | 34 +++ tests/sync_client/test_event_subresources.py | 156 ++++++++++ 5 files changed, 530 insertions(+) create mode 100644 tests/async_client/test_event_subresources.py create mode 100644 tests/sync_client/__init__.py create mode 100644 tests/sync_client/conftest.py create mode 100644 tests/sync_client/test_event_subresources.py diff --git a/erclient/client.py b/erclient/client.py index 02bbbc0..0b4f4f0 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -427,6 +427,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. @@ -1393,6 +1424,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..3bd3c7b --- /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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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.service_root, 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/__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..402c1e0 --- /dev/null +++ b/tests/sync_client/conftest.py @@ -0,0 +1,34 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from erclient.client import ERClient + + +def _mock_response(status_code, json_data=None, text=None, ok=None): + """Create a mock requests.Response-like object.""" + mock = MagicMock() + mock.status_code = status_code + mock.ok = ok if ok is not None else (200 <= status_code < 300) + if json_data is not None: + mock.json.return_value = json_data + mock.text = json.dumps(json_data) + elif text is not None: + mock.text = text + else: + mock.text = "" + return mock + + +@pytest.fixture +def er_server_info(): + return { + "service_root": "https://fake-site.erdomain.org/api/v1.0", + "token": "1110c87681cd1d12ad07c2d0f57d15d6079ae5d8", + } + + +@pytest.fixture +def er_client(er_server_info): + return ERClient(**er_server_info) 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) From c5f1a724e63ff72dc7f30cb3a8599747448e0477 Mon Sep 17 00:00:00 2001 From: JoshuaVulcan <38018017+JoshuaVulcan@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:50:27 -0800 Subject: [PATCH 2/2] Fix event subresources async test URL base to use _api_root Co-authored-by: Cursor --- tests/async_client/test_event_subresources.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/async_client/test_event_subresources.py b/tests/async_client/test_event_subresources.py index 3bd3c7b..09d24fa 100644 --- a/tests/async_client/test_event_subresources.py +++ b/tests/async_client/test_event_subresources.py @@ -72,7 +72,7 @@ def event_segments_response(): @pytest.mark.asyncio async def test_get_event_geometry_success(er_client, event_geometry_response): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -87,7 +87,7 @@ async def test_get_event_geometry_success(er_client, event_geometry_response): @pytest.mark.asyncio async def test_get_event_geometry_not_found(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -103,7 +103,7 @@ async def test_get_event_geometry_not_found(er_client): @pytest.mark.asyncio async def test_get_event_geometry_forbidden(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -120,7 +120,7 @@ async def test_get_event_geometry_forbidden(er_client): 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.service_root, assert_all_called=False + 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( @@ -138,7 +138,7 @@ async def test_get_event_geometry_no_geometry(er_client): @pytest.mark.asyncio async def test_post_event_state_success(er_client, event_state_response): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -155,7 +155,7 @@ async def test_post_event_state_success(er_client, event_state_response): @pytest.mark.asyncio async def test_post_event_state_not_found(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -171,7 +171,7 @@ async def test_post_event_state_not_found(er_client): @pytest.mark.asyncio async def test_post_event_state_bad_request(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -187,7 +187,7 @@ async def test_post_event_state_bad_request(er_client): @pytest.mark.asyncio async def test_post_event_state_forbidden(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -203,7 +203,7 @@ async def test_post_event_state_forbidden(er_client): @pytest.mark.asyncio async def test_post_event_state_timeout(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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 @@ -218,7 +218,7 @@ async def test_post_event_state_timeout(er_client): @pytest.mark.asyncio async def test_get_event_segments_success(er_client, event_segments_response): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -233,7 +233,7 @@ async def test_get_event_segments_success(er_client, event_segments_response): @pytest.mark.asyncio async def test_get_event_segments_empty(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -249,7 +249,7 @@ async def test_get_event_segments_empty(er_client): @pytest.mark.asyncio async def test_get_event_segments_not_found(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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( @@ -265,7 +265,7 @@ async def test_get_event_segments_not_found(er_client): @pytest.mark.asyncio async def test_get_event_segments_forbidden(er_client): async with respx.mock( - base_url=er_client.service_root, assert_all_called=False + 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(