From d1149afdda8fe6ac8068a4d9e776948b09a3297c Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Tue, 2 Jun 2026 16:12:47 +0200 Subject: [PATCH 1/9] Squashed commit of the following: commit 1610082907db4f19a8a1f299cd08a08d13ffe8c9 Merge: bd98622 0375263 Author: Marcos Lozano Romero Date: Tue Jun 2 16:08:27 2026 +0200 Merge remote-tracking branch 'origin/main' into v2.1-next commit 0375263310533f24888b8a79ee4e8190799b85df Author: Marcos Lozano Romero Date: Tue Jun 2 14:51:39 2026 +0200 Update CHANGELOG for version 2.0.1 (#149) Add version 2.0.1 release notes including SMS paginator fix. commit eecdcd9ce44797a44a574f685c6eb4c942304420 Author: Marcos Lozano Romero Date: Tue Jun 2 14:37:43 2026 +0200 Bugfix/fix page size none in sms paginator (#144) (#145) * chore: update version to 2.0.1 * bugfix(sms): fix SmsPaginator extra call commit 81c868b48c2f58388e4c73ac4aeb6553e0da0cd9 Author: Marcos Lozano Romero Date: Wed May 27 17:31:22 2026 +0200 chore: update readme (#143) * chore: update readme --- .gitignore | 3 + CHANGELOG.md | 8 +++ README.md | 2 + pyproject.toml | 2 +- sinch/__init__.py | 2 +- sinch/core/pagination.py | 59 +++++++---------- tests/conftest.py | 120 +++++++++++++--------------------- tests/unit/test_pagination.py | 101 +++++++++++++++++++++++++++- 8 files changed, 185 insertions(+), 112 deletions(-) diff --git a/.gitignore b/.gitignore index c44c3ae7..3246a258 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,9 @@ poetry.lock # Pyenv .python-version +#CLAUDE +CLAUDE.local.md + # .DS_Store files .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c6d38c..ea401a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ All notable changes to the **Sinch Python SDK** are documented in this file. --- +## v2.0.1 – 2026-06-02 + +### SMS + +- **[fix]** SMS paginator fix (#145). + +--- + ## v2.0.0 – 2026-03-31 ### Breaking Changes diff --git a/README.md b/README.md index d8e54ef9..92811ffa 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ Supported regions: `us`, `eu`, `br`. In your [Account dashboard](https://dashboard.sinch.com/settings/access-keys), you will find your `projectId` and access keys composed of pairs of `keyId` / `keySecret`. +> **Note:** the `keySecret` is visible only when you create the Access Key. Store it safely and create a new Access Key if you have lost it. + ```python from sinch import SinchClient diff --git a/pyproject.toml b/pyproject.toml index 4e4533f9..be877f57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch" description = "Sinch SDK for Python programming language" -version = "2.0.0" +version = "2.1.0" license = "Apache 2.0" readme = "README.md" authors = [ diff --git a/sinch/__init__.py b/sinch/__init__.py index 2d00736e..14ccc3e0 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,5 +1,5 @@ """ Sinch Python SDK""" -__version__ = "2.0.0" +__version__ = "2.1.0" from sinch.core.clients.sinch_client_sync import SinchClient diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index abc17741..326a4c9c 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -1,29 +1,8 @@ from abc import ABC, abstractmethod -from typing import Generic +from typing import Generic, Iterator from sinch.core.types import BM -class PageIterator: - def __init__(self, paginator, yield_first_page=False): - self.paginator = paginator - # If yielding the first page, set started to False - self.started = not yield_first_page - - def __iter__(self): - return self - - def __next__(self): - if not self.started: - self.started = True - return self.paginator - - if self.paginator.has_next_page: - self.paginator = self.paginator.next_page() - return self.paginator - else: - raise StopIteration - - class Paginator(ABC, Generic[BM]): """ Pagination response object. @@ -45,7 +24,7 @@ def content(self): # TODO: Make iterator() method abstract in Parent class as we implement in the other domains: # - Refactor pydantic models in other domains to have a content property. - def iterator(self): + def iterator(self) -> Iterator[BM]: pass @abstractmethod @@ -96,19 +75,29 @@ def iterator(self): paginator = next_page_instance def _calculate_next_page(self): - """Calculates if there's a next page based on count, page, and page_size.""" - if hasattr(self.result, 'count') and hasattr(self.result, 'page'): - # Use the requested page_size from the endpoint - request_page_size = self.endpoint.request_data.page_size or 1 - if request_page_size > 0 and hasattr(self.result, 'page_size'): - # Calculate total pages needed using the request page_size - total_pages = (self.result.count + request_page_size - 1) // request_page_size - # Check if current page is less than total pages - 1 (0-indexed) - self.has_next_page = self.result.page < (total_pages - 1) - else: - self.has_next_page = False - else: + """Calculates if there's a next page based on count, page, and effective page_size.""" + count = getattr(self.result, 'count', None) + page = getattr(self.result, 'page', None) + page_size = getattr(self.result, 'page_size', None) + + if count is None or page is None or page_size is None: + self.has_next_page = False + return + + if not self.content(): self.has_next_page = False + return + + # Cache first response page_size when not provided in order to calculate next pages correctly + request_page_size = self.endpoint.request_data.page_size + if request_page_size is None: + if not hasattr(self, '_first_response_page_size'): + self._first_response_page_size = page_size + request_page_size = self._first_response_page_size + + total_pages = (count + request_page_size - 1) // request_page_size + self.has_next_page = page < (total_pages - 1) + @classmethod def _initialize(cls, sinch, endpoint): diff --git a/tests/conftest.py b/tests/conftest.py index d879c2d8..321893a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,19 +2,14 @@ import os from dataclasses import dataclass from unittest.mock import Mock, MagicMock -from sinch.domains.sms.models.v1.internal import ( - ListDeliveryReportsRequest, - ListDeliveryReportsResponse, -) import pytest - +from typing import Optional from sinch import SinchClient -from sinch.core.models.base_model import SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.v1.authentication import OAuthToken -from sinch.domains.numbers.models.v1.response import ActiveNumber - +from pydantic import BaseModel +from pydantic import Field, StrictInt def parse_iso_datetime(iso_string): """ @@ -27,14 +22,14 @@ def parse_iso_datetime(iso_string): return datetime.fromisoformat(iso_string) -@dataclass -class IntBasedPaginationRequest(SinchRequestBaseModel): - page: int - page_size: int = 0 +class SMSBasePaginationRequest(BaseModel): + page: Optional[StrictInt] = Field( + default=None) + page_size: Optional[StrictInt] = Field( + default=None) -@dataclass -class TokenBasedPaginationRequest(SinchRequestBaseModel): +class TokenBasedPaginationRequest(BaseModel): page_size: int page_token: str = None @@ -150,20 +145,15 @@ def token_based_pagination_request_data(): @pytest.fixture def sms_pagination_request_data(): - return ListDeliveryReportsRequest( + return SMSBasePaginationRequest( page=0, page_size=2 ) - @pytest.fixture -def third_int_based_pagination_response(): - return ListDeliveryReportsResponse( - count=4, - page=2, - page_size=2, - delivery_reports=[] - ) +def sms_pagination_request_data_with_page_and_page_size_none(): + return SMSBasePaginationRequest() + @pytest.fixture @@ -266,14 +256,24 @@ def mock_sinch_client_conversation(): @pytest.fixture def mock_pagination_active_number_responses(): return [ - Mock(content=[ActiveNumber(phone_number="+12345678901"), - ActiveNumber(phone_number="+12345678902")], - next_page_token="token_1"), - Mock(content=[ActiveNumber(phone_number="+12345678903"), - ActiveNumber(phone_number="+12345678904")], - next_page_token="token_2"), - Mock(content=[ActiveNumber(phone_number="+12345678905")], - next_page_token=None) + Mock( + content=[ + Mock(phone_number="+12345678901"), + Mock(phone_number="+12345678902"), + ], + next_page_token="token_1", + ), + Mock( + content=[ + Mock(phone_number="+12345678903"), + Mock(phone_number="+12345678904"), + ], + next_page_token="token_2", + ), + Mock( + content=[Mock(phone_number="+12345678905")], + next_page_token=None, + ), ] @@ -286,50 +286,22 @@ def mock_pagination_expected_phone_numbers_response(): @pytest.fixture def mock_sms_pagination_responses(): - from datetime import datetime - from sinch.domains.sms.models.v1.response import RecipientDeliveryReport - return [ - Mock(content=[ - RecipientDeliveryReport( - at=parse_iso_datetime("2025-10-19T16:45:31.935Z"), - batch_id="01K7YNS82JMYGAKAATHFP0QTB5", - code=400, - recipient="12346836075", - status="DELIVERED", - type="recipient_delivery_report_sms" - ), - RecipientDeliveryReport( - at=parse_iso_datetime("2025-10-19T16:40:26.855Z"), - batch_id="01K7YNFY30DS2KKVQZVBFANHMR", - code=400, - recipient="12346836075", - status="DELIVERED", - type="recipient_delivery_report_sms" - ) - ], - count=4, page=0, page_size=2), - Mock(content=[ - RecipientDeliveryReport( - at=parse_iso_datetime("2025-10-19T16:35:15.123Z"), - batch_id="01K7YNGZ45XW8KKPQRSTUVWXYZ", - code=401, - recipient="34683607595", - status="DISPATCHED", - type="recipient_delivery_report_sms" - ), - RecipientDeliveryReport( - at=parse_iso_datetime("2025-10-19T16:30:10.456Z"), - batch_id="01K7YNHM67YZ3LMNOPQRSTUVWX", - code=402, - recipient="34683607596", - status="FAILED", - type="recipient_delivery_report_sms" - ) - ], - count=4, page=1, page_size=2), - Mock(content=[], - count=4, page=2, page_size=2) + Mock( + content=[ + Mock(batch_id="01K7YNS82JMYGAKAATHFP0QTB5"), + Mock(batch_id="01K7YNFY30DS2KKVQZVBFANHMR"), + ], + count=4, page=0, page_size=2, + ), + Mock( + content=[ + Mock(batch_id="01K7YNGZ45XW8KKPQRSTUVWXYZ"), + Mock(batch_id="01K7YNHM67YZ3LMNOPQRSTUVWX"), + ], + count=4, page=1, page_size=2, + ), + Mock(content=[], count=4, page=2, page_size=0), ] diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 2dfa938b..10a795cd 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -4,6 +4,7 @@ SMSPaginator, TokenBasedPaginator ) +from tests.conftest import SMSBasePaginationRequest # Helper function to initialize SMS paginator @@ -12,7 +13,7 @@ def initialize_sms_paginator(endpoint_mock, request_data, responses): # Create a mock that returns different responses based on page number def mock_request(endpoint): - page = endpoint.request_data.page + page = endpoint.request_data.page or 0 if page == 0: return responses[0] elif page == 1: @@ -25,6 +26,104 @@ def mock_request(endpoint): return SMSPaginator(sinch=client, endpoint=endpoint_mock) +def test_page_size_is_zero(): + request_data = SMSBasePaginationRequest(page=0) + response = Mock(count=0, page=0, page_size=0, content=[]) + client = Mock() + client.configuration.transport.request.return_value = response + endpoint = Mock(request_data=request_data) + + paginator = SMSPaginator(sinch=client, endpoint=endpoint) + + assert paginator.has_next_page is False + +def test_response_without_page_size(): + request_data = SMSBasePaginationRequest(page=0) + response = Mock(count=1, page=0, page_size=None, content=[Mock()]) + client = Mock() + client.configuration.transport.request.return_value = response + endpoint = Mock(request_data=request_data) + + paginator = SMSPaginator(sinch=client, endpoint=endpoint) + + assert paginator.has_next_page is False + + +def test_partial_last_page_does_not_trigger_extra_call(): + """Regression: when the last page is partial (response.page_size smaller than + the first response's page_size), the paginator must not request a further + empty page after the last real one.""" + request_data = SMSBasePaginationRequest() + responses_by_page = { + None: Mock(content=[Mock()] * 30, count=49, page=0, page_size=30), + 1: Mock(content=[Mock()] * 19, count=49, page=1, page_size=19), + } + client = Mock() + client.configuration.transport.request.side_effect = ( + lambda ep: responses_by_page[ep.request_data.page] + ) + endpoint = Mock(request_data=request_data) + + paginator = SMSPaginator(sinch=client, endpoint=endpoint) + list(paginator.iterator()) + + assert client.configuration.transport.request.call_count == 2 + +def test_stop_on_first_page(): + """Regression: when the first page is already the last one, the paginator must not make an extra call.""" + request_data = SMSBasePaginationRequest() + responses_by_page = { + None: Mock(content=[Mock()] * 15, count=15, page=0, page_size=15), + } + client = Mock() + client.configuration.transport.request.side_effect = ( + lambda ep: responses_by_page[ep.request_data.page] + ) + endpoint = Mock(request_data=request_data) + + paginator = SMSPaginator(sinch=client, endpoint=endpoint) + list(paginator.iterator()) + + assert client.configuration.transport.request.call_count == 1 + + +def test_explicit_page_size_with_mid_stream_start_stops_in_one_call(): + """When page_size is passed explicitly and (page+1)*page_size >= count, the + paginator must stop without making an extra empty call.""" + request_data = SMSBasePaginationRequest(page=1, page_size=30) + response = Mock(content=[Mock()] * 19, count=49, page=1, page_size=19) + client = Mock() + client.configuration.transport.request.return_value = response + endpoint = Mock(request_data=request_data) + + paginator = SMSPaginator(sinch=client, endpoint=endpoint) + + assert paginator.has_next_page is False + assert client.configuration.transport.request.call_count == 1 + + +def test_mid_stream_without_page_size_makes_one_extra_call(): + """Known edge case: starting mid-stream without an explicit page_size can't + distinguish a partial last page from a full small page, so the paginator + makes one extra (empty) request before stopping. This test documents the + inevitable behavior so future changes don't accidentally break it.""" + request_data = SMSBasePaginationRequest(page=1) + responses_by_page = { + 1: Mock(content=[Mock()] * 19, count=49, page=1, page_size=19), + 2: Mock(content=[], count=49, page=2, page_size=0), + } + client = Mock() + client.configuration.transport.request.side_effect = ( + lambda ep: responses_by_page[ep.request_data.page] + ) + endpoint = Mock(request_data=request_data) + + paginator = SMSPaginator(sinch=client, endpoint=endpoint) + list(paginator.iterator()) + + assert client.configuration.transport.request.call_count == 2 + + def test_page_sms_iterator_sync_using_manual_pagination( sms_pagination_request_data, From 34f654568d1f63e077cd143e073944074302e3fd Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Tue, 2 Jun 2026 17:08:09 +0200 Subject: [PATCH 2/9] feat(sms) implement get and list methods for inbounds --- .gitignore | 3 +- examples/snippets/sms/inbounds/get/snippet.py | 29 +++++ .../snippets/sms/inbounds/list/snippet.py | 28 +++++ sinch/domains/sms/api/v1/inbounds_apis.py | 108 ++++++++++++++++++ .../sms/api/v1/internal/inbounds_endpoints.py | 54 +++++++++ .../models/v1/internal/inbound_id_request.py | 14 +++ .../v1/internal/list_inbounds_request.py | 13 +++ .../v1/internal/list_inbounds_response.py | 15 +++ .../domains/sms/models/v1/shared/__init__.py | 12 ++ .../sms/models/v1/shared/base_mo_message.py | 41 +++++++ .../sms/models/v1/shared/mo_binary_message.py | 15 +++ .../sms/models/v1/shared/mo_media_body.py | 18 +++ .../sms/models/v1/shared/mo_media_item.py | 16 +++ .../sms/models/v1/shared/mo_media_message.py | 14 +++ .../sms/models/v1/shared/mo_text_message.py | 13 +++ .../sms/models/v1/types/inbound_message.py | 9 ++ .../sms/sinch_events/v1/events/__init__.py | 15 +-- .../sinch_events/v1/events/sms_sinch_event.py | 104 ++--------------- .../sms/sinch_events/v1/internal/__init__.py | 5 - .../sinch_events/v1/internal/sinch_event.py | 7 -- .../sms/sinch_events/v1/sms_sinch_event.py | 25 ++-- sinch/domains/sms/sms.py | 2 + .../e2e/sms/features/steps/webhooks.steps.py | 6 +- 23 files changed, 427 insertions(+), 139 deletions(-) create mode 100644 examples/snippets/sms/inbounds/get/snippet.py create mode 100644 examples/snippets/sms/inbounds/list/snippet.py create mode 100644 sinch/domains/sms/api/v1/inbounds_apis.py create mode 100644 sinch/domains/sms/api/v1/internal/inbounds_endpoints.py create mode 100644 sinch/domains/sms/models/v1/internal/inbound_id_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_inbounds_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_inbounds_response.py create mode 100644 sinch/domains/sms/models/v1/shared/base_mo_message.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_binary_message.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_media_body.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_media_item.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_media_message.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_text_message.py create mode 100644 sinch/domains/sms/models/v1/types/inbound_message.py delete mode 100644 sinch/domains/sms/sinch_events/v1/internal/__init__.py delete mode 100644 sinch/domains/sms/sinch_events/v1/internal/sinch_event.py diff --git a/.gitignore b/.gitignore index 3246a258..6542ef59 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +lcov.info # E2E features *.feature @@ -141,7 +142,7 @@ poetry.lock # Pyenv .python-version -#CLAUDE +# Claude CLAUDE.local.md # .DS_Store files 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..472a4dc6 --- /dev/null +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -0,0 +1,28 @@ +""" +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 +from sinch.core.pagination import Paginator +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + +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: Paginator[InboundMessage] = sinch_client.sms.inbound_messages.list(to=["+1234567890"]) + +for message in inbound_messages: + print(f"Inbound message:\n{message}") \ No newline at end of file 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..c3e57558 --- /dev/null +++ b/sinch/domains/sms/api/v1/inbounds_apis.py @@ -0,0 +1,108 @@ + + + + + +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..4a2ff6c0 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -0,0 +1,54 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +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/{service_plan_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 self.request_data.model_dump(exclude_none=True, by_alias=True) + + 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) \ No newline at end of file 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..8d60e434 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/inbound_id_request.py @@ -0,0 +1,14 @@ + + +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.", + ) \ No newline at end of file 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..69f40330 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py @@ -0,0 +1,13 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, 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..efc91795 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py @@ -0,0 +1,15 @@ +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.") diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 5139c795..0f17acd6 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -11,6 +11,12 @@ ) 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__ = [ "BinaryRequest", @@ -22,4 +28,10 @@ "MessageDeliveryStatus", "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..2ccb7189 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/base_mo_message.py @@ -0,0 +1,41 @@ +from datetime import datetime +from typing import Optional, Union + +from pydantic import Field, StrictStr, field_validator + +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( + normalize_iso_timestamp, +) +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..d3d13c25 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_body.py @@ -0,0 +1,18 @@ +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..7f0a0b79 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_item.py @@ -0,0 +1,16 @@ +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..9f11c5b1 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/inbound_message.py @@ -0,0 +1,9 @@ +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..b9ef6d48 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,17 @@ -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 Annotated, Union +from pydantic import Field -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") +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage +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 c312c2de..2380ecba 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.sinch_events.v1.sms_sinch_event import SmsSinchEvent @@ -16,6 +17,7 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + self.inbound_messages = Inbounds(self._sinch) def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: """ 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' From 8838497f1ba376a5d287c154f937790a8a1d7205 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 3 Jun 2026 10:25:26 +0200 Subject: [PATCH 3/9] feat(sms) fix ruff imports not used --- .../sms/models/v1/internal/list_inbounds_request.py | 2 +- sinch/domains/sms/models/v1/shared/base_mo_message.py | 7 ++----- .../domains/sms/sinch_events/v1/events/sms_sinch_event.py | 4 +--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_request.py b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py index 69f40330..75007587 100644 --- a/sinch/domains/sms/models/v1/internal/list_inbounds_request.py +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py @@ -1,6 +1,6 @@ from typing import Optional from datetime import datetime -from pydantic import Field, StrictInt, StrictStr, conlist +from pydantic import StrictInt, StrictStr, conlist from sinch.domains.sms.models.v1.internal.base import BaseModelConfigurationRequest diff --git a/sinch/domains/sms/models/v1/shared/base_mo_message.py b/sinch/domains/sms/models/v1/shared/base_mo_message.py index 2ccb7189..63531016 100644 --- a/sinch/domains/sms/models/v1/shared/base_mo_message.py +++ b/sinch/domains/sms/models/v1/shared/base_mo_message.py @@ -1,11 +1,8 @@ from datetime import datetime -from typing import Optional, Union +from typing import Optional -from pydantic import Field, StrictStr, field_validator +from pydantic import Field, StrictStr -from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( - normalize_iso_timestamp, -) from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) 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 b9ef6d48..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,6 +1,4 @@ -from typing import Annotated, Union - -from pydantic import Field +from typing import Union from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, From 4f2ee2f6c47fdb43be925553ba44c99258bae257 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 3 Jun 2026 10:28:48 +0200 Subject: [PATCH 4/9] feat(sms) fix ruff imports not used --- sinch/domains/sms/api/v1/inbounds_apis.py | 56 +++++++------------ .../sms/api/v1/internal/inbounds_endpoints.py | 17 ++++-- .../models/v1/internal/inbound_id_request.py | 4 +- .../v1/internal/list_inbounds_request.py | 4 +- .../v1/internal/list_inbounds_response.py | 19 +++++-- .../domains/sms/models/v1/shared/__init__.py | 4 +- .../sms/models/v1/shared/base_mo_message.py | 1 - .../sms/models/v1/shared/mo_media_body.py | 6 +- .../sms/models/v1/shared/mo_media_item.py | 4 +- .../sms/models/v1/types/inbound_message.py | 4 +- 10 files changed, 65 insertions(+), 54 deletions(-) diff --git a/sinch/domains/sms/api/v1/inbounds_apis.py b/sinch/domains/sms/api/v1/inbounds_apis.py index c3e57558..0d141ed6 100644 --- a/sinch/domains/sms/api/v1/inbounds_apis.py +++ b/sinch/domains/sms/api/v1/inbounds_apis.py @@ -1,8 +1,3 @@ - - - - - from datetime import datetime from typing import List, Optional @@ -22,12 +17,7 @@ class Inbounds(BaseSms): - - def get( - self, - inbound_id: str, - **kwargs - ) -> InboundMessage: + def get(self, inbound_id: str, **kwargs) -> InboundMessage: """ This operation retrieves a specific inbound message using the provided inbound ID. @@ -41,12 +31,9 @@ def get( For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ - request_data = InboundIdRequest( - inbound_id=inbound_id, - **kwargs - ) + request_data = InboundIdRequest(inbound_id=inbound_id, **kwargs) return self._request(GetInboundEndpoint, request_data) - + def list( self, page: Optional[int] = None, @@ -55,7 +42,7 @@ def list( start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, client_reference: Optional[str] = None, - **kwargs + **kwargs, ) -> Paginator[InboundMessage]: """ With the list operation, @@ -65,20 +52,20 @@ def list( :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. + :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 + :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. + :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. @@ -92,17 +79,16 @@ def list( 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 + 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 index 4a2ff6c0..80271b51 100644 --- a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -28,10 +28,14 @@ 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) + 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/{service_plan_id}/inbounds" HTTP_METHOD = HTTPMethods.GET.value @@ -49,6 +53,9 @@ 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) \ No newline at end of file + 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 index 8d60e434..050c86de 100644 --- a/sinch/domains/sms/models/v1/internal/inbound_id_request.py +++ b/sinch/domains/sms/models/v1/internal/inbound_id_request.py @@ -1,5 +1,3 @@ - - from pydantic import Field from sinch.domains.sms.models.v1.internal.base import ( @@ -11,4 +9,4 @@ class InboundIdRequest(BaseModelConfigurationRequest): inbound_id: str = Field( default=..., description="The unique identifier of the inbound message.", - ) \ No newline at end of file + ) diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_request.py b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py index 75007587..409f3062 100644 --- a/sinch/domains/sms/models/v1/internal/list_inbounds_request.py +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py @@ -1,7 +1,9 @@ from typing import Optional from datetime import datetime from pydantic import StrictInt, StrictStr, conlist -from sinch.domains.sms.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class ListInboundsRequest(BaseModelConfigurationRequest): diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py index efc91795..73168979 100644 --- a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py @@ -9,7 +9,18 @@ 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.") + 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.", + ) diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 0f17acd6..38cec2f8 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -13,7 +13,9 @@ 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_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 diff --git a/sinch/domains/sms/models/v1/shared/base_mo_message.py b/sinch/domains/sms/models/v1/shared/base_mo_message.py index 63531016..7b5d6252 100644 --- a/sinch/domains/sms/models/v1/shared/base_mo_message.py +++ b/sinch/domains/sms/models/v1/shared/base_mo_message.py @@ -35,4 +35,3 @@ class BaseMOMessage(BaseModelConfigurationResponse): 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_media_body.py b/sinch/domains/sms/models/v1/shared/mo_media_body.py index d3d13c25..6e35ccbc 100644 --- a/sinch/domains/sms/models/v1/shared/mo_media_body.py +++ b/sinch/domains/sms/models/v1/shared/mo_media_body.py @@ -11,8 +11,10 @@ class MOMediaBody(BaseModelConfigurationResponse): 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." + 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." + 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 index 7f0a0b79..47d72547 100644 --- a/sinch/domains/sms/models/v1/shared/mo_media_item.py +++ b/sinch/domains/sms/models/v1/shared/mo_media_item.py @@ -6,7 +6,9 @@ class MOMediaItem(BaseModelConfigurationResponse): - url: Optional[StrictStr] = Field(default=None, description="URL to the media file.") + url: Optional[StrictStr] = Field( + default=None, description="URL to the media file." + ) content_type: StrictStr = Field( ..., description="Content type of the media file." ) diff --git a/sinch/domains/sms/models/v1/types/inbound_message.py b/sinch/domains/sms/models/v1/types/inbound_message.py index 9f11c5b1..c2f511f2 100644 --- a/sinch/domains/sms/models/v1/types/inbound_message.py +++ b/sinch/domains/sms/models/v1/types/inbound_message.py @@ -1,7 +1,9 @@ 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_binary_message import ( + MOBinaryMessage, +) from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage _InboundMessageUnion = Union[MOTextMessage, MOBinaryMessage, MOMediaMessage] From 9859dbcec96103a98bd5483b31888c9b3ab0bbb8 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 3 Jun 2026 10:36:03 +0200 Subject: [PATCH 5/9] feat(sms) fix list snippet --- examples/snippets/sms/inbounds/list/snippet.py | 2 +- sinch/domains/sms/api/v1/internal/inbounds_endpoints.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/snippets/sms/inbounds/list/snippet.py b/examples/snippets/sms/inbounds/list/snippet.py index 472a4dc6..8c9f4dbd 100644 --- a/examples/snippets/sms/inbounds/list/snippet.py +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -24,5 +24,5 @@ inbound_messages: Paginator[InboundMessage] = sinch_client.sms.inbound_messages.list(to=["+1234567890"]) -for message in inbound_messages: +for message in inbound_messages.iterator(): print(f"Inbound message:\n{message}") \ No newline at end of file diff --git a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py index 80271b51..e7e1f629 100644 --- a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -37,7 +37,7 @@ def handle_response(self, response: HTTPResponse) -> InboundMessage: class ListInboundsEndpoint(SmsEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{service_plan_id}/inbounds" + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value From 708d6e83985f5c30343d0f00e317c39d2c9bbbe8 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 4 Jun 2026 12:10:38 +0200 Subject: [PATCH 6/9] test(sms) add inbounds unit and e2e tests --- .github/workflows/ci.yml | 2 + .../snippets/sms/inbounds/list/snippet.py | 7 +- .../sms/api/v1/internal/inbounds_endpoints.py | 3 +- .../v1/internal/list_inbounds_response.py | 5 + .../e2e/sms/features/steps/inbounds.steps.py | 94 +++++++++++++ .../inbounds/test_get_inbound_endpoint.py | 89 +++++++++++++ .../inbounds/test_list_inbounds_endpoint.py | 123 +++++++++++++++++ .../internal/test_inbound_id_request_model.py | 18 +++ .../test_list_inbounds_request_model.py | 36 +++++ .../test_list_inbounds_response_model.py | 64 +++++++++ .../response/test_mo_binary_message_model.py | 90 +++++++++++++ .../response/test_mo_media_message_model.py | 95 +++++++++++++ .../response/test_mo_text_message_model.py | 94 +++++++++++++ tests/unit/domains/sms/v1/test_inbounds.py | 126 ++++++++++++++++++ 14 files changed, 841 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/sms/features/steps/inbounds.steps.py create mode 100644 tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py create mode 100644 tests/unit/domains/sms/v1/test_inbounds.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0534969d..4af33568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,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/examples/snippets/sms/inbounds/list/snippet.py b/examples/snippets/sms/inbounds/list/snippet.py index 8c9f4dbd..ca846fa8 100644 --- a/examples/snippets/sms/inbounds/list/snippet.py +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -9,8 +9,6 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.core.pagination import Paginator -from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage load_dotenv() @@ -22,7 +20,8 @@ ) -inbound_messages: Paginator[InboundMessage] = sinch_client.sms.inbound_messages.list(to=["+1234567890"]) +inbound_messages = sinch_client.sms.inbound_messages.list(to=["+1234567890"]) +print("List of inbound messages:\n") for message in inbound_messages.iterator(): - print(f"Inbound message:\n{message}") \ No newline at end of file + print(message) \ No newline at end of file diff --git a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py index e7e1f629..f2037bb3 100644 --- a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -1,5 +1,6 @@ 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 ( @@ -47,7 +48,7 @@ def __init__(self, project_id: str, request_data: ListInboundsRequest): self.request_data = request_data def build_query_params(self) -> dict: - return self.request_data.model_dump(exclude_none=True, by_alias=True) + return model_dump_for_query_params(self.request_data) def handle_response(self, response: HTTPResponse) -> ListInboundsResponse: try: diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py index 73168979..61bb3bc1 100644 --- a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py @@ -24,3 +24,8 @@ class ListInboundsResponse(BaseModelConfigurationResponse): 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/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/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" From e0bd2bf33b7121e98eb2ee06480792660d3f0c52 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 08:41:54 +0200 Subject: [PATCH 7/9] feat(sms) add inbounds in __init__ --- sinch/domains/sms/api/v1/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index db903927..91f47a01 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,7 +1,9 @@ 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 __all__ = [ "Batches", "DeliveryReports", + "Inbounds", ] From aee8a9ff3541fc319dfbd5ddd1f20b393dc96c7b Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 15:13:16 +0200 Subject: [PATCH 8/9] feat(sms) update changelog and migration guide for SMS Inbounds API --- CHANGELOG.md | 9 +++++++++ MIGRATION_GUIDE.md | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea401a01..09d26f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ All notable changes to the **Sinch Python SDK** are documented in this file. --- +## v2.1.0 – 2026-06-05 + +### SMS + +- **[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)). + +--- + ## v2.0.1 – 2026-06-02 ### SMS diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 19148549..28e0a5f1 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.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. +The SMS domain API access remains the same: `sinch.sms.batches`, `sinch.sms.delivery_reports`, and `sinch.sms.inbounds`. However, the underlying models and method signatures have changed. ##### Batches API @@ -230,6 +228,38 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and | `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | | `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | +##### 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) From 7fb6e8714c4a00e755110adf011f33958758e14a Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 15:26:02 +0200 Subject: [PATCH 9/9] fix(sms): remove hardcoded recipient in list snippet --- examples/snippets/sms/inbounds/list/snippet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/snippets/sms/inbounds/list/snippet.py b/examples/snippets/sms/inbounds/list/snippet.py index ca846fa8..00108d5b 100644 --- a/examples/snippets/sms/inbounds/list/snippet.py +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -20,7 +20,7 @@ ) -inbound_messages = sinch_client.sms.inbound_messages.list(to=["+1234567890"]) +inbound_messages = sinch_client.sms.inbound_messages.list() print("List of inbound messages:\n") for message in inbound_messages.iterator():