diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py new file mode 100644 index 00000000..99468c2a --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py @@ -0,0 +1,94 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import SendMessageEndpoint +from sinch.domains.conversation.api.v1.exceptions import ConversationException +from sinch.domains.conversation.models.v1.messages.internal.request import ( + SendMessageRequest, + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, +) +from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage +from sinch.domains.conversation.models.v1.messages.response.types import SendMessageResponse + + +@pytest.fixture +def request_data(): + return SendMessageRequest( + app_id="my app ID", + recipient=Recipient(contact_id="my contact ID"), + message=SendMessageRequestBody( + text_message=TextMessage(text="This is a text message.") + ), + ) + + +@pytest.fixture +def mock_send_message_response(): + """Mock response for SendMessageResponse.""" + return HTTPResponse( + status_code=200, + body={"message_id": "01FC66621XXXXX119Z8PMV1QPQ"}, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + """Mock error response for send message endpoint.""" + return HTTPResponse( + status_code=400, + body={ + "error": { + "code": 400, + "message": "Invalid argument", + "status": "INVALID_ARGUMENT" + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return SendMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages:send" + ) + + +def test_request_body_expects_valid_json_with_app_id_recipient_message(request_data): + """Test that the endpoint produces a JSON body with app_id, recipient, and message.""" + endpoint = SendMessageEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["app_id"] == "my app ID" + assert body["recipient"]["contact_id"] == "my contact ID" + assert "text_message" in body["message"] + assert "project_id" not in body + + +def test_handle_response_expects_send_message_response(endpoint, mock_send_message_response): + """Test that SendMessageResponse is handled correctly.""" + parsed_response = endpoint.handle_response(mock_send_message_response) + + assert isinstance(parsed_response, SendMessageResponse) + assert parsed_response.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_handle_response_expects_conversation_exception_on_error( + endpoint, mock_error_response +): + """Test that ConversationException is raised when server returns an error.""" + with pytest.raises(ConversationException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py new file mode 100644 index 00000000..e60dea81 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py @@ -0,0 +1,94 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage +from sinch.domains.conversation.models.v1.messages.internal.request import ( + Recipient, + SendMessageRequestBody, + SendMessageRequest, +) + + +def test_send_message_request_expects_parsed_input(): + """ + Test that the model parses input correctly. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + ) + + assert request.app_id == "my-app-id" + assert request.recipient.contact_id == "my-contact-id" + assert request.message.text_message is not None + assert request.message.text_message.text == "Hello" + + +@pytest.mark.parametrize("processing_strategy", ["DEFAULT", "DISPATCH_ONLY"]) +def test_send_message_request_expects_accepts_processing_strategy(processing_strategy): + """ + Test that the model accepts processing_strategy with different values. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + processing_strategy=processing_strategy, + ) + + assert request.processing_strategy == processing_strategy + + +@pytest.mark.parametrize("ttl_input,expected_serialized", [(10, "10s"), ("10s", "10s"), ("10", "10s"), (None, None)]) +def test_send_message_request_expects_ttl_serialized_to_backend(ttl_input, expected_serialized): + """ + Test that ttl is serialized as "10s" when sent to the backend (int/str normalized to string with 's' suffix). + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + ttl=ttl_input, + ) + + payload = request.model_dump(mode="json", exclude_none=True) + if expected_serialized is None: + assert "ttl" not in payload + else: + assert payload["ttl"] == expected_serialized + + +def test_send_message_request_expects_validation_error_for_missing_app_id(): + """ + Test that the model raises a ValidationError when app_id field is missing. + """ + data = { + "recipient": Recipient(contact_id="my-contact-id"), + "message": SendMessageRequestBody(text_message=TextMessage(text="Hello")), + } + + with pytest.raises(ValidationError) as excinfo: + SendMessageRequest(**data) + + error_message = str(excinfo.value) + + assert "field required" in error_message.casefold() + assert "app_id" in error_message + + +def test_send_message_request_expects_validation_error_for_missing_recipient(): + """ + Test that the model raises a ValidationError when recipient field is missing. + """ + data = { + "app_id": "my-app-id", + "message": SendMessageRequestBody(text_message=TextMessage(text="Hello")), + } + + with pytest.raises(ValidationError) as excinfo: + SendMessageRequest(**data) + + error_message = str(excinfo.value) + + assert "field required" in error_message.casefold() + assert "recipient" in error_message diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py new file mode 100644 index 00000000..013b70d8 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py @@ -0,0 +1,191 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( + TextChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateMessage, + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) + + +def test_send_message_request_body_expects_accepts_text_message(): + """ + Test that the model accepts text_message with valid content. + """ + body = SendMessageRequestBody(text_message=TextMessage(text="Test message content")) + + assert body.text_message.text == "Test message content" + + +def test_send_message_request_body_expects_accepts_card_message(): + """ + Test that the model accepts card_message. + """ + body = SendMessageRequestBody(card_message=CardMessage(title="Card title")) + + assert body.card_message is not None + assert body.card_message.title == "Card title" + + +def test_send_message_request_body_expects_accepts_carousel_message(): + """ + Test that the model accepts carousel_message with a list of cards. + """ + body = SendMessageRequestBody( + carousel_message=CarouselMessage(cards=[CardMessage(title="Card 1")]) + ) + + assert body.carousel_message is not None + assert len(body.carousel_message.cards) == 1 + assert body.carousel_message.cards[0].title == "Card 1" + + +def test_send_message_request_body_expects_accepts_choice_message(): + """ + Test that the model accepts choice_message with choices. + """ + body = SendMessageRequestBody( + choice_message=ChoiceMessage( + choices=[TextChoiceMessage(text_message=TextMessage(text="Option 1"))] + ) + ) + + assert body.choice_message is not None + assert len(body.choice_message.choices) == 1 + assert body.choice_message.choices[0].text_message.text == "Option 1" + + +def test_send_message_request_body_expects_accepts_location_message(): + """ + Test that the model accepts location_message with coordinates and title. + """ + body = SendMessageRequestBody( + location_message=LocationMessage( + coordinates=Coordinates(latitude=59.3293, longitude=18.0686), + title="Stockholm", + ) + ) + + assert body.location_message is not None + assert body.location_message.title == "Stockholm" + assert body.location_message.coordinates.latitude == 59.3293 + assert body.location_message.coordinates.longitude == 18.0686 + + +def test_send_message_request_body_expects_accepts_media_message(): + """ + Test that the model accepts media_message with url. + """ + body = SendMessageRequestBody( + media_message=MediaProperties(url="https://example.com/image.jpg") + ) + + assert body.media_message is not None + assert body.media_message.url == "https://example.com/image.jpg" + + +def test_send_message_request_body_expects_accepts_template_message(): + """ + Test that the model accepts template_message with omni_template. + """ + body = SendMessageRequestBody( + template_message=TemplateMessage( + omni_template=TemplateReferenceOmniChannel( + template_id="tpl_123", version="latest" + ) + ) + ) + + assert body.template_message is not None + assert body.template_message.omni_template is not None + assert body.template_message.omni_template.template_id == "tpl_123" + assert body.template_message.omni_template.version == "latest" + + +def test_send_message_request_body_expects_accepts_choice_with_one_message_key(): + """ + Parsing from dict: each choice with exactly one message-type key is valid. + Choices array can include Call, Location, Text, URL, Calendar, Request location + (number limited to 10 per spec). + """ + choices = [ + {"text_message": {"text": "Option 1"}}, + {"call_message": {"title": "Call us", "phone_number": "+46732000000"}}, + {"url_message": {"title": "Website", "url": "https://example.com"}}, + { + "location_message": { + "title": "Show map", + "coordinates": {"latitude": 59.33, "longitude": 18.07}, + } + }, + { + "share_location_message": { + "title": "Share location", + "fallback_url": "https://example.com", + } + }, + ] + body = SendMessageRequestBody( + choice_message=ChoiceMessage(choices=choices) + ) + assert body.choice_message is not None + assert len(body.choice_message.choices) == 5 + assert body.choice_message.choices[0].text_message.text == "Option 1" + assert body.choice_message.choices[1].call_message.phone_number == "+46732000000" + assert body.choice_message.choices[2].url_message.url == "https://example.com" + assert body.choice_message.choices[3].location_message.title == "Show map" + assert ( + body.choice_message.choices[4].share_location_message.title + == "Share location" + ) + + +def test_send_message_request_body_expects_rejects_choice_with_zero_message_keys(): + """ + Parsing from dict: choice with no message-type key raises. + """ + with pytest.raises(ValueError, match="exactly one of"): + SendMessageRequestBody( + choice_message=ChoiceMessage(choices=[{"postback_data": "x"}]) + ) + + +def test_send_message_request_body_expects_rejects_choice_with_two_message_keys(): + """ + Parsing from dict: choice with two message-type keys raises. + """ + with pytest.raises(ValueError, match="exactly one of"): + SendMessageRequestBody( + choice_message=ChoiceMessage( + choices=[ + { + "text_message": {"text": "A"}, + "call_message": {"title": "Call", "phone_number": "1"}, + } + ] + ) + ) diff --git a/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py new file mode 100644 index 00000000..2f6b542a --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py @@ -0,0 +1,35 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.response.types import ( + SendMessageResponse, +) + + +def test_parsing_send_message_response_expects_message_id_only(): + """Test that SendMessageResponse parses with required message_id only.""" + data = {"message_id": "01FC66621XXXXX119Z8PMV1QPQ"} + parsed = SendMessageResponse.model_validate(data) + + assert isinstance(parsed, SendMessageResponse) + assert parsed.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.accepted_time is None + + +def test_parsing_send_message_response_expects_accepted_time(): + """Test that SendMessageResponse parses accepted_time from ISO string.""" + data = { + "message_id": "01FC66621XXXXX119Z8PMV1QPQ", + "accepted_time": "2026-01-14T20:32:31.147Z", + } + parsed = SendMessageResponse.model_validate(data) + + assert parsed.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.accepted_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) + + +def test_send_message_response_expects_message_id_required(): + """Test that SendMessageResponse requires message_id.""" + with pytest.raises(ValueError): + SendMessageResponse.model_validate({"accepted_time": "2026-01-14T20:32:31.147Z"}) diff --git a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py new file mode 100644 index 00000000..ad6875f6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py @@ -0,0 +1,147 @@ +import pytest +from sinch.domains.conversation.api.v1.utils import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + Recipient, +) + + +class TestBuildRecipientDict: + + @pytest.mark.parametrize( + "contact_id,recipient_identities,expected", + [ + ("contact-123", None, {"contact_id": "contact-123"}), + ( + None, + [{"channel": "RCS", "identity": "+46701234567"}], + {"channel_identities": [{"channel": "RCS", "identity": "+46701234567"}]}, + ), + ], + ) + def test_build_recipient_dict_expects_valid_input_returns_recipient_dict( + self, contact_id, recipient_identities, expected + ): + """Test that providing contact_id or recipient_identities returns the expected dict.""" + result = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + assert result == expected + + @pytest.mark.parametrize( + "contact_id,recipient_identities,error_substring", + [ + ( + "contact-123", + [{"channel": "RCS", "identity": "+46701234567"}], + "Cannot specify both", + ), + (None, None, "Must provide either"), + ], + ) + def test_build_recipient_dict_expects_value_error_when_invalid( + self, contact_id, recipient_identities, error_substring + ): + """Test that invalid combinations raise ValueError with expected message.""" + with pytest.raises(ValueError) as excinfo: + build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + assert error_substring in str(excinfo.value) + + +class TestCoerceRecipient: + + def test_coerce_recipient_expects_recipient_instance_returned_unchanged(self): + """Passing a Recipient returns the same instance with contact_id preserved.""" + recipient_input = Recipient(contact_id="contact-123") + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result is recipient_input + assert result.contact_id == "contact-123" + + def test_coerce_recipient_expects_dict_with_contact_id_converted_to_recipient( + self, + ): + """Passing a dict with contact_id returns a new Recipient with that contact_id.""" + recipient_input = {"contact_id": "contact-456"} + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result is not recipient_input + assert result.contact_id == "contact-456" + + def test_coerce_recipient_expects_dict_with_channel_identities_converted_to_recipient( + self, + ): + """Passing a dict with channel_identities returns Recipient with identified_by.""" + recipient_input = { + "channel_identities": [{"channel": "RCS", "identity": "+46701234567"}] + } + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result.identified_by is not None + assert len(result.identified_by.channel_identities) == 1 + assert ( + result.identified_by.channel_identities[0].identity == "+46701234567" + ) + + def test_coerce_recipient_expects_dict_with_identified_by_converted_to_recipient( + self, + ): + """Passing a dict with identified_by.channel_identities returns Recipient.""" + recipient_input = { + "identified_by": { + "channel_identities": [ + {"channel": "RCS", "identity": "+46701234567"}, + ] + } + } + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result.identified_by is not None + assert len(result.identified_by.channel_identities) == 1 + assert ( + result.identified_by.channel_identities[0].identity == "+46701234567" + ) + + +class TestSplitSendKwargs: + + @pytest.mark.parametrize( + "kwargs,expected_message_kwargs,expected_request_kwargs", + [ + ({}, {}, {}), + ( + {"text_message": {"text": "Hello"}}, + {"text_message": {"text": "Hello"}}, + {}, + ), + ( + {"ttl": 10, "callback_url": "https://example.com/callback"}, + {}, + {"ttl": 10, "callback_url": "https://example.com/callback"}, + ), + ( + { + "text_message": {"text": "Hi"}, + "ttl": 30, + "media_message": {"url": "https://example.com/image.jpg"}, + }, + { + "text_message": {"text": "Hi"}, + "media_message": {"url": "https://example.com/image.jpg"}, + }, + {"ttl": 30}, + ), + ], + ) + def test_split_send_kwargs_expects_kwargs_split_into_message_and_request( + self, kwargs, expected_message_kwargs, expected_request_kwargs + ): + """Test that kwargs are split into message_kwargs and request_kwargs.""" + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + assert message_kwargs == expected_message_kwargs + assert request_kwargs == expected_request_kwargs