Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions erclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 + '/')

Expand Down Expand Up @@ -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']
Comment on lines +653 to +654
results = self._get(path='messages', params=params)
Comment on lines +653 to +655
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.
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions tests/async_client/test_delete_message.py
Original file line number Diff line number Diff line change
@@ -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()
41 changes: 41 additions & 0 deletions tests/async_client/test_get_message.py
Original file line number Diff line number Diff line change
@@ -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()
157 changes: 157 additions & 0 deletions tests/async_client/test_get_messages.py
Original file line number Diff line number Diff line change
@@ -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},
)
Comment on lines +96 to +99

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()
48 changes: 48 additions & 0 deletions tests/sync_client/test_delete_message.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading