diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f97feb3c..dfa2adbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,8 @@ jobs: cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/inbounds.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/inbounds_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0d748a..cfa30c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SMS - **[feature]** SMS Groups API: `create`, `list`, `get`, `update`, `replace`, `delete`, and `list_members` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#groups-api)). +- **[feature]** SMS Inbounds API: `get` and `list` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#210)). +- **[design]** SMS Sinch Events inbound payload models unified with the Inbounds API: `MOTextSinchEvent`, `MOBinarySinchEvent`, `MOMediaSinchEvent`, `MediaBody`, and `MediaItem` removed from `sinch_events`; use `InboundMessage` (and its variants) from `sinch.domains.sms.models.v1.types` instead (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#210)). --- diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index b14c6535..6119f8cc 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -205,9 +205,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2 #### Replacement APIs -The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. - -Note that `sinch.sms.inbounds` is not supported yet and will be available in a future minor version. `sinch.sms.groups` is now available — see [Groups API](#groups-api) below. +The SMS domain API access remains the same: `sinch.sms.batches`, `sinch.sms.delivery_reports`, `sinch.sms.inbounds` and `sinch.sms.groups`. However, the underlying models and method signatures have changed. ##### Batches API @@ -232,8 +230,6 @@ Note that `sinch.sms.inbounds` is not supported yet and will be available in a f ##### Groups API -> **Added in v2.1.0.** `sinch.sms.groups` is now fully supported. - ###### Replacement models | Old class | New class | @@ -265,6 +261,38 @@ Note that `sinch.sms.inbounds` is not supported yet and will be available in a f | `delete()` with `DeleteSMSGroupRequest` | `delete()` with `group_id: str` parameter | | `get_phone_numbers()` / phone number listing | `list_members()` with `group_id: str`. Returns **`Paginator[str]`** | +##### Inbounds API + +###### Replacement models + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.inbounds.requests.ListSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.ListInboundsRequest`](sinch/domains/sms/models/v1/internal/list_inbounds_request.py) | +| `sinch.domains.sms.models.inbounds.requests.GetSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.InboundIdRequest`](sinch/domains/sms/models/v1/internal/inbound_id_request.py) | +| `sinch.domains.sms.models.inbounds.responses.SinchListInboundMessagesResponse` | [`sinch.domains.sms.models.v1.internal.ListInboundsResponse`](sinch/domains/sms/models/v1/internal/list_inbounds_response.py) | +| `sinch.domains.sms.models.inbounds.responses.GetInboundMessagesResponse` | [`sinch.domains.sms.models.v1.types.InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (Union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`) | + +###### Replacement APIs + +| Old method | New method in `sms.inbounds` | +|------------|------------------------------| +| `list()` with `ListSMSInboundMessageRequest` | `list()` with individual parameters: `page`, `page_size`, `to`, `start_date`, `end_date`, `client_reference`. Returns **`Paginator[InboundMessage]`** | +| `get()` with `GetSMSInboundMessageRequest` | `get()` with `inbound_id: str` parameter | + +##### SMS Sinch Events + +The inbound payload models in `sinch_events` have been unified with the Inbounds API models. The following classes have been removed: + +| Removed class | Replacement | +|---------------|-------------| +| `sinch.domains.sms.sinch_events.v1.events.MOTextSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOTextMessage`](sinch/domains/sms/models/v1/shared/mo_text_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MOBinarySinchEvent` | [`sinch.domains.sms.models.v1.shared.MOBinaryMessage`](sinch/domains/sms/models/v1/shared/mo_binary_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MOMediaSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOMediaMessage`](sinch/domains/sms/models/v1/shared/mo_media_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MediaBody` | Embedded in `MOMediaMessage` | +| `sinch.domains.sms.sinch_events.v1.events.MediaItem` | Embedded in `MOMediaMessage` | + +`IncomingSMSSinchEvent` is now a type alias for [`InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (discriminated union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`). Code that previously type-checked against `MOTextSinchEvent` and siblings should switch to their `MO*Message` equivalents. + --- ### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) diff --git a/examples/snippets/sms/inbounds/get/snippet.py b/examples/snippets/sms/inbounds/get/snippet.py new file mode 100644 index 00000000..412e4f1c --- /dev/null +++ b/examples/snippets/sms/inbounds/get/snippet.py @@ -0,0 +1,29 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/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", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the inbound message to retrieve +inbound_id = "INBOUND_ID" + +response = sinch_client.sms.inbound_messages.get( + inbound_id=inbound_id +) + +print(f"Inbound message:\n{response}") diff --git a/examples/snippets/sms/inbounds/list/snippet.py b/examples/snippets/sms/inbounds/list/snippet.py new file mode 100644 index 00000000..00108d5b --- /dev/null +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/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", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + + +inbound_messages = sinch_client.sms.inbound_messages.list() + +print("List of inbound messages:\n") +for message in inbound_messages.iterator(): + print(message) \ No newline at end of file diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index b322cc84..fa632f0b 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,9 +1,11 @@ from sinch.domains.sms.api.v1.batches_apis import Batches from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds from sinch.domains.sms.api.v1.groups_apis import Groups __all__ = [ "Batches", "DeliveryReports", + "Inbounds", "Groups", ] diff --git a/sinch/domains/sms/api/v1/inbounds_apis.py b/sinch/domains/sms/api/v1/inbounds_apis.py new file mode 100644 index 00000000..0d141ed6 --- /dev/null +++ b/sinch/domains/sms/api/v1/inbounds_apis.py @@ -0,0 +1,94 @@ +from datetime import datetime +from typing import List, Optional + +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.api.v1.base.base_sms import BaseSms +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ( + GetInboundEndpoint, + ListInboundsEndpoint, +) +from sinch.domains.sms.models.v1.internal.inbound_id_request import ( + InboundIdRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ( + ListInboundsRequest, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class Inbounds(BaseSms): + def get(self, inbound_id: str, **kwargs) -> InboundMessage: + """ + This operation retrieves a specific inbound message using the provided inbound ID. + + :param inbound_id: The inbound ID found when listing inbound messages. (required) + :type inbound_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: InboundMessage + :rtype: InboundMessage + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = InboundIdRequest(inbound_id=inbound_id, **kwargs) + return self._request(GetInboundEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + to: Optional[List[str]] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[InboundMessage]: + """ + With the list operation, + you can list all inbound messages that you have received. This operation supports pagination. Inbounds are returned in reverse chronological order. + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page (optional) + :type page_size: Optional[int] + :param to: Only list messages sent to this destination. Multiple phone numbers formatted as either + [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) or short codes can be comma separated. + (optional) + :type to: Optional[List[str]] + :param start_date: Only list messages received at or after this date/time. Formatted as + [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Default: Now-24 + (optional) + :type start_date: Optional[datetime] + + :param end_date: Only list messages received before this date/time. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. (optional) + :type end_date: Optional[datetime] + :param client_reference: Using a client reference in inbound messages requires additional setup on your account. + Contact your [account manager](https://dashboard.sinch.com/settings/account-details) to enable this feature. + Only list inbound messages that are in response to messages with a previously provided client reference. + (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[InboundMessage] + :rtype: Paginator[InboundMessage] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListInboundsEndpoint( + project_id=self._get_path_identifier(), + request_data=ListInboundsRequest( + page=page, + page_size=page_size, + to=to, + start_date=start_date, + end_date=end_date, + client_reference=client_reference, + **kwargs, + ), + ) + + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py new file mode 100644 index 00000000..f2037bb3 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -0,0 +1,62 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.base.sms_endpoint import SmsEndpoint +from sinch.domains.sms.models.v1.internal.inbound_id_request import ( + InboundIdRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ( + ListInboundsRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ( + ListInboundsResponse, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class GetInboundEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds/{inbound_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: InboundIdRequest): + super(GetInboundEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> InboundMessage: + try: + super(GetInboundEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, InboundMessage) + + +class ListInboundsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListInboundsRequest): + super(ListInboundsEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response(self, response: HTTPResponse) -> ListInboundsResponse: + try: + super(ListInboundsEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListInboundsResponse) diff --git a/sinch/domains/sms/models/v1/internal/inbound_id_request.py b/sinch/domains/sms/models/v1/internal/inbound_id_request.py new file mode 100644 index 00000000..050c86de --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/inbound_id_request.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class InboundIdRequest(BaseModelConfigurationRequest): + inbound_id: str = Field( + default=..., + description="The unique identifier of the inbound message.", + ) diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_request.py b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py new file mode 100644 index 00000000..409f3062 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py @@ -0,0 +1,15 @@ +from typing import Optional +from datetime import datetime +from pydantic import StrictInt, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListInboundsRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None + to: Optional[conlist(StrictStr)] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py new file mode 100644 index 00000000..61bb3bc1 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import Field, StrictInt, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class ListInboundsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of inbounds matching the given filters", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + inbounds: Optional[conlist(InboundMessage)] = Field( + default=None, + description="The page of inbounds matching the given filters.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of inbounds returned in this request.", + ) + + @property + def content(self): + """Returns the content of the inbounds list.""" + return self.inbounds or [] diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 67fcc08b..e5cc9163 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -16,6 +16,14 @@ ) from sinch.domains.sms.models.v1.shared.text_request import TextRequest from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage +from sinch.domains.sms.models.v1.shared.mo_binary_message import ( + MOBinaryMessage, +) +from sinch.domains.sms.models.v1.shared.mo_media_item import MOMediaItem +from sinch.domains.sms.models.v1.shared.mo_media_body import MOMediaBody +from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage __all__ = [ "AddKeyword", @@ -30,4 +38,10 @@ "RemoveKeyword", "TextRequest", "TextResponse", + "BaseMOMessage", + "MOTextMessage", + "MOBinaryMessage", + "MOMediaItem", + "MOMediaBody", + "MOMediaMessage", ] diff --git a/sinch/domains/sms/models/v1/shared/base_mo_message.py b/sinch/domains/sms/models/v1/shared/base_mo_message.py new file mode 100644 index 00000000..7b5d6252 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/base_mo_message.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BaseMOMessage(BaseModelConfigurationResponse): + from_: StrictStr = Field( + ..., + alias="from", + description="The phone number that sent the message.", + ) + id: StrictStr = Field(..., description="The ID of this inbound message.") + received_at: datetime = Field( + ..., + description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + to: StrictStr = Field( + ..., + description="The Sinch phone number or short code to which the message was sent.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", + ) + operator_id: Optional[StrictStr] = Field( + default=None, + description="The MCC/MNC of the sender's operator if known.", + ) + sent_at: Optional[datetime] = Field( + default=None, + description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_binary_message.py b/sinch/domains/sms/models/v1/shared/mo_binary_message.py new file mode 100644 index 00000000..50c17623 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_binary_message.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage + + +class MOBinaryMessage(BaseMOMessage): + body: StrictStr = Field( + ..., description="The incoming message body (Base64 encoded)." + ) + type: Literal["mo_binary"] = Field( + ..., description="The type of incoming message. Binary SMS." + ) + udh: StrictStr = Field( + ..., description="The UDH header of a binary message HEX encoded." + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_media_body.py b/sinch/domains/sms/models/v1/shared/mo_media_body.py new file mode 100644 index 00000000..6e35ccbc --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_body.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.shared.mo_media_item import MOMediaItem + + +class MOMediaBody(BaseModelConfigurationResponse): + subject: Optional[StrictStr] = Field( + default=None, description="The subject of the MMS media message." + ) + message: Optional[StrictStr] = Field( + default=None, + description="The text message content of the MMS media message.", + ) + media: Optional[conlist(MOMediaItem)] = Field( + default=None, + description="Collection of attachments in incoming message.", + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_media_item.py b/sinch/domains/sms/models/v1/shared/mo_media_item.py new file mode 100644 index 00000000..47d72547 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_item.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional, Union +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MOMediaItem(BaseModelConfigurationResponse): + url: Optional[StrictStr] = Field( + default=None, description="URL to the media file." + ) + content_type: StrictStr = Field( + ..., description="Content type of the media file." + ) + status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( + ..., description="Status of the media upload." + ) + code: StrictInt = Field(..., description="The result code.") diff --git a/sinch/domains/sms/models/v1/shared/mo_media_message.py b/sinch/domains/sms/models/v1/shared/mo_media_message.py new file mode 100644 index 00000000..4d3f6be8 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_message.py @@ -0,0 +1,14 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage +from sinch.domains.sms.models.v1.shared.mo_media_body import MOMediaBody + + +class MOMediaMessage(BaseMOMessage): + body: MOMediaBody = Field( + ..., + description="The media message body.", + ) + type: Literal["mo_media"] = Field( + ..., description="The type of incoming message. MMS." + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_text_message.py b/sinch/domains/sms/models/v1/shared/mo_text_message.py new file mode 100644 index 00000000..42b9f6db --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_text_message.py @@ -0,0 +1,13 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage + + +class MOTextMessage(BaseMOMessage): + body: StrictStr = Field( + ..., + description="The incoming message body. Maximum 2000 characters.", + ) + type: Literal["mo_text"] = Field( + ..., description="The type of incoming message. Regular SMS." + ) diff --git a/sinch/domains/sms/models/v1/types/inbound_message.py b/sinch/domains/sms/models/v1/types/inbound_message.py new file mode 100644 index 00000000..c2f511f2 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/inbound_message.py @@ -0,0 +1,11 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage +from sinch.domains.sms.models.v1.shared.mo_binary_message import ( + MOBinaryMessage, +) +from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage + +_InboundMessageUnion = Union[MOTextMessage, MOBinaryMessage, MOMediaMessage] + +InboundMessage = Annotated[_InboundMessageUnion, Field(discriminator="type")] diff --git a/sinch/domains/sms/sinch_events/v1/events/__init__.py b/sinch/domains/sms/sinch_events/v1/events/__init__.py index 00aba842..4ad09541 100644 --- a/sinch/domains/sms/sinch_events/v1/events/__init__.py +++ b/sinch/domains/sms/sinch_events/v1/events/__init__.py @@ -1,17 +1,6 @@ from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( IncomingSMSSinchEvent, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, - MediaBody, - MediaItem, + SmsSinchEventPayload, ) -__all__ = [ - "IncomingSMSSinchEvent", - "MOTextSinchEvent", - "MOBinarySinchEvent", - "MOMediaSinchEvent", - "MediaBody", - "MediaItem", -] +__all__ = ["IncomingSMSSinchEvent", "SmsSinchEventPayload"] diff --git a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py index fc87e608..a376c4da 100644 --- a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py @@ -1,97 +1,15 @@ -from datetime import datetime -from typing import Optional, Union, Literal, Annotated -from pydantic import Field, StrictStr, StrictInt, conlist -from sinch.domains.sms.sinch_events.v1.internal import SinchEvent +from typing import Union +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage -class MediaItem(SinchEvent): - url: StrictStr = Field(..., description="URL to the media file") - content_type: StrictStr = Field( - ..., description="Content type of the media file" - ) - status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( - ..., description="Status of the media upload" - ) - code: StrictInt = Field(..., description="Status code") +IncomingSMSSinchEvent = InboundMessage - -class MediaBody(SinchEvent): - subject: Optional[StrictStr] = Field( - default=None, description="The subject text" - ) - message: Optional[StrictStr] = Field( - default=None, description="The message text" - ) - media: conlist(MediaItem) = Field(..., description="Array of media items") - - -class BaseIncomingSMSSinchEvent(SinchEvent): - from_: StrictStr = Field( - ..., - alias="from", - description="The phone number that sent the message.", - ) - id: StrictStr = Field(..., description="The ID of this inbound message.") - received_at: datetime = Field( - ..., - description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", - ) - to: StrictStr = Field( - ..., - description="The Sinch phone number or short code to which the message was sent.", - ) - client_reference: Optional[StrictStr] = Field( - default=None, - description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", - ) - operator_id: Optional[StrictStr] = Field( - default=None, - description="The MCC/MNC of the sender's operator if known.", - ) - sent_at: Optional[datetime] = Field( - default=None, - description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", - ) - - -class MOTextSinchEvent(BaseIncomingSMSSinchEvent): - body: StrictStr = Field( - ..., - description="The incoming message body. Maximum 2000 characters.", - ) - type: Literal["mo_text"] = Field( - ..., description="The type of incoming message. Regular SMS." - ) - - -class MOBinarySinchEvent(BaseIncomingSMSSinchEvent): - body: StrictStr = Field( - ..., description="The incoming message body (Base64 encoded)." - ) - type: Literal["mo_binary"] = Field( - ..., description="The type of incoming message. Binary SMS." - ) - udh: StrictStr = Field( - ..., description="The UDH header of a binary message HEX encoded." - ) - - -class MOMediaSinchEvent(BaseIncomingSMSSinchEvent): - body: MediaBody = Field( - ..., - description="The media message body containing subject, message, and media items.", - ) - type: Literal["mo_media"] = Field( - ..., description="The type of incoming message. MMS." - ) - - -# Union type for isinstance checks -_IncomingSMSSinchEventUnion = Union[ - MOTextSinchEvent, MOBinarySinchEvent, MOMediaSinchEvent -] - -# Discriminated union for validation -IncomingSMSSinchEvent = Annotated[ - _IncomingSMSSinchEventUnion, Field(discriminator="type") +SmsSinchEventPayload = Union[ + InboundMessage, + BatchDeliveryReport, + RecipientDeliveryReport, ] diff --git a/sinch/domains/sms/sinch_events/v1/internal/__init__.py b/sinch/domains/sms/sinch_events/v1/internal/__init__.py deleted file mode 100644 index 43b3a8dd..00000000 --- a/sinch/domains/sms/sinch_events/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.sms.sinch_events.v1.internal.sinch_event import ( - SinchEvent, -) - -__all__ = ["SinchEvent"] diff --git a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py deleted file mode 100644 index 184012f9..00000000 --- a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py +++ /dev/null @@ -1,7 +0,0 @@ -from sinch.domains.sms.models.v1.internal.base import ( - BaseModelConfigurationResponse, -) - - -class SinchEvent(BaseModelConfigurationResponse): - pass diff --git a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py index 03f52892..2fd5e3f3 100644 --- a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -1,33 +1,24 @@ import json -from typing import Any, Dict, Union, Optional +from typing import Any, Dict, Optional, Union + from pydantic import TypeAdapter + from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( validate_sinch_event_signature_with_nonce, ) from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, - parse_json, normalize_iso_timestamp, -) -from sinch.domains.sms.sinch_events.v1.events import ( - IncomingSMSSinchEvent, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, + parse_json, ) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, RecipientDeliveryReport, ) - - -SmsSinchEventPayload = Union[ - BatchDeliveryReport, - RecipientDeliveryReport, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, -] +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, + SmsSinchEventPayload, +) class SmsSinchEvent: diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index 967cef35..4ed60507 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,6 +2,7 @@ Batches, DeliveryReports, ) +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds from sinch.domains.sms.api.v1.groups_apis import Groups from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent @@ -17,6 +18,7 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + self.inbound_messages = Inbounds(self._sinch) self.groups = Groups(self._sinch) def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: diff --git a/tests/e2e/sms/features/steps/inbounds.steps.py b/tests/e2e/sms/features/steps/inbounds.steps.py new file mode 100644 index 00000000..9bf38d4e --- /dev/null +++ b/tests/e2e/sms/features/steps/inbounds.steps.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +from behave import when, then +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@when('I send a request to retrieve an inbound message') +def step_retrieve_inbound_message(context): + """Retrieve a single inbound message by ID""" + context.response = context.sms.inbound_messages.get( + inbound_id='01W4FFL35P4NC4K35INBOUND01' + ) + + +@then('the response contains the inbound message details') +def step_validate_inbound_message(context): + """Validate the inbound message response""" + data: MOTextMessage = context.response + assert isinstance(data, MOTextMessage) + assert data.id == '01W4FFL35P4NC4K35INBOUND01' + assert data.from_ == '12015555555' + assert data.to == '12017777777' + assert data.body == 'Hello John!' + assert data.type == 'mo_text' + assert data.operator_id == '311071' + assert data.received_at == datetime(2024, 6, 6, 14, 16, 54, 777000, tzinfo=timezone.utc) + + +@when('I send a request to list the inbound messages') +def step_list_inbound_messages(context): + """List a page of inbound messages""" + context.response = context.sms.inbound_messages.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + + +@then('the response contains "{count}" inbound messages') +def step_validate_inbound_messages_count(context, count): + """Validate the count of inbound messages in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the inbound messages') +def step_list_all_inbound_messages(context): + """List all inbound messages using iterator""" + response = context.sms.inbound_messages.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + inbound_messages_list = [] + + for inbound_message in response.iterator(): + inbound_messages_list.append(inbound_message) + + context.inbound_messages_list = inbound_messages_list + + +@then('the inbound messages list contains "{count}" inbound messages') +def step_validate_inbound_messages_list_count(context, count): + """Validate the count of inbound messages in the full list""" + expected_count = int(count) + assert len(context.inbound_messages_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.inbound_messages_list)}' + + +@when('I iterate manually over the inbound messages pages') +def step_iterate_manually_inbound_messages(context): + """Manually iterate over inbound messages pages""" + context.list_response = context.sms.inbound_messages.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + + context.inbound_messages_list = [] + context.pages_iteration = 0 + reached_last_page = False + + while not reached_last_page: + context.inbound_messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_last_page = True + + +@then('the inbound messages iteration result contains the data from "{count}" pages') +def step_validate_inbound_messages_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 99907fc4..16c85332 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -1,10 +1,8 @@ import requests from datetime import datetime, timezone from behave import given, when, then +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent -from sinch.domains.sms.sinch_events.v1.events import ( - MOTextSinchEvent, -) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, RecipientDeliveryReport, @@ -36,7 +34,7 @@ def step_check_valid_signature(context, event_type, status=None): @then('the SMS event describes an "incoming SMS" event') def step_check_incoming_sms_event(context): - incoming_sms_event: MOTextSinchEvent = context.event + incoming_sms_event: MOTextMessage = context.event assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' assert incoming_sms_event.from_ == '12015555555' assert incoming_sms_event.to == '12017777777' diff --git a/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py b/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py new file mode 100644 index 00000000..b8d1a8f3 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py @@ -0,0 +1,89 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import GetInboundEndpoint +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest +from sinch.domains.sms.models.v1.shared import MOBinaryMessage, MOTextMessage + + +@pytest.fixture +def request_data(): + return InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_mo_text_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Test inbound message", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetInboundEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/inbounds/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_mo_text_message(endpoint, mock_mo_text_response): + """Test that the response is correctly parsed as MOTextMessage.""" + parsed = endpoint.handle_response(mock_mo_text_response) + + assert isinstance(parsed, MOTextMessage) + assert parsed.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.from_ == "+46701234567" + assert parsed.to == "+46709876543" + assert parsed.body == "Test inbound message" + assert parsed.type == "mo_text" + assert parsed.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_handle_response_expects_mo_binary_message(request_data): + """Test that the response is correctly parsed as MOBinaryMessage.""" + mock_binary_response = HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + }, + headers={"Content-Type": "application/json"}, + ) + endpoint = GetInboundEndpoint("test_project_id", request_data) + parsed = endpoint.handle_response(mock_binary_response) + + assert isinstance(parsed, MOBinaryMessage) + assert parsed.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.body == "SGVsbG8gV29ybGQ=" + assert parsed.udh == "050003010201" + assert parsed.type == "mo_binary" + + +def test_handle_response_expects_sms_exception_on_error(endpoint): + """Test that SmsException is raised when server returns an error.""" + error_response = HTTPResponse(status_code=404, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py b/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py new file mode 100644 index 00000000..c5c39ef6 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py @@ -0,0 +1,123 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ListInboundsEndpoint +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ListInboundsRequest +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def request_data(): + return ListInboundsRequest( + page=0, + page_size=2, + to=["+46709876543"], + client_reference="ref123", + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 1, + "page": 0, + "page_size": 2, + "inbounds": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Test inbound message", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListInboundsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/inbounds" + ) + + +def test_build_query_params_expects_all_params(endpoint): + query_params = endpoint.build_query_params() + + assert query_params["page"] == 0 + assert query_params["page_size"] == 2 + assert query_params["to"] == "+46709876543" + assert query_params["client_reference"] == "ref123" + + +def test_build_query_params_expects_excludes_none_values(): + """Test that None values are excluded from query parameters.""" + endpoint = ListInboundsEndpoint( + "test_project_id", ListInboundsRequest() + ) + query_params = endpoint.build_query_params() + + assert len(query_params) == 0 + assert "page" not in query_params + assert "page_size" not in query_params + assert "to" not in query_params + assert "start_date" not in query_params + assert "end_date" not in query_params + assert "client_reference" not in query_params + + +def test_build_query_params_expects_date_filters(): + """Test that date filters are included when provided.""" + request_data = ListInboundsRequest( + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + ) + endpoint = ListInboundsEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert "start_date" in query_params + assert "end_date" in query_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed = endpoint.handle_response(mock_response) + + assert isinstance(parsed, ListInboundsResponse) + assert parsed.count == 1 + assert parsed.page == 0 + assert parsed.page_size == 2 + assert parsed.inbounds is not None + assert len(parsed.inbounds) == 1 + + first_inbound = parsed.inbounds[0] + assert isinstance(first_inbound, MOTextMessage) + assert first_inbound.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first_inbound.from_ == "+46701234567" + assert first_inbound.body == "Test inbound message" + assert first_inbound.type == "mo_text" + + +def test_handle_response_expects_sms_exception_on_error(endpoint): + """Test that SmsException is raised when server returns an error.""" + error_response = HTTPResponse(status_code=404, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 404" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 404 diff --git a/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py new file mode 100644 index 00000000..a5980ddc --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py @@ -0,0 +1,18 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest + + +def test_inbound_id_request_expects_valid_inbound_id(): + """Test that the model correctly parses a valid inbound ID.""" + model = InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.inbound_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_inbound_id_request_expects_validation_error_for_missing_inbound_id(): + """Test that missing required inbound_id field raises a ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + InboundIdRequest() + + assert "inbound_id" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py new file mode 100644 index 00000000..0140fe8a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ListInboundsRequest + + +def test_list_inbounds_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListInboundsRequest() + + assert model.page is None + assert model.page_size is None + assert model.to is None + assert model.start_date is None + assert model.end_date is None + assert model.client_reference is None + + +def test_list_inbounds_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) + + model = ListInboundsRequest( + page=1, + page_size=50, + to=["+46701234567", "+46709876543"], + start_date=start, + end_date=end, + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.to == ["+46701234567", "+46709876543"] + assert model.start_date == start + assert model.end_date == end + assert model.client_reference == "my-client-ref" diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py new file mode 100644 index 00000000..4f606cd7 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py @@ -0,0 +1,64 @@ +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +def test_list_inbounds_response_empty_content_expects_empty_list(): + """Test that empty inbounds list returns empty content.""" + model = ListInboundsResponse(count=0, page=0, page_size=30, inbounds=None) + + assert model.count == 0 + assert model.page == 0 + assert model.page_size == 30 + assert model.content == [] + + +def test_list_inbounds_response_expects_correct_mapping(): + """Test that response is handled and mapped to the appropriate fields correctly.""" + data = { + "count": 2, + "page": 0, + "page_size": 2, + "inbounds": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Hello from test", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "ref-001", + }, + { + "id": "01FC66621XXXXX119Z8PMV1QPR", + "from": "+46701234568", + "to": "+46709876543", + "body": "Second message", + "type": "mo_text", + "received_at": "2024-06-06T09:25:00.000Z", + }, + ], + } + response = ListInboundsResponse(**data) + + assert response.count == 2 + assert response.page == 0 + assert response.page_size == 2 + + content = response.content + assert isinstance(content, list) + assert len(content) == 2 + + first = content[0] + assert isinstance(first, MOTextMessage) + assert first.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first.from_ == "+46701234567" + assert first.body == "Hello from test" + assert first.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert first.client_reference == "ref-001" + + second = content[1] + assert isinstance(second, MOTextMessage) + assert second.id == "01FC66621XXXXX119Z8PMV1QPR" + assert second.body == "Second message" + assert second.client_reference is None diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py new file mode 100644 index 00000000..3b26cfed --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py @@ -0,0 +1,90 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOBinaryMessage + + +@pytest.fixture +def sample_mo_binary_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + + +def test_mo_binary_message_expects_valid_input(sample_mo_binary_data): + """Test that the model correctly parses valid input.""" + msg = MOBinaryMessage(**sample_mo_binary_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.body == "SGVsbG8gV29ybGQ=" + assert msg.udh == "050003010201" + assert msg.type == "mo_binary" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_mo_binary_message_expects_optional_fields_none(sample_mo_binary_data): + """Test that optional fields default to None when not provided.""" + msg = MOBinaryMessage(**sample_mo_binary_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_binary_message_expects_optional_fields_populated(): + """Test that optional fields are parsed correctly when provided.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "my-client-ref", + "operator_id": "24001", + } + msg = MOBinaryMessage(**data) + + assert msg.client_reference == "my-client-ref" + assert msg.operator_id == "24001" + + +def test_mo_binary_message_expects_validation_error_for_missing_udh(): + """Test that missing required udh field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOBinaryMessage(**data) + + assert "udh" in str(exc_info.value) + + +def test_mo_binary_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOBinaryMessage(**data) + + assert "body" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py new file mode 100644 index 00000000..6e89f49a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOMediaBody, MOMediaItem, MOMediaMessage + + +@pytest.fixture +def sample_mo_media_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + "body": { + "subject": "MMS subject", + "message": "Hello media", + "media": [ + { + "url": "https://example.com/img.jpg", + "content_type": "image/jpeg", + "status": "Uploaded", + "code": 200, + } + ], + }, + } + + +def test_mo_media_message_expects_valid_input(sample_mo_media_data): + """Test that the model correctly parses valid input including nested body.""" + msg = MOMediaMessage(**sample_mo_media_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.type == "mo_media" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + assert isinstance(msg.body, MOMediaBody) + assert msg.body.subject == "MMS subject" + assert msg.body.message == "Hello media" + assert msg.body.media is not None + assert len(msg.body.media) == 1 + + item = msg.body.media[0] + assert isinstance(item, MOMediaItem) + assert item.url == "https://example.com/img.jpg" + assert item.content_type == "image/jpeg" + assert item.status == "Uploaded" + assert item.code == 200 + + +def test_mo_media_message_expects_body_with_no_media(): + """Test that body with no media attachment is valid.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + "body": { + "subject": "Hi", + "message": "Text only MMS", + }, + } + msg = MOMediaMessage(**data) + + assert msg.body.subject == "Hi" + assert msg.body.message == "Text only MMS" + assert msg.body.media is None + + +def test_mo_media_message_expects_optional_fields_none(sample_mo_media_data): + """Test that optional base fields default to None.""" + msg = MOMediaMessage(**sample_mo_media_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_media_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOMediaMessage(**data) + + assert "body" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py new file mode 100644 index 00000000..59dac69b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def sample_mo_text_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + + +def test_mo_text_message_expects_valid_input(sample_mo_text_data): + """Test that the model correctly parses valid input.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.body == "Hello world" + assert msg.type == "mo_text" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_mo_text_message_expects_optional_fields_none(sample_mo_text_data): + """Test that optional fields default to None when not provided.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_text_message_expects_from_alias(sample_mo_text_data): + """Test that the model accepts 'from' alias and exposes it as 'from_'.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.from_ == "+46701234567" + + +def test_mo_text_message_expects_optional_fields_populated(): + """Test that optional fields are parsed correctly when provided.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "my-client-ref", + "operator_id": "24001", + "sent_at": "2024-06-06T09:20:00.000Z", + } + msg = MOTextMessage(**data) + + assert msg.client_reference == "my-client-ref" + assert msg.operator_id == "24001" + assert msg.sent_at == datetime(2024, 6, 6, 9, 20, 0, tzinfo=timezone.utc) + + +def test_mo_text_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOTextMessage(**data) + + assert "body" in str(exc_info.value) + + +def test_mo_text_message_expects_validation_error_for_missing_id(): + """Test that missing required id field raises a ValidationError.""" + data = { + "from": "+46701234567", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOTextMessage(**data) + + assert "id" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/test_inbounds.py b/tests/unit/domains/sms/v1/test_inbounds.py new file mode 100644 index 00000000..97588e3b --- /dev/null +++ b/tests/unit/domains/sms/v1/test_inbounds.py @@ -0,0 +1,126 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ( + GetInboundEndpoint, + ListInboundsEndpoint, +) +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def mock_mo_text_response(): + """Sample MOTextMessage for testing.""" + return MOTextMessage( + id="01FC66621XXXXX119Z8PMV1QPQ", + from_="+46701234567", + to="+46709876543", + body="Test inbound message", + type="mo_text", + received_at=datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc), + ) + + +def test_inbounds_get_correct_request( + mock_sinch_client_sms, mocker, mock_mo_text_response +): + """Test that get sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = mock_mo_text_response + + spy_endpoint = mocker.spy(GetInboundEndpoint, "__init__") + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.get(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].inbound_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, MOTextMessage) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_inbounds_list_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and handles the response properly.""" + mock_response = ListInboundsResponse(count=1, page=0, page_size=2, inbounds=[]) + mock_sinch_client_sms.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ListInboundsEndpoint, "__init__") + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.list( + page=0, + page_size=2, + to=["+46709876543"], + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].page == 0 + assert kwargs["request_data"].page_size == 2 + assert kwargs["request_data"].to == ["+46709876543"] + assert kwargs["request_data"].start_date == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert kwargs["request_data"].end_date == datetime(2025, 1, 31, tzinfo=timezone.utc) + assert kwargs["request_data"].client_reference == "test_client_ref" + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_sms_endpoint_handle_response_raises_exception_on_error( + mock_sinch_client_sms, +): + """ + Test that SmsEndpoint.handle_response raises SmsException when status_code >= 400. + """ + request_data = InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + endpoint = GetInboundEndpoint("test_project_id", request_data) + + error_response = HTTPResponse(status_code=400, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 400" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 400 + + +def test_inbounds_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, mock_mo_text_response +): + """ + Test that SMS requests validate authentication and recalculate auth method + when credentials change after initialization. + """ + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + config.transport.request.return_value = mock_mo_text_response + config.sms_api_token = "test_sms_token" + + assert config.authentication_method == "project_auth" + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.get(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert config.authentication_method == "sms_auth" + assert isinstance(response, MOTextMessage) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ"