From 962d0a0bab11f3a8421e773064e43cb563a7c06e Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Feb 2026 19:33:09 +0100 Subject: [PATCH 1/2] DEVEXP-1241: Conversation Messages - List (Unit Tests & Snippets) --- .../conversation/messages/list/snippet.py | 37 ++++++ .../messages/test_list_messages_endpoint.py | 115 ++++++++++++++++++ .../request/test_list_messages_request.py | 98 +++++++++++++++ .../internal/test_list_messages_response.py | 58 +++++++++ 4 files changed, 308 insertions(+) create mode 100644 examples/snippets/conversation/messages/list/snippet.py create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py diff --git a/examples/snippets/conversation/messages/list/snippet.py b/examples/snippets/conversation/messages/list/snippet.py new file mode 100644 index 0000000..9ba365c --- /dev/null +++ b/examples/snippets/conversation/messages/list/snippet.py @@ -0,0 +1,37 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to list messages from +app_id ="CONVERSATION_APP_ID" + +messages = sinch_client.conversation.messages.list( + app_id=app_id, + page_size=10 +) + +page_counter = 1 +while True: + print(f"Page {page_counter} List of Messages: {messages}") + + if not messages.has_next_page: + break + + messages = messages.next_page() + page_counter += 1 diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py new file mode 100644 index 0000000..e77318c --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py @@ -0,0 +1,115 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import ListMessagesEndpoint +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, +) + + +@pytest.fixture +def request_data(): + return ListMessagesRequest(page_size=10) + + +@pytest.fixture +def endpoint(request_data): + return ListMessagesEndpoint("test_project_id", request_data) + + +@pytest.fixture +def mock_list_messages_response(contact_message_response_data): + return HTTPResponse( + status_code=200, + body={ + "messages": [contact_message_response_data], + "next_page_token": "token_next_page_abc", + }, + headers={"Content-Type": "application/json"}, + ) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly (no path params beyond project_id).""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages" + ) + + +def test_build_query_params_expects_empty_when_only_page_size(): + """Test that query params only include non-None fields.""" + request_data = ListMessagesRequest(page_size=10) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + + assert query_params["page_size"] == 10 + assert "conversation_id" not in query_params + + +def test_build_query_params_expects_parsed_params(): + """Test that all query param fields are serialized when set.""" + request_data = ListMessagesRequest( + conversation_id="CONV123", + contact_id="CONTACT456", + app_id="APP789", + channel_identity="+46701234567", + page_size=20, + page_token="token_xyz", + view="WITH_METADATA", + messages_source="DISPATCH_SOURCE", + only_recipient_originated=True, + channel="WHATSAPP", + direction="TO_APP", + ) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + + assert query_params["conversation_id"] == "CONV123" + assert query_params["contact_id"] == "CONTACT456" + assert query_params["app_id"] == "APP789" + assert query_params["channel_identity"] == "+46701234567" + assert query_params["page_size"] == 20 + assert query_params["page_token"] == "token_xyz" + assert query_params["view"] == "WITH_METADATA" + assert query_params["messages_source"] == "DISPATCH_SOURCE" + assert query_params["only_recipient_originated"] is True + assert query_params["channel"] == "WHATSAPP" + assert query_params["direction"] == "TO_APP" + + +def test_handle_response_expects_list_messages_response( + endpoint, mock_list_messages_response +): + """Test that a successful response is parsed to ListMessagesResponse.""" + result = endpoint.handle_response(mock_list_messages_response) + + assert isinstance(result, ListMessagesResponse) + assert result.next_page_token == "token_next_page_abc" + assert result.messages is not None + assert len(result.messages) == 1 + assert result.messages[0].id == "CAPY123456789ABCDEFGHIJKLMNOP" + + +def test_handle_response_expects_empty_messages_list(): + """Test that response with empty messages list is handled correctly.""" + request_data = ListMessagesRequest(page_size=10) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + mock_response = HTTPResponse( + status_code=200, + body={"messages": [], "next_page_token": None}, + headers={"Content-Type": "application/json"}, + ) + + result = endpoint.handle_response(mock_response) + + assert isinstance(result, ListMessagesResponse) + assert result.messages == [] + assert result.next_page_token is None diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py new file mode 100644 index 0000000..e8a338a --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py @@ -0,0 +1,98 @@ +from datetime import datetime, timezone +import pytest +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, +) + + +def test_list_messages_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + request = ListMessagesRequest( + conversation_id="CONV123456789ABCDEFGHIJKLM", + contact_id="CONTACT456789ABCDEFGHIJKLMNOP", + app_id="APP123456789ABCDEFGHIJK", + channel_identity="+46701234567", + start_time=start, + end_time=end, + page_size=50, + page_token="next_page_token_abc", + view="WITH_METADATA", + messages_source="CONVERSATION_SOURCE", + only_recipient_originated=True, + channel="WHATSAPP", + direction="TO_CONTACT", + ) + + assert request.conversation_id == "CONV123456789ABCDEFGHIJKLM" + assert request.contact_id == "CONTACT456789ABCDEFGHIJKLMNOP" + assert request.app_id == "APP123456789ABCDEFGHIJK" + assert request.channel_identity == "+46701234567" + assert request.start_time == start + assert request.end_time == end + assert request.page_size == 50 + assert request.page_token == "next_page_token_abc" + assert request.view == "WITH_METADATA" + assert request.messages_source == "CONVERSATION_SOURCE" + assert request.only_recipient_originated is True + assert request.channel == "WHATSAPP" + assert request.direction == "TO_CONTACT" + + +@pytest.mark.parametrize( + "messages_source", + ["CONVERSATION_SOURCE", "DISPATCH_SOURCE"], +) +def test_list_messages_request_expects_accepts_messages_source(messages_source): + """Test that the model accepts messages_source with different values.""" + request = ListMessagesRequest( + page_size=10, + messages_source=messages_source, + ) + + assert request.page_size == 10 + assert request.messages_source == messages_source + + +@pytest.mark.parametrize( + "view", + ["WITH_METADATA", "WITHOUT_METADATA"], +) +def test_list_messages_request_expects_accepts_view(view): + """Test that the model accepts view with different values.""" + request = ListMessagesRequest(page_size=10, view=view) + + assert request.view == view + + +@pytest.mark.parametrize( + "channel", + ["WHATSAPP", "RCS", "SMS", "MESSENGER"], +) +def test_list_messages_request_expects_accepts_channel(channel): + """Test that the model accepts channel with different values.""" + request = ListMessagesRequest(page_size=10, channel=channel) + + assert request.channel == channel + + +@pytest.mark.parametrize( + "direction", + ["TO_APP", "TO_CONTACT"], +) +def test_list_messages_request_expects_accepts_direction(direction): + """Test that the model accepts direction with different values.""" + request = ListMessagesRequest(page_size=10, direction=direction) + + assert request.direction == direction + + +def test_list_messages_request_expects_model_dump_excludes_none(): + """Test that model_dump with exclude_none=True omits None values.""" + request = ListMessagesRequest(page_size=10) + dumped = request.model_dump(exclude_none=True, by_alias=True) + + assert "page_size" in dumped + assert dumped["page_size"] == 10 diff --git a/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py new file mode 100644 index 0000000..91d6e0f --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py @@ -0,0 +1,58 @@ +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) + + +def test_list_messages_response_expects_correct_mapping( + contact_message_response_data, +): + """Test that response is correctly parsed from dict.""" + data = { + "messages": [contact_message_response_data], + "next_page_token": "token_abc", + } + response = ListMessagesResponse.model_validate(data) + + assert response.next_page_token == "token_abc" + assert response.messages is not None + assert len(response.messages) == 1 + assert response.messages[0].id == contact_message_response_data["id"] + assert response.content == response.messages + + +def test_list_messages_response_expects_empty_messages_list(): + """Test that response with empty messages list has content as empty list.""" + response = ListMessagesResponse(messages=[], next_page_token=None) + + assert response.messages == [] + assert response.content == [] + + +def test_list_messages_response_expects_next_page_token(): + """Test that next_page_token is parsed correctly.""" + response = ListMessagesResponse( + messages=[], + next_page_token="token_next_page_xyz", + ) + + assert response.next_page_token == "token_next_page_xyz" + + +def test_list_messages_response_expects_content_property_returns_messages( + contact_message_response_data, + app_message_response_data, +): + """Test that content property returns messages list for pagination compatibility.""" + data = { + "messages": [contact_message_response_data, app_message_response_data], + "next_page_token": None, + } + response = ListMessagesResponse.model_validate(data) + + assert hasattr(response, "content") + assert response.content == response.messages + assert len(response.content) == 2 From 2708aa6a71499627175946a56d72e0b6fc151a09 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 5 Feb 2026 13:42:57 +0100 Subject: [PATCH 2/2] PR comments --- .../conversation/messages/list/snippet.py | 2 +- .../messages/test_list_messages_endpoint.py | 2 +- .../request/test_list_messages_request.py | 59 +------------------ .../internal/test_list_messages_response.py | 39 ++++-------- 4 files changed, 13 insertions(+), 89 deletions(-) diff --git a/examples/snippets/conversation/messages/list/snippet.py b/examples/snippets/conversation/messages/list/snippet.py index 9ba365c..7f81d1d 100644 --- a/examples/snippets/conversation/messages/list/snippet.py +++ b/examples/snippets/conversation/messages/list/snippet.py @@ -19,7 +19,7 @@ ) # The ID of the Conversation App to list messages from -app_id ="CONVERSATION_APP_ID" +app_id = "CONVERSATION_APP_ID" messages = sinch_client.conversation.messages.list( app_id=app_id, diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py index e77318c..e6eb805 100644 --- a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py @@ -42,7 +42,7 @@ def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation) ) -def test_build_query_params_expects_empty_when_only_page_size(): +def test_build_query_params_expects_excludes_unset_fields(): """Test that query params only include non-None fields.""" request_data = ListMessagesRequest(page_size=10) endpoint = ListMessagesEndpoint("test_project_id", request_data) diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py index e8a338a..03fb01b 100644 --- a/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -import pytest + from sinch.domains.conversation.models.v1.messages.internal.request import ( ListMessagesRequest, ) @@ -39,60 +39,3 @@ def test_list_messages_request_expects_parsed_input(): assert request.only_recipient_originated is True assert request.channel == "WHATSAPP" assert request.direction == "TO_CONTACT" - - -@pytest.mark.parametrize( - "messages_source", - ["CONVERSATION_SOURCE", "DISPATCH_SOURCE"], -) -def test_list_messages_request_expects_accepts_messages_source(messages_source): - """Test that the model accepts messages_source with different values.""" - request = ListMessagesRequest( - page_size=10, - messages_source=messages_source, - ) - - assert request.page_size == 10 - assert request.messages_source == messages_source - - -@pytest.mark.parametrize( - "view", - ["WITH_METADATA", "WITHOUT_METADATA"], -) -def test_list_messages_request_expects_accepts_view(view): - """Test that the model accepts view with different values.""" - request = ListMessagesRequest(page_size=10, view=view) - - assert request.view == view - - -@pytest.mark.parametrize( - "channel", - ["WHATSAPP", "RCS", "SMS", "MESSENGER"], -) -def test_list_messages_request_expects_accepts_channel(channel): - """Test that the model accepts channel with different values.""" - request = ListMessagesRequest(page_size=10, channel=channel) - - assert request.channel == channel - - -@pytest.mark.parametrize( - "direction", - ["TO_APP", "TO_CONTACT"], -) -def test_list_messages_request_expects_accepts_direction(direction): - """Test that the model accepts direction with different values.""" - request = ListMessagesRequest(page_size=10, direction=direction) - - assert request.direction == direction - - -def test_list_messages_request_expects_model_dump_excludes_none(): - """Test that model_dump with exclude_none=True omits None values.""" - request = ListMessagesRequest(page_size=10) - dumped = request.model_dump(exclude_none=True, by_alias=True) - - assert "page_size" in dumped - assert dumped["page_size"] == 10 diff --git a/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py index 91d6e0f..8ecc2d5 100644 --- a/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py +++ b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py @@ -9,19 +9,25 @@ def test_list_messages_response_expects_correct_mapping( contact_message_response_data, + app_message_response_data, ): - """Test that response is correctly parsed from dict.""" + """ + Test that response is correctly parsed from dict and + content property returns messages. + """ data = { - "messages": [contact_message_response_data], + "messages": [contact_message_response_data, app_message_response_data], "next_page_token": "token_abc", } response = ListMessagesResponse.model_validate(data) assert response.next_page_token == "token_abc" assert response.messages is not None - assert len(response.messages) == 1 + assert len(response.messages) == 2 assert response.messages[0].id == contact_message_response_data["id"] + assert response.messages[1].id == app_message_response_data["id"] assert response.content == response.messages + assert len(response.content) == 2 def test_list_messages_response_expects_empty_messages_list(): @@ -30,29 +36,4 @@ def test_list_messages_response_expects_empty_messages_list(): assert response.messages == [] assert response.content == [] - - -def test_list_messages_response_expects_next_page_token(): - """Test that next_page_token is parsed correctly.""" - response = ListMessagesResponse( - messages=[], - next_page_token="token_next_page_xyz", - ) - - assert response.next_page_token == "token_next_page_xyz" - - -def test_list_messages_response_expects_content_property_returns_messages( - contact_message_response_data, - app_message_response_data, -): - """Test that content property returns messages list for pagination compatibility.""" - data = { - "messages": [contact_message_response_data, app_message_response_data], - "next_page_token": None, - } - response = ListMessagesResponse.model_validate(data) - - assert hasattr(response, "content") - assert response.content == response.messages - assert len(response.content) == 2 + assert response.next_page_token is None