diff --git a/erclient/client.py b/erclient/client.py index 49c69d2..8299e85 100644 --- a/erclient/client.py +++ b/erclient/client.py @@ -348,6 +348,17 @@ def delete_subject(self, subject_id): def delete_message(self, message_id): self._delete('messages/' + message_id + '/') + def post_message(self, message): + """ + Post a new message. + :param message: dict with message payload (message_type, text, message_time, device_location, etc.) + :return: created message data + """ + self.logger.debug('Posting message: %s', message) + result = self._post('messages', payload=message) + self.logger.debug('Result of message post is: %s', result) + return result + def delete_patrol(self, patrol_id): self._delete('activity/patrols/' + patrol_id + '/') @@ -624,23 +635,35 @@ def get_event_type(self, event_type_name, version=DEFAULT_VERSION, include_schem def get_event_categories(self, include_inactive=False): return self._get(f'activity/events/categories', params={"include_inactive": include_inactive}) - def get_messages(self): - - results = self._get(path='messages') + def get_messages(self, **kwargs): + """ + Get messages as a generator, with automatic pagination. + :param kwargs: optional query params (e.g. page_size) + :return: generator yielding individual message dicts + """ + params = dict((k, v) for k, v in kwargs.items()) + results = self._get(path='messages', params=params) while True: if results and results.get('results'): for r in results['results']: yield r - if results and results['next']: - url, params = split_link(results['next']) - # FixMe: p is not defined in this context - p['page'] = params['page'] - results = self._get(path='messages') + if results and results.get('next'): + url, query_params = split_link(results['next']) + params['page'] = query_params['page'] + results = self._get(path='messages', params=params) else: break + def get_message(self, message_id): + """ + Get a single message by ID. + :param message_id: UUID of the message + :return: message data dict + """ + return self._get(path=f'messages/{message_id}') + def get_event_types(self, include_inactive=False, include_schema=False, version=DEFAULT_VERSION): """ Get event types. @@ -1332,6 +1355,36 @@ async def post_message(self, message, params=None): self.logger.debug(f'Posting message: {message}') return await self._post('messages', payload=message, params=params) + async def get_messages(self, **kwargs): + """ + Returns an async generator to iterate over messages. + Optional kwargs passed as query params: + page_size: Change the page size. Default 100. + """ + params = {**kwargs} + if not params.get('page_size'): + params['page_size'] = 100 + async for message in self._get_data(endpoint='messages', params=params): + yield message + + async def get_message(self, message_id): + """ + Get a single message by ID. + :param message_id: UUID of the message + :return: message data dict + """ + self.logger.debug(f'Getting message: {message_id}') + return await self._get(f'messages/{message_id}') + + async def delete_message(self, message_id): + """ + Delete a message by ID. + :param message_id: UUID of the message + :return: response data + """ + self.logger.debug(f'Deleting message: {message_id}') + return await self._delete(f'messages/{message_id}/') + async def get_source_by_manufacturer_id(self, manufacturer_id): """ Get a source by manufacturer_id. diff --git a/tests/async_client/test_delete_message.py b/tests/async_client/test_delete_message.py new file mode 100644 index 0000000..cec2566 --- /dev/null +++ b/tests/async_client/test_delete_message.py @@ -0,0 +1,38 @@ +import httpx +import pytest +import respx + +from erclient import ERClientNotFound + + +@pytest.mark.asyncio +async def test_delete_message_success(er_client): + message_id = "da783214-0d79-4d8c-ba6c-687688e3f6e7" + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.delete(f"messages/{message_id}/") + route.return_value = httpx.Response(httpx.codes.NO_CONTENT) + + result = await er_client.delete_message(message_id) + assert route.called + assert result is True # 204 No Content returns True (canonical _delete behavior) + await er_client.close() + + +@pytest.mark.asyncio +async def test_delete_message_not_found(er_client, not_found_response): + message_id = "nonexistent-id" + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.delete(f"messages/{message_id}/") + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, + json=not_found_response, + ) + + with pytest.raises(ERClientNotFound): + await er_client.delete_message(message_id) + assert route.called + await er_client.close() diff --git a/tests/async_client/test_get_message.py b/tests/async_client/test_get_message.py new file mode 100644 index 0000000..c302356 --- /dev/null +++ b/tests/async_client/test_get_message.py @@ -0,0 +1,41 @@ +import httpx +import pytest +import respx + +from erclient import ERClientNotFound + + +@pytest.mark.asyncio +async def test_get_message_success(er_client, message_received_response): + message_id = "da783214-0d79-4d8c-ba6c-687688e3f6e7" + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"messages/{message_id}") + route.return_value = httpx.Response( + httpx.codes.OK, + json={"data": message_received_response}, + ) + + result = await er_client.get_message(message_id) + assert route.called + assert result == message_received_response + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_message_not_found(er_client, not_found_response): + message_id = "nonexistent-id" + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get(f"messages/{message_id}") + route.return_value = httpx.Response( + httpx.codes.NOT_FOUND, + json=not_found_response, + ) + + with pytest.raises(ERClientNotFound): + await er_client.get_message(message_id) + assert route.called + await er_client.close() diff --git a/tests/async_client/test_get_messages.py b/tests/async_client/test_get_messages.py new file mode 100644 index 0000000..404a4ad --- /dev/null +++ b/tests/async_client/test_get_messages.py @@ -0,0 +1,157 @@ +import httpx +import pytest +import respx + + +@pytest.fixture +def get_messages_single_page_response(message_received_response): + return { + "count": 2, + "next": None, + "previous": None, + "results": [ + message_received_response, + { + "id": "ab123456-0d79-4d8c-ba6c-687688e3f6e7", + "sender": { + "content_type": "observations.subject", + "id": "d2bd0ac8-080d-4be9-a8c2-2250623e6782", + "name": "gundi2", + "subject_type": "unassigned", + "subject_subtype": "mm-inreach-test", + "common_name": None, + "additional": {}, + "created_at": "2025-06-05T07:05:12.817899-07:00", + "updated_at": "2025-06-05T07:05:12.817926-07:00", + "is_active": True, + "user": None, + "tracks_available": False, + "image_url": "/static/pin-black.svg", + }, + "receiver": None, + "device": "443724d6-043f-4014-bea6-4d80a38469c8", + "message_type": "inbox", + "text": "Second test message", + "status": "received", + "device_location": {"latitude": -51.687, "longitude": -72.71}, + "message_time": "2025-06-05T04:08:00.000000-07:00", + "read": False, + }, + ], + } + + +@pytest.fixture +def get_messages_page_one_response(message_received_response): + return { + "count": 3, + "next": "https://fake-site.erdomain.org/api/v1.0/messages?page=2&page_size=2&use_cursor=true", + "previous": None, + "results": [ + message_received_response, + { + "id": "ab123456-0d79-4d8c-ba6c-687688e3f6e7", + "sender": None, + "receiver": None, + "device": "443724d6-043f-4014-bea6-4d80a38469c8", + "message_type": "inbox", + "text": "Second message", + "status": "received", + "device_location": {"latitude": -51.687, "longitude": -72.71}, + "message_time": "2025-06-05T04:08:00.000000-07:00", + "read": False, + }, + ], + } + + +@pytest.fixture +def get_messages_page_two_response(): + return { + "count": 3, + "next": None, + "previous": "https://fake-site.erdomain.org/api/v1.0/messages?page_size=2&use_cursor=true", + "results": [ + { + "id": "cd789012-0d79-4d8c-ba6c-687688e3f6e7", + "sender": None, + "receiver": None, + "device": "443724d6-043f-4014-bea6-4d80a38469c8", + "message_type": "inbox", + "text": "Third message", + "status": "received", + "device_location": {"latitude": -51.687, "longitude": -72.71}, + "message_time": "2025-06-05T04:09:00.000000-07:00", + "read": True, + }, + ], + } + + +@pytest.mark.asyncio +async def test_get_messages_single_page(er_client, get_messages_single_page_response): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + route = respx_mock.get("messages").return_value = httpx.Response( + httpx.codes.OK, + json={"data": get_messages_single_page_response}, + ) + + messages = [] + async for msg in er_client.get_messages(): + messages.append(msg) + + assert len(messages) == 2 + assert messages[0]["id"] == "da783214-0d79-4d8c-ba6c-687688e3f6e7" + assert messages[1]["id"] == "ab123456-0d79-4d8c-ba6c-687688e3f6e7" + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_messages_paginated( + er_client, get_messages_page_one_response, get_messages_page_two_response +): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + # First page + respx_mock.get("messages").mock( + side_effect=[ + httpx.Response( + httpx.codes.OK, + json={"data": get_messages_page_one_response}, + ), + httpx.Response( + httpx.codes.OK, + json={"data": get_messages_page_two_response}, + ), + ] + ) + + messages = [] + async for msg in er_client.get_messages(page_size=2): + messages.append(msg) + + assert len(messages) == 3 + assert messages[0]["id"] == "da783214-0d79-4d8c-ba6c-687688e3f6e7" + assert messages[2]["id"] == "cd789012-0d79-4d8c-ba6c-687688e3f6e7" + await er_client.close() + + +@pytest.mark.asyncio +async def test_get_messages_empty(er_client): + async with respx.mock( + base_url=er_client._api_root("v1.0"), assert_all_called=False + ) as respx_mock: + respx_mock.get("messages").return_value = httpx.Response( + httpx.codes.OK, + json={"data": {"count": 0, "next": None, "previous": None, "results": []}}, + ) + + messages = [] + async for msg in er_client.get_messages(): + messages.append(msg) + + assert len(messages) == 0 + await er_client.close() diff --git a/tests/sync_client/test_delete_message.py b/tests/sync_client/test_delete_message.py new file mode 100644 index 0000000..5ece466 --- /dev/null +++ b/tests/sync_client/test_delete_message.py @@ -0,0 +1,48 @@ +import json +from unittest.mock import patch, MagicMock + +import pytest + +from erclient import ERClientNotFound, ERClientPermissionDenied + + +def test_delete_message_success(er_client): + message_id = "da783214-0d79-4d8c-ba6c-687688e3f6e7" + mock_response = MagicMock() + mock_response.ok = True + mock_response.status_code = 204 + + with patch.object(er_client._http_session, "delete", return_value=mock_response) as mock_delete: + # Sync delete_message calls _delete which returns True but delete_message itself doesn't return + er_client.delete_message(message_id) + + # Verify the delete endpoint was called correctly + mock_delete.assert_called_once() + call_url = mock_delete.call_args[0][0] + assert "messages/" + message_id + "/" in call_url + + +def test_delete_message_not_found(er_client): + message_id = "nonexistent-id" + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.text = json.dumps({"status": {"detail": "not found"}}) + + with patch.object(er_client._http_session, "delete", return_value=mock_response): + with pytest.raises(ERClientNotFound): + er_client.delete_message(message_id) + + +def test_delete_message_forbidden(er_client): + message_id = "da783214-0d79-4d8c-ba6c-687688e3f6e7" + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 403 + mock_response.text = json.dumps( + {"status": {"detail": "You do not have permission to perform this action."}} + ) + + with patch.object(er_client._http_session, "delete", return_value=mock_response): + with pytest.raises(ERClientPermissionDenied): + er_client.delete_message(message_id) diff --git a/tests/sync_client/test_get_message.py b/tests/sync_client/test_get_message.py new file mode 100644 index 0000000..3750f78 --- /dev/null +++ b/tests/sync_client/test_get_message.py @@ -0,0 +1,30 @@ +import json +from unittest.mock import patch, MagicMock + +import pytest + +from erclient import ERClientNotFound + + +def test_get_message_success(er_client, message_detail_response): + message_id = "da783214-0d79-4d8c-ba6c-687688e3f6e7" + mock_response = MagicMock() + mock_response.ok = True + mock_response.text = json.dumps(message_detail_response) + + with patch.object(er_client._http_session, "get", return_value=mock_response): + result = er_client.get_message(message_id) + + assert result == message_detail_response["data"] + + +def test_get_message_not_found(er_client): + message_id = "nonexistent-id" + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.text = json.dumps({"status": {"detail": "not found"}}) + + with patch.object(er_client._http_session, "get", return_value=mock_response): + with pytest.raises(ERClientNotFound): + er_client.get_message(message_id) diff --git a/tests/sync_client/test_get_messages.py b/tests/sync_client/test_get_messages.py new file mode 100644 index 0000000..96b9670 --- /dev/null +++ b/tests/sync_client/test_get_messages.py @@ -0,0 +1,78 @@ +import json +from unittest.mock import patch, MagicMock, call + +import pytest + + +def test_get_messages_single_page(er_client, get_messages_single_page_response): + mock_response = MagicMock() + mock_response.ok = True + mock_response.text = json.dumps(get_messages_single_page_response) + + with patch.object(er_client._http_session, "get", return_value=mock_response): + messages = list(er_client.get_messages()) + + assert len(messages) == 2 + assert messages[0]["id"] == "da783214-0d79-4d8c-ba6c-687688e3f6e7" + assert messages[1]["id"] == "ab123456-0d79-4d8c-ba6c-687688e3f6e7" + + +def test_get_messages_paginated( + er_client, get_messages_page_one_response, get_messages_page_two_response +): + mock_response_page1 = MagicMock() + mock_response_page1.ok = True + mock_response_page1.text = json.dumps(get_messages_page_one_response) + + mock_response_page2 = MagicMock() + mock_response_page2.ok = True + mock_response_page2.text = json.dumps(get_messages_page_two_response) + + with patch.object( + er_client._http_session, + "get", + side_effect=[mock_response_page1, mock_response_page2], + ): + messages = list(er_client.get_messages()) + + assert len(messages) == 3 + assert messages[0]["id"] == "da783214-0d79-4d8c-ba6c-687688e3f6e7" + assert messages[1]["id"] == "ab123456-0d79-4d8c-ba6c-687688e3f6e7" + assert messages[2]["id"] == "cd789012-0d79-4d8c-ba6c-687688e3f6e7" + + +def test_get_messages_empty(er_client): + empty_response = { + "data": { + "count": 0, + "next": None, + "previous": None, + "results": [], + } + } + mock_response = MagicMock() + mock_response.ok = True + mock_response.text = json.dumps(empty_response) + + with patch.object(er_client._http_session, "get", return_value=mock_response): + messages = list(er_client.get_messages()) + + assert len(messages) == 0 + + +def test_get_messages_accepts_kwargs(er_client, get_messages_single_page_response): + """Verify that extra kwargs like page_size are passed as params.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.text = json.dumps(get_messages_single_page_response) + + with patch.object(er_client._http_session, "get", return_value=mock_response) as mock_get: + messages = list(er_client.get_messages(page_size=50)) + + # Verify that get was called with the page_size param + call_kwargs = mock_get.call_args + assert call_kwargs is not None + assert "params" in call_kwargs.kwargs or ( + len(call_kwargs.args) > 1 + or (call_kwargs.kwargs.get("params", {}).get("page_size") == 50) + ) diff --git a/tests/sync_client/test_post_message.py b/tests/sync_client/test_post_message.py new file mode 100644 index 0000000..fa08da6 --- /dev/null +++ b/tests/sync_client/test_post_message.py @@ -0,0 +1,59 @@ +import json +from unittest.mock import patch, MagicMock + +import pytest + +from erclient import ERClientNotFound, ERClientPermissionDenied + + +def test_post_message_success(er_client, message, message_created_response): + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = message_created_response + mock_response.text = json.dumps(message_created_response) + + with patch.object(er_client._http_session, "post", return_value=mock_response): + result = er_client.post_message(message) + + assert result == message_created_response["data"] + + +def test_post_message_returns_full_response_when_no_data_key(er_client, message): + """When the response has no 'data' key, the full response is returned.""" + raw_response = { + "id": "da783214-0d79-4d8c-ba6c-687688e3f6e7", + "text": "A test message!", + } + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = raw_response + mock_response.text = json.dumps(raw_response) + + with patch.object(er_client._http_session, "post", return_value=mock_response): + result = er_client.post_message(message) + + assert result == raw_response + + +def test_post_message_not_found(er_client, message): + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 404 + mock_response.text = json.dumps({"status": {"detail": "not found"}}) + + with patch.object(er_client._http_session, "post", return_value=mock_response): + with pytest.raises(ERClientNotFound): + er_client.post_message(message) + + +def test_post_message_forbidden(er_client, message): + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 403 + mock_response.text = json.dumps( + {"status": {"detail": "You do not have permission to perform this action."}} + ) + + with patch.object(er_client._http_session, "post", return_value=mock_response): + with pytest.raises(ERClientPermissionDenied): + er_client.post_message(message)