From 0995a1e7d357468acd0ef881c93be82805944c33 Mon Sep 17 00:00:00 2001 From: marcos-sinch Date: Fri, 22 May 2026 15:14:21 +0200 Subject: [PATCH 01/10] chore: update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7d72bfc8..744b11e7 100644 --- a/README.md +++ b/README.md @@ -136,14 +136,14 @@ For handling all possible exceptions thrown by this SDK use `SinchException` (su By default, the HTTP implementation uses the `requests` library. -To use a custom HTTP client, inject your own transport during initialization: +To use a custom HTTP client, assign your transport to the client's configuration after initialization: ```python sinch_client = SinchClient( key_id="key_id", key_secret="key_secret", project_id="some_project", - transport=MyHTTPImplementation ) +sinch_client.configuration.transport = MyHTTPImplementation(sinch_client) ``` Custom client has to obey types and methods described by `HTTPTransport` abstract base class: From 1e797a386298de7ad1d59df38929dc8a1aecb744 Mon Sep 17 00:00:00 2001 From: marcos-sinch Date: Mon, 25 May 2026 12:07:50 +0200 Subject: [PATCH 02/10] chore: update README --- README.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 744b11e7..21d2131e 100644 --- a/README.md +++ b/README.md @@ -48,40 +48,46 @@ The Sinch client provides access to the following Sinch products: ### Client initialization - To establish a connection with the Sinch backend, you must provide the appropriate credentials based on the API you intend to use. For security best practices, avoid hardcoding credentials. Instead, retrieve them from environment variables. -#### SMS API -For the SMS API in **Australia (AU)**, **Brazil (BR)**, **Canada (CA)**, **the United States (US)**, -and **the European Union (EU)**, provide the following parameters: +#### Project auth + +The standard authentication method for all Sinch APIs. +If you plan to use more than one API, or if you need access to the Conversation API, use this method. + +When using the SMS API, also pass `sms_region`. When using the Conversation API, also pass `conversation_region`. +Both are required for their respective APIs and have no default value. ```python from sinch import SinchClient sinch_client = SinchClient( - service_plan_id="service_plan_id", - sms_api_token="api_token" + project_id="project_id", + key_id="key_id", + key_secret="key_secret", + sms_region="us", # required if using the SMS API + conversation_region="eu", # required if using the Conversation API ) ``` -#### All Other Sinch APIs -For all other Sinch APIs, including SMS in US and EU regions, use the following parameters: +#### SMS token auth + +An alternative authentication method exclusive to the SMS API. ```python from sinch import SinchClient sinch_client = SinchClient( - project_id="project_id", - key_id="key_id", - key_secret="key_secret" + service_plan_id="service_plan_id", + sms_api_token="api_token", + sms_region="us", ) ``` -### SMS and Conversation regions (V2) - -You must set `sms_region` before using the SMS API and `conversation_region` before using the Conversation API—either in the `SinchClient(...)` constructor or on `sinch_client.configuration` before the first call to that product. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for examples. +> **Note:** `sms_region` and `conversation_region` no longer have defaults and **must** be set before +> calling those APIs—omitting them will cause a runtime error. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for details. ## Logging From 2a3ad230fa5d9790c832236fbbd37c266d48b54b Mon Sep 17 00:00:00 2001 From: marcos-sinch Date: Mon, 25 May 2026 17:18:49 +0200 Subject: [PATCH 03/10] chore: update README --- README.md | 125 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 21d2131e..9d3143c0 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,21 @@ The Sinch client provides access to the following Sinch products: ### Client initialization -To establish a connection with the Sinch backend, you must provide the appropriate credentials based on the API -you intend to use. For security best practices, avoid hardcoding credentials. -Instead, retrieve them from environment variables. +To establish a connection with the Sinch backend, you must provide credentials based on the API you intend to use. +For security best practices, avoid hardcoding credentials — retrieve them from environment variables instead. + +> **Note:** `sms_region` and `conversation_region` no longer have defaults and **must** be set before +> calling those APIs—omitting them will cause a runtime error. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for details. -#### Project auth -The standard authentication method for all Sinch APIs. -If you plan to use more than one API, or if you need access to the Conversation API, use this method. +#### SMS API -When using the SMS API, also pass `sms_region`. When using the Conversation API, also pass `conversation_region`. -Both are required for their respective APIs and have no default value. +The SMS API supports two authentication methods. `sms_region` is required for both and has no default. + +**Project auth (OAuth2)** + +The SDK automatically exchanges your key ID and key secret for a short-lived OAuth2 token and refreshes it automatically on expiry. +Supported regions: `us`, `eu`, `br`. ```python from sinch import SinchClient @@ -67,14 +71,14 @@ sinch_client = SinchClient( project_id="project_id", key_id="key_id", key_secret="key_secret", - sms_region="us", # required if using the SMS API - conversation_region="eu", # required if using the Conversation API + sms_region="us" ) ``` -#### SMS token auth +**Service Plan ID auth (legacy)** -An alternative authentication method exclusive to the SMS API. +Uses a static bearer token that never expires. +Support all regions: `us`, `eu`, `br`, `ca`, `au`. ```python from sinch import SinchClient @@ -82,12 +86,43 @@ from sinch import SinchClient sinch_client = SinchClient( service_plan_id="service_plan_id", sms_api_token="api_token", - sms_region="us", + sms_region="us" ) ``` -> **Note:** `sms_region` and `conversation_region` no longer have defaults and **must** be set before -> calling those APIs—omitting them will cause a runtime error. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for details. +#### Conversation API - Project auth (OAuth2) + +`conversation_region` is required and has no default. +Supported regions: `us`, `eu`, `br`. + +> **Why region matters:** The Conversation API stores and routes data within the selected region for regulatory compliance. Choose the region that matches your data residency requirements. + +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="project_id", + key_id="key_id", + key_secret="key_secret", + conversation_region="eu" +) +``` + +> **SMS integration note:** If you also use the SMS API, `sms_region` and `conversation_region` **must match**. Mismatched regions will cause delivery failures. + +#### Other APIs - Project auth (OAuth2) + +These APIs are not regionalized and use project-based auth. + +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="project_id", + key_id="key_id", + key_secret="key_secret", +) +``` ## Logging @@ -142,22 +177,62 @@ For handling all possible exceptions thrown by this SDK use `SinchException` (su By default, the HTTP implementation uses the `requests` library. -To use a custom HTTP client, assign your transport to the client's configuration after initialization: +To use a custom HTTP client, assign your transport to the client's configuration after initialization. + +Custom transports must extend `HTTPTransport` and implement the `send` method. The base class provides `prepare_request` and `authenticate` helpers, and handles OAuth token refresh automatically. + +The following example replaces the default `requests` backend with `httpx` and routes traffic through an authenticated proxy: + ```python +import httpx +from sinch import SinchClient +from sinch.core.ports.http_transport import HTTPTransport +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.models.http_response import HTTPResponse + + +class MyHTTPImplementation(HTTPTransport): + def __init__(self, sinch, proxy_url, proxy_user, proxy_password): + super().__init__(sinch) + self.http_client = httpx.Client( + proxy=f"http://{proxy_user}:{proxy_password}@{proxy_url}" + ) + + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: + request_data = self.prepare_request(endpoint) + request_data = self.authenticate(endpoint, request_data) + + body = request_data.request_body + response = self.http_client.request( + method=request_data.http_method, + url=request_data.url, + json=body if isinstance(body, dict) else None, + content=body if not isinstance(body, dict) else None, + auth=request_data.auth, + headers=request_data.headers, + params=request_data.query_params, + timeout=self.sinch.configuration.connection_timeout, + ) + response_body = self.deserialize_json_response(response) + + return HTTPResponse( + status_code=response.status_code, + body=response_body, + headers=dict(response.headers), + ) + + sinch_client = SinchClient( key_id="key_id", key_secret="key_secret", project_id="some_project", ) -sinch_client.configuration.transport = MyHTTPImplementation(sinch_client) -``` - -Custom client has to obey types and methods described by `HTTPTransport` abstract base class: -```python -class HTTPTransport(ABC): - @abstractmethod - def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - pass +sinch_client.configuration.transport = MyHTTPImplementation( + sinch_client, + proxy_url="proxy.example.com:8080", + proxy_user="proxy_user", + proxy_password="proxy_password", +) ``` Note: Asynchronous HTTP clients are not supported. From bd986221bf6743bba5212b7c57bc6ce09b15d2fb Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 27 May 2026 12:46:27 +0200 Subject: [PATCH 04/10] chore: update README --- .gitignore | 3 +++ README.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index e5edf123..c44c3ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,9 @@ cython_debug/ # Poetry poetry.lock +# Pyenv +.python-version + # .DS_Store files .DS_Store diff --git a/README.md b/README.md index 9d3143c0..d8e54ef9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ The SMS API supports two authentication methods. `sms_region` is required for bo The SDK automatically exchanges your key ID and key secret for a short-lived OAuth2 token and refreshes it automatically on expiry. 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`. + ```python from sinch import SinchClient @@ -80,6 +82,8 @@ sinch_client = SinchClient( Uses a static bearer token that never expires. Support all regions: `us`, `eu`, `br`, `ca`, `au`. +In your [Service APIs dashboard](https://dashboard.sinch.com/sms/api/services), you will find your `servicePlanId` and `apiToken` (bearer token). + ```python from sinch import SinchClient From d1149afdda8fe6ac8068a4d9e776948b09a3297c Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Tue, 2 Jun 2026 16:12:47 +0200 Subject: [PATCH 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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"