From b8057b2d5da34b544bc9042f4ce5154bba63dc62 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 28 May 2026 08:26:41 +0200 Subject: [PATCH 01/20] refactor(sms): remove deprecated groups and inbound models --- sinch/domains/sms/models/groups/__init__.py | 13 ----- sinch/domains/sms/models/groups/requests.py | 49 ------------------- sinch/domains/sms/models/groups/responses.py | 42 ---------------- sinch/domains/sms/models/inbounds/__init__.py | 15 ------ sinch/domains/sms/models/inbounds/requests.py | 18 ------- .../domains/sms/models/inbounds/responses.py | 17 ------- 6 files changed, 154 deletions(-) delete mode 100644 sinch/domains/sms/models/groups/__init__.py delete mode 100644 sinch/domains/sms/models/groups/requests.py delete mode 100644 sinch/domains/sms/models/groups/responses.py delete mode 100644 sinch/domains/sms/models/inbounds/__init__.py delete mode 100644 sinch/domains/sms/models/inbounds/requests.py delete mode 100644 sinch/domains/sms/models/inbounds/responses.py diff --git a/sinch/domains/sms/models/groups/__init__.py b/sinch/domains/sms/models/groups/__init__.py deleted file mode 100644 index d5d32a6e..00000000 --- a/sinch/domains/sms/models/groups/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class SMSGroup(SinchBaseModel): - id: str - size: int - created_at: str - modified_at: str - name: str - child_groups: list - auto_update: dict diff --git a/sinch/domains/sms/models/groups/requests.py b/sinch/domains/sms/models/groups/requests.py deleted file mode 100644 index b2b37fa8..00000000 --- a/sinch/domains/sms/models/groups/requests.py +++ /dev/null @@ -1,49 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class CreateSMSGroupRequest(SinchRequestBaseModel): - name: str - members: list - child_groups: list - auto_update: dict - - -@dataclass -class ListSMSGroupRequest(SinchRequestBaseModel): - page_size: int - page: int - - -@dataclass -class DeleteSMSGroupRequest(SinchRequestBaseModel): - group_id: str - - -@dataclass -class GetSMSGroupRequest(SinchRequestBaseModel): - group_id: str - - -@dataclass -class GetSMSGroupPhoneNumbersRequest(SinchRequestBaseModel): - group_id: str - - -@dataclass -class UpdateSMSGroupRequest(SinchRequestBaseModel): - group_id: str - name: str - add: list - remove: list - auto_update: dict - add_from_group: str - remove_from_group: str - - -@dataclass -class ReplaceSMSGroupPhoneNumbersRequest(SinchRequestBaseModel): - group_id: str - members: list - name: str diff --git a/sinch/domains/sms/models/groups/responses.py b/sinch/domains/sms/models/groups/responses.py deleted file mode 100644 index 7ebe70af..00000000 --- a/sinch/domains/sms/models/groups/responses.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.groups import SMSGroup - - -@dataclass -class CreateSMSGroupResponse(SMSGroup): - pass - - -@dataclass -class GetSMSGroupResponse(SMSGroup): - pass - - -@dataclass -class SinchListSMSGroupResponse(SinchBaseModel): - page: int - page_size: int - count: int - groups: List[SMSGroup] - - -@dataclass -class SinchDeleteSMSGroupResponse(SinchBaseModel): - pass - - -@dataclass -class SinchGetSMSGroupPhoneNumbersResponse(SinchBaseModel): - phone_numbers: list - - -@dataclass -class UpdateSMSGroupResponse(SMSGroup): - pass - - -@dataclass -class ReplaceSMSGroupResponse(SMSGroup): - pass diff --git a/sinch/domains/sms/models/inbounds/__init__.py b/sinch/domains/sms/models/inbounds/__init__.py deleted file mode 100644 index 92807b53..00000000 --- a/sinch/domains/sms/models/inbounds/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class InboundMessage(SinchBaseModel): - type: str - id: str - from_: str - to: str - body: str - operator_id: str - send_at: str - received_at: str - client_reference: str diff --git a/sinch/domains/sms/models/inbounds/requests.py b/sinch/domains/sms/models/inbounds/requests.py deleted file mode 100644 index b945285a..00000000 --- a/sinch/domains/sms/models/inbounds/requests.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListSMSInboundMessageRequest(SinchRequestBaseModel): - start_date: str - to: str - end_date: str - page_size: int - page_size: int - client_reference: str - page: int = 0 - - -@dataclass -class GetSMSInboundMessageRequest(SinchRequestBaseModel): - inbound_id: str diff --git a/sinch/domains/sms/models/inbounds/responses.py b/sinch/domains/sms/models/inbounds/responses.py deleted file mode 100644 index 1062ad14..00000000 --- a/sinch/domains/sms/models/inbounds/responses.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.inbounds import InboundMessage - - -@dataclass -class SinchListInboundMessagesResponse(SinchBaseModel): - page: str - page_size: str - count: str - inbounds: List[InboundMessage] - - -@dataclass -class GetInboundMessagesResponse(InboundMessage): - pass From 69ee1ca39f4cb07a9775213368f8fed5398ee627 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 28 May 2026 09:55:19 +0200 Subject: [PATCH 02/20] chore(update version): update version to 2.1.0 --- pyproject.toml | 2 +- sinch/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From f1898ec2e1c495c0b7c5a745d16016ba76cae103 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 28 May 2026 17:44:39 +0200 Subject: [PATCH 03/20] feat(sms): add list and create groups --- .gitignore | 3 + README.md | 2 + .../snippets/sms/groups/create/snippet.py | 25 +++++ .../core/adapters/requests_http_transport.py | 2 +- sinch/domains/sms/api/v1/groups.py | 100 ++++++++++++++++++ .../sms/api/v1/internal/groups_endpoints.py | 76 +++++++++++++ .../sms/models/v1/internal/__init__.py | 11 ++ .../sms/models/v1/internal/group_request.py | 26 +++++ .../models/v1/internal/list_groups_request.py | 17 +++ .../sms/models/v1/response/__init__.py | 6 ++ .../sms/models/v1/response/group_response.py | 38 +++++++ .../v1/response/list_groups_response.py | 31 ++++++ .../domains/sms/models/v1/shared/__init__.py | 8 ++ .../sms/models/v1/shared/auto_update.py | 45 ++++++++ sinch/domains/sms/models/v1/types/__init__.py | 2 + .../sms/models/v1/types/auto_update_dict.py | 18 ++++ sinch/domains/sms/sms.py | 2 + 17 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 examples/snippets/sms/groups/create/snippet.py create mode 100644 sinch/domains/sms/api/v1/groups.py create mode 100644 sinch/domains/sms/api/v1/internal/groups_endpoints.py create mode 100644 sinch/domains/sms/models/v1/internal/group_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_groups_request.py create mode 100644 sinch/domains/sms/models/v1/response/group_response.py create mode 100644 sinch/domains/sms/models/v1/response/list_groups_response.py create mode 100644 sinch/domains/sms/models/v1/shared/auto_update.py create mode 100644 sinch/domains/sms/models/v1/types/auto_update_dict.py diff --git a/.gitignore b/.gitignore index c44c3ae7..3dc2799f 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/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/examples/snippets/sms/groups/create/snippet.py b/examples/snippets/sms/groups/create/snippet.py new file mode 100644 index 00000000..23c78a07 --- /dev/null +++ b/examples/snippets/sms/groups/create/snippet.py @@ -0,0 +1,25 @@ +""" +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" +) + +response = sinch_client.sms.groups.create( + name="Test Group", + members=["+1234567890", "+1987654321"] +) + +print(f"Group created:\n{response}") diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 62c0a3cb..6fc62a77 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -15,7 +15,7 @@ def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: self.sinch.configuration.logger.debug( f"Sync HTTP {request_data.http_method} call with headers:" - f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}" + f" {request_data.headers}, body: {request_data.request_body} and query_params: {request_data.query_params} to URL: {request_data.url}" ) response = self.http_session.request( method=request_data.http_method, diff --git a/sinch/domains/sms/api/v1/groups.py b/sinch/domains/sms/api/v1/groups.py new file mode 100644 index 00000000..1d7d655e --- /dev/null +++ b/sinch/domains/sms/api/v1/groups.py @@ -0,0 +1,100 @@ + +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.groups_endpoints import ( + CreateGroupEndpoint, + ListGroupsEndpoint, +) +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.internal.list_groups_request import ( + ListGroupsRequest, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.types.auto_update_dict import AutoUpdateDict + + +class Groups(BaseSms): + + def create( + self, + name: Optional[str] = None, + members: Optional[List[str]] = None, + child_groups: Optional[List[str]] = None, + auto_update: Optional[AutoUpdateDict] = None, + **kwargs, + ) -> GroupResponse: + """ + This endpoint allows you to create a group of recipients. A new group must be created with a group + name. This is represented by the `name` field which can be up to 20 characters. In addition, there + are a number of optional fields: + + - `members` field enables groups to be created with an initial list of contacts. + - `auto_update` allows customers to auto subscribe to a new group. This contains three fields. The + `to` field contains the group creator's number. (This number **must be provisioned by contacting + your account manager**.) The `add` and `remove` fields are objects containing the keywords that + customers need to text to join or leave a group. + + :param name: Name of the group. Max 20 characters. (optional) + :type name: Optional[str] + :param members: Initial list of phone numbers in E.164 format (MSISDNs) for the group. (optional) + :type members: Optional[List[str]] + :param child_groups: MSISDNs of child groups to include in this group. If present, this group will + be auto-populated. Elements must be valid group IDs. (optional) + :type child_groups: Optional[List[str]] + :param auto_update: The auto-update settings for the group. (optional) + :type auto_update: Optional[AutoUpdateDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupRequest( + name=name, + members=members, + child_groups=child_groups, + auto_update=auto_update, + **kwargs, + ) + return self._request(CreateGroupEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + **kwargs, + ) -> Paginator[GroupResponse]: + """ + With the list operation you can list all groups that you have created. + This operation supports pagination. + + Groups 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 **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[GroupResponse] + :rtype: Paginator[GroupResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListGroupsEndpoint( + project_id=self._get_path_identifier(), + request_data=ListGroupsRequest( + page=page, + page_size=page_size, + **kwargs, + ), + ) + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) + \ No newline at end of file diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py new file mode 100644 index 00000000..97059fa8 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -0,0 +1,76 @@ + + + +import json + +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.group_request import GroupRequest +from sinch.domains.sms.models.v1.internal.list_groups_request import ( + ListGroupsRequest, +) +from sinch.domains.sms.models.v1.response.list_groups_response import ( + ListGroupsResponse, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +class CreateGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + + def __init__(self, project_id: str, request_data: GroupRequest): + super(CreateGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Use mode='json' to serialize datetime objects to ISO-8601 strings + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(CreateGroupEndpoint, 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, GroupResponse) + +class ListGroupsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListGroupsRequest): + super(ListGroupsEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response( + self, response: HTTPResponse + ) -> ListGroupsResponse: + try: + super(ListGroupsEndpoint, 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, ListGroupsResponse + ) \ No newline at end of file diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py index 24ca21db..9a15a711 100644 --- a/sinch/domains/sms/models/v1/internal/__init__.py +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -19,14 +19,19 @@ from sinch.domains.sms.models.v1.internal.list_delivery_reports_request import ( ListDeliveryReportsRequest, ) +from sinch.domains.sms.models.v1.internal.list_groups_request import ( + ListGroupsRequest, +) __all__ = [ "BatchIdRequest", "DeliveryFeedbackRequest", + "GroupRequest", "ListBatchesRequest", "ListDeliveryReportsResponse", "GetRecipientDeliveryReportRequest", "ListDeliveryReportsRequest", + "ListGroupsRequest", "GetBatchDeliveryReportRequest", "DryRunRequest", "ReplaceBatchRequest", @@ -61,4 +66,10 @@ def __getattr__(name: str): ) return UpdateBatchMessageRequest + if name == "GroupRequest": + from sinch.domains.sms.models.v1.internal.group_request import ( + GroupRequest, + ) + + return GroupRequest raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/sms/models/v1/internal/group_request.py b/sinch/domains/sms/models/v1/internal/group_request.py new file mode 100644 index 00000000..2813b595 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/group_request.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import Field, StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.sms.models.v1.shared.auto_update import AutoUpdate + + +class GroupRequest(BaseModelConfigurationRequest): + name: Optional[StrictStr] = Field( + default=None, + description="Name of group", + ) + members: Optional[conlist(StrictStr)] = Field( + description="Initial list of phone numbers in [E.164 format] for the group.", + ) + child_groups: Optional[conlist(StrictStr)] = Field( + default=None, + description="MSISDNs of child groups will be included in this group. Elements must be group IDs.", + ) + auto_update: Optional[AutoUpdate] = Field( + default=None, + description="Configuration for auto-subscription via MO keywords.", + ) diff --git a/sinch/domains/sms/models/v1/internal/list_groups_request.py b/sinch/domains/sms/models/v1/internal/list_groups_request.py new file mode 100644 index 00000000..426e7b7e --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_groups_request.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import Field, StrictInt + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListGroupsRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) diff --git a/sinch/domains/sms/models/v1/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py index 9648cf44..410918de 100644 --- a/sinch/domains/sms/models/v1/response/__init__.py +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -4,9 +4,13 @@ from sinch.domains.sms.models.v1.response.dry_run_response import ( DryRunResponse, ) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse from sinch.domains.sms.models.v1.response.list_batches_response import ( ListBatchesResponse, ) +from sinch.domains.sms.models.v1.response.list_groups_response import ( + ListGroupsResponse, +) from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( RecipientDeliveryReport, ) @@ -14,6 +18,8 @@ __all__ = [ "BatchDeliveryReport", "DryRunResponse", + "GroupResponse", "ListBatchesResponse", + "ListGroupsResponse", "RecipientDeliveryReport", ] diff --git a/sinch/domains/sms/models/v1/response/group_response.py b/sinch/domains/sms/models/v1/response/group_response.py new file mode 100644 index 00000000..ddb29d98 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/group_response.py @@ -0,0 +1,38 @@ +from typing import List, Optional +from datetime import datetime +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.shared.auto_update import AutoUpdate + + +class GroupResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, + description="The ID used to reference this group.", + ) + name: Optional[StrictStr] = Field( + default=None, + description="Name of group", + ) + size: Optional[StrictInt] = Field( + default=None, + description="The number of members currently in the group.", + ) + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for group creation. Format: YYYY-MM-DDThh:mm:ss.SSSZ", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when the group was last updated. Format: YYYY-MM-DDThh:mm:ss.SSSZ", + ) + child_groups: Optional[List[StrictStr]] = Field( + default=None, + description="MSISDNs of child groups will be included in this group. Elements must be group IDs.", + ) + auto_update: Optional[AutoUpdate] = Field( + default=None, + description="Configuration for auto-subscription via MO keywords.", + ) diff --git a/sinch/domains/sms/models/v1/response/list_groups_response.py b/sinch/domains/sms/models/v1/response/list_groups_response.py new file mode 100644 index 00000000..dd79568f --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_groups_response.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import Field, StrictInt, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +class ListGroupsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of entries matching the given filters.", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) + groups: Optional[conlist(GroupResponse)] = Field( + default=None, + description="The page of groups matching the given filters.", + ) + + @property + def content(self): + """Returns the content of the group list.""" + return self.groups or [] diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 5139c795..67fcc08b 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -1,3 +1,8 @@ +from sinch.domains.sms.models.v1.shared.auto_update import ( + AddKeyword, + AutoUpdate, + RemoveKeyword, +) from sinch.domains.sms.models.v1.shared.binary_request import BinaryRequest from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse from sinch.domains.sms.models.v1.shared.dry_run_per_recipient_details import ( @@ -13,6 +18,8 @@ from sinch.domains.sms.models.v1.shared.text_response import TextResponse __all__ = [ + "AddKeyword", + "AutoUpdate", "BinaryRequest", "BinaryResponse", "DryRunPerRecipientDetails", @@ -20,6 +27,7 @@ "MediaRequest", "MediaResponse", "MessageDeliveryStatus", + "RemoveKeyword", "TextRequest", "TextResponse", ] diff --git a/sinch/domains/sms/models/v1/shared/auto_update.py b/sinch/domains/sms/models/v1/shared/auto_update.py new file mode 100644 index 00000000..e6e97320 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/auto_update.py @@ -0,0 +1,45 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class AddKeyword(BaseModelConfigurationResponse): + first_word: StrictStr = Field( + default=..., + description="Opt-in keyword like 'JOIN' if auto_update.to is a dedicated long/short number, " + "or unique brand keyword like 'Sinch' if it is a shared short code.", + ) + second_word: Optional[StrictStr] = Field( + default=None, + description="Opt-in keyword like 'JOIN' if auto_update.to is a shared short code.", + ) + + +class RemoveKeyword(BaseModelConfigurationResponse): + first_word: StrictStr = Field( + default=..., + description="Opt-out keyword like 'LEAVE' if auto_update.to is a dedicated long/short number, " + "or unique brand keyword like 'Sinch' if it is a shared short code.", + ) + second_word: Optional[StrictStr] = Field( + default=None, + description="Opt-out keyword like 'LEAVE' if auto_update.to is a shared short code.", + ) + + +class AutoUpdate(BaseModelConfigurationResponse): + to: StrictStr = Field( + default=..., + description="Short code or long number addressed in MO. " + "Must be a valid phone number or short code provisioned by your account manager.", + ) + add: Optional[AddKeyword] = Field( + default=None, + description="Keyword to be sent in MO to add MSISDN to the group.", + ) + remove: Optional[RemoveKeyword] = Field( + default=None, + description="Keyword to be sent in MO to remove MSISDN from the group.", + ) diff --git a/sinch/domains/sms/models/v1/types/__init__.py b/sinch/domains/sms/models/v1/types/__init__.py index a52cfcc2..54f33b1f 100644 --- a/sinch/domains/sms/models/v1/types/__init__.py +++ b/sinch/domains/sms/models/v1/types/__init__.py @@ -1,3 +1,4 @@ +from sinch.domains.sms.models.v1.types.auto_update_dict import AutoUpdateDict from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( DeliveryReceiptStatusCodeType, ) @@ -14,6 +15,7 @@ ) __all__ = [ + "AutoUpdateDict", "BatchResponse", "DeliveryReceiptStatusCodeType", "DeliveryReportType", diff --git a/sinch/domains/sms/models/v1/types/auto_update_dict.py b/sinch/domains/sms/models/v1/types/auto_update_dict.py new file mode 100644 index 00000000..f3f786f8 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/auto_update_dict.py @@ -0,0 +1,18 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class AddKeywordDict(TypedDict): + first_word: str + second_word: NotRequired[str] + + +class RemoveKeywordDict(TypedDict): + first_word: str + second_word: NotRequired[str] + + +class AutoUpdateDict(TypedDict): + to: str + add: NotRequired[AddKeywordDict] + remove: NotRequired[RemoveKeywordDict] diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index c312c2de..06c5553e 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.groups import Groups 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.groups = Groups(self._sinch) def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: """ From 4882538fd69e7a5bdd0cb17c4a45f903d4e10179 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 29 May 2026 16:10:05 +0200 Subject: [PATCH 04/20] feat(sms): add delete, list_members, update and replace groups --- .../snippets/sms/groups/create/snippet.py | 8 +- .../snippets/sms/groups/delete/snippet.py | 27 +++ examples/snippets/sms/groups/get/snippet.py | 29 +++ examples/snippets/sms/groups/list/snippet.py | 29 +++ .../sms/groups/list_members/snippet.py | 28 +++ .../snippets/sms/groups/replace/snippet.py | 32 ++++ .../snippets/sms/groups/update/snippet.py | 33 ++++ sinch/domains/sms/api/v1/groups.py | 173 +++++++++++++++++- .../sms/api/v1/internal/groups_endpoints.py | 140 +++++++++++++- .../sms/models/v1/internal/__init__.py | 14 ++ .../models/v1/internal/group_id_request.py | 12 ++ .../v1/internal/replace_group_request.py | 8 + .../v1/internal/update_group_request.py | 41 +++++ .../sms/models/v1/shared/group_id_mixin.py | 10 + 14 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 examples/snippets/sms/groups/delete/snippet.py create mode 100644 examples/snippets/sms/groups/get/snippet.py create mode 100644 examples/snippets/sms/groups/list/snippet.py create mode 100644 examples/snippets/sms/groups/list_members/snippet.py create mode 100644 examples/snippets/sms/groups/replace/snippet.py create mode 100644 examples/snippets/sms/groups/update/snippet.py create mode 100644 sinch/domains/sms/models/v1/internal/group_id_request.py create mode 100644 sinch/domains/sms/models/v1/internal/replace_group_request.py create mode 100644 sinch/domains/sms/models/v1/internal/update_group_request.py create mode 100644 sinch/domains/sms/models/v1/shared/group_id_mixin.py diff --git a/examples/snippets/sms/groups/create/snippet.py b/examples/snippets/sms/groups/create/snippet.py index 23c78a07..a3c30f3c 100644 --- a/examples/snippets/sms/groups/create/snippet.py +++ b/examples/snippets/sms/groups/create/snippet.py @@ -5,8 +5,11 @@ """ import os + from dotenv import load_dotenv + from sinch import SinchClient +from sinch.domains.sms.api.v1.groups import GroupResponse load_dotenv() @@ -17,9 +20,8 @@ sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" ) -response = sinch_client.sms.groups.create( - name="Test Group", - members=["+1234567890", "+1987654321"] +response: GroupResponse = sinch_client.sms.groups.create( + name="Test Group", members=["+1234567890", "+1987654321"] ) print(f"Group created:\n{response}") diff --git a/examples/snippets/sms/groups/delete/snippet.py b/examples/snippets/sms/groups/delete/snippet.py new file mode 100644 index 00000000..156010f3 --- /dev/null +++ b/examples/snippets/sms/groups/delete/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the group to delete +group_id = "GROUP_ID" + +sinch_client.sms.groups.delete(group_id=group_id) + +print(f"Group {group_id} deleted") diff --git a/examples/snippets/sms/groups/get/snippet.py b/examples/snippets/sms/groups/get/snippet.py new file mode 100644 index 00000000..045da6b3 --- /dev/null +++ b/examples/snippets/sms/groups/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 +from sinch.domains.sms.api.v1.groups import GroupResponse + +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" +) + +GROUP_ID = "GROUP_ID" + +response: GroupResponse = sinch_client.sms.groups.get( + group_id=GROUP_ID +) + +print(f"Group details:\n{response}") \ No newline at end of file diff --git a/examples/snippets/sms/groups/list/snippet.py b/examples/snippets/sms/groups/list/snippet.py new file mode 100644 index 00000000..af6ee3cf --- /dev/null +++ b/examples/snippets/sms/groups/list/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 +from sinch.core.pagination import Paginator +from sinch.domains.sms.api.v1.groups import GroupResponse + +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" +) + +groups: Paginator[GroupResponse] = sinch_client.sms.groups.list( + name="Test Group", members=["+1234567890", "+1987654321"] +) + +for group in groups: + print(f"Group:\n{group}") diff --git a/examples/snippets/sms/groups/list_members/snippet.py b/examples/snippets/sms/groups/list_members/snippet.py new file mode 100644 index 00000000..ee9bb2c2 --- /dev/null +++ b/examples/snippets/sms/groups/list_members/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 typing import List + +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 group to list members for +group_id = "GROUP_ID" + +members: List[str] = sinch_client.sms.groups.list_members(group_id=group_id) + +print(f"Group {group_id} members: {members}") diff --git a/examples/snippets/sms/groups/replace/snippet.py b/examples/snippets/sms/groups/replace/snippet.py new file mode 100644 index 00000000..12f457ed --- /dev/null +++ b/examples/snippets/sms/groups/replace/snippet.py @@ -0,0 +1,32 @@ +""" +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.domains.sms.api.v1.groups import GroupResponse + +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 group to replace +group_id = "GROUP_ID" + +response: GroupResponse = sinch_client.sms.groups.replace( + group_id=group_id, + name="Replaced Group", + members=["+1234567890", "+1987654321"], +) + +print(f"Group replaced:\n{response}") diff --git a/examples/snippets/sms/groups/update/snippet.py b/examples/snippets/sms/groups/update/snippet.py new file mode 100644 index 00000000..0a0fbaad --- /dev/null +++ b/examples/snippets/sms/groups/update/snippet.py @@ -0,0 +1,33 @@ +""" +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.domains.sms.api.v1.groups import GroupResponse + +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 group to update +group_id = "GROUP_ID" + +response: GroupResponse = sinch_client.sms.groups.update( + group_id=group_id, + add=["+1234567890"], + remove=["+1987654321"], + name="Renamed Group", +) + +print(f"Group updated:\n{response}") diff --git a/sinch/domains/sms/api/v1/groups.py b/sinch/domains/sms/api/v1/groups.py index 1d7d655e..5ae2f4eb 100644 --- a/sinch/domains/sms/api/v1/groups.py +++ b/sinch/domains/sms/api/v1/groups.py @@ -5,12 +5,26 @@ from sinch.domains.sms.api.v1.base.base_sms import BaseSms from sinch.domains.sms.api.v1.internal.groups_endpoints import ( CreateGroupEndpoint, + DeleteGroupEndpoint, + GetGroupEndpoint, + ListGroupMembersEndpoint, ListGroupsEndpoint, + ReplaceGroupEndpoint, + UpdateGroupEndpoint, +) +from sinch.domains.sms.models.v1.internal.group_id_request import ( + GroupIdRequest, ) from sinch.domains.sms.models.v1.internal.group_request import GroupRequest from sinch.domains.sms.models.v1.internal.list_groups_request import ( ListGroupsRequest, ) +from sinch.domains.sms.models.v1.internal.replace_group_request import ( + ReplaceGroupRequest, +) +from sinch.domains.sms.models.v1.internal.update_group_request import ( + UpdateGroupRequest, +) from sinch.domains.sms.models.v1.response.group_response import GroupResponse from sinch.domains.sms.models.v1.types.auto_update_dict import AutoUpdateDict @@ -97,4 +111,161 @@ def list( endpoint.set_authentication_method(self._sinch) return SMSPaginator(sinch=self._sinch, endpoint=endpoint) - \ No newline at end of file + + def get(self, group_id: str, **kwargs) -> GroupResponse: + """ + This operation retrieves a specific group with the provided group ID. + + :param group_id: ID of a group that you are interested in getting. + :type group_id: str + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id, **kwargs) + return self._request(GetGroupEndpoint, request_data) + + def replace( + self, + group_id: str, + name: Optional[str] = None, + members: Optional[List[str]] = None, + child_groups: Optional[List[str]] = None, + auto_update: Optional[AutoUpdateDict] = None, + **kwargs, + ) -> GroupResponse: + """ + The replace operation will replace all parameters, including members, of + an existing group with new values. + + Replacing a group targeted by a batch message scheduled in the future is + allowed and changes will be reflected when the batch is sent. + + :param group_id: ID of the group to replace. + :type group_id: str + :param name: Name of the group. Max 20 characters. (optional) + :type name: Optional[str] + :param members: Initial list of phone numbers in E.164 format (MSISDNs) for the group. (optional) + :type members: Optional[List[str]] + :param child_groups: MSISDNs of child groups to include in this group. If present, this group will + be auto-populated. Elements must be valid group IDs. (optional) + :type child_groups: Optional[List[str]] + :param auto_update: The auto-update settings for the group. (optional) + :type auto_update: Optional[AutoUpdateDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = ReplaceGroupRequest( + group_id=group_id, + name=name, + members=members, + child_groups=child_groups, + auto_update=auto_update, + **kwargs, + ) + return self._request(ReplaceGroupEndpoint, request_data) + + def update( + self, + group_id: str, + add: Optional[List[str]] = None, + remove: Optional[List[str]] = None, + name: Optional[str] = None, + add_from_group: Optional[str] = None, + remove_from_group: Optional[str] = None, + auto_update: Optional[AutoUpdateDict] = None, + **kwargs, + ) -> GroupResponse: + """ + With the update group operation, you can add and remove members in an + existing group as well as rename the group. + + This method encompasses a few ways to update a group: + + 1. By using `add` and `remove` arrays containing phone numbers, you control the group + movements. Any list of valid numbers in E.164 format can be added. + 2. By using the `auto_update` object, your customer can add or remove themselves from groups. + 3. You can also add or remove other groups into this group with `add_from_group` and + `remove_from_group`. + + Other group update info: + + - The request will not be rejected for duplicate adds or unknown removes. + - The additions will be done before the deletions. If a phone number is on both lists, + it will not be part of the resulting group. + - Updating a group targeted by a batch message scheduled in the future is allowed. + Changes will be reflected when the batch is sent. + + :param group_id: ID of the group to update. + :type group_id: str + :param add: List of phone numbers (MSISDNs) in E.164 format to add to the group. (optional) + :type add: Optional[List[str]] + :param remove: List of phone numbers (MSISDNs) in E.164 format to remove from the group. (optional) + :type remove: Optional[List[str]] + :param name: Name of the group. Omit to leave the name unchanged; set explicitly to null to + remove the existing name. (optional) + :type name: Optional[str] + :param add_from_group: Copy the members from another group into this group. Must be a valid + group ID. (optional) + :type add_from_group: Optional[str] + :param remove_from_group: Remove the members in a specified group from this group. Must be a + valid group ID. (optional) + :type remove_from_group: Optional[str] + :param auto_update: The auto-update settings for the group. (optional) + :type auto_update: Optional[AutoUpdateDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = UpdateGroupRequest( + group_id=group_id, + add=add, + remove=remove, + name=name, + add_from_group=add_from_group, + remove_from_group=remove_from_group, + auto_update=auto_update, + **kwargs, + ) + return self._request(UpdateGroupEndpoint, request_data) + + def delete(self, group_id: str) -> None: + """ + This operation deletes the group with the provided group ID. + + :param group_id: ID of the group to delete. + :type group_id: str + + :returns: None + :rtype: None + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id) + return self._request(DeleteGroupEndpoint, request_data) + + def list_members(self, group_id: str) -> List[str]: + """ + This operation retrieves the members of the group with the provided group ID. + + :param group_id: ID of the group whose members are being retrieved. + :type group_id: str + + :returns: List of phone numbers (MSISDNs) in E.164 format. + :rtype: List[str] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id) + return self._request(ListGroupMembersEndpoint, request_data) \ No newline at end of file diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 97059fa8..4ce1cace 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -2,20 +2,32 @@ import json +from typing import List + +from pydantic import TypeAdapter 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.group_id_request import ( + GroupIdRequest, +) from sinch.domains.sms.models.v1.internal.group_request import GroupRequest from sinch.domains.sms.models.v1.internal.list_groups_request import ( ListGroupsRequest, ) +from sinch.domains.sms.models.v1.internal.replace_group_request import ( + ReplaceGroupRequest, +) +from sinch.domains.sms.models.v1.internal.update_group_request import ( + UpdateGroupRequest, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse from sinch.domains.sms.models.v1.response.list_groups_response import ( ListGroupsResponse, ) -from sinch.domains.sms.models.v1.response.group_response import GroupResponse class CreateGroupEndpoint(SmsEndpoint): @@ -73,4 +85,128 @@ def handle_response( ) return self.process_response_model( response.body, ListGroupsResponse - ) \ No newline at end of file + ) + + +class GetGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupIdRequest): + super(GetGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(GetGroupEndpoint, 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, GroupResponse) + + +class ReplaceGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.PUT.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ReplaceGroupRequest): + super(ReplaceGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude=path_params, + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(ReplaceGroupEndpoint, 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, GroupResponse) + + +class UpdateGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: UpdateGroupRequest): + super(UpdateGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude=path_params, + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(UpdateGroupEndpoint, 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, GroupResponse) + + +class DeleteGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.DELETE.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupIdRequest): + super(DeleteGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> None: + try: + super(DeleteGroupEndpoint, 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 None + + +class ListGroupMembersEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}/members" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupIdRequest): + super(ListGroupMembersEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> List[str]: + try: + super(ListGroupMembersEndpoint, 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 TypeAdapter(List[str]).validate_python(response.body) \ No newline at end of file diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py index 9a15a711..d026436e 100644 --- a/sinch/domains/sms/models/v1/internal/__init__.py +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -35,8 +35,10 @@ "GetBatchDeliveryReportRequest", "DryRunRequest", "ReplaceBatchRequest", + "ReplaceGroupRequest", "SendSMSRequest", "UpdateBatchMessageRequest", + "UpdateGroupRequest", ] @@ -54,6 +56,12 @@ def __getattr__(name: str): ) return ReplaceBatchRequest + if name == "ReplaceGroupRequest": + from sinch.domains.sms.models.v1.internal.replace_group_request import ( + ReplaceGroupRequest, + ) + + return ReplaceGroupRequest if name == "SendSMSRequest": from sinch.domains.sms.models.v1.internal.send_sms_request import ( SendSMSRequest, @@ -66,6 +74,12 @@ def __getattr__(name: str): ) return UpdateBatchMessageRequest + if name == "UpdateGroupRequest": + from sinch.domains.sms.models.v1.internal.update_group_request import ( + UpdateGroupRequest, + ) + + return UpdateGroupRequest if name == "GroupRequest": from sinch.domains.sms.models.v1.internal.group_request import ( GroupRequest, diff --git a/sinch/domains/sms/models/v1/internal/group_id_request.py b/sinch/domains/sms/models/v1/internal/group_id_request.py new file mode 100644 index 00000000..da92395e --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/group_id_request.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class GroupIdRequest(BaseModelConfigurationRequest): + group_id: StrictStr = Field( + default=..., + description="ID of the group.", + ) \ No newline at end of file diff --git a/sinch/domains/sms/models/v1/internal/replace_group_request.py b/sinch/domains/sms/models/v1/internal/replace_group_request.py new file mode 100644 index 00000000..18355815 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/replace_group_request.py @@ -0,0 +1,8 @@ +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.shared.group_id_mixin import GroupIdMixin + + +class ReplaceGroupRequest(GroupIdMixin, GroupRequest): + """Request model for replacing a group.""" + + pass diff --git a/sinch/domains/sms/models/v1/internal/update_group_request.py b/sinch/domains/sms/models/v1/internal/update_group_request.py new file mode 100644 index 00000000..e625cc6f --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_group_request.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import Field, StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.sms.models.v1.shared.auto_update import AutoUpdate +from sinch.domains.sms.models.v1.shared.group_id_mixin import GroupIdMixin + + +class UpdateGroupRequest(GroupIdMixin, BaseModelConfigurationRequest): + """Request model for updating a group (incremental changes).""" + + add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers (MSISDNs) in E.164 format to add to the group.", + ) + remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers (MSISDNs) in E.164 format to remove from the group.", + ) + name: Optional[StrictStr] = Field( + default=None, + description=( + "Name of the group. Omit to leave the name unchanged; " + "set explicitly to null to remove the existing name." + ), + ) + add_from_group: Optional[StrictStr] = Field( + default=None, + description="Copy the members from another group into this group. Must be a valid group ID.", + ) + remove_from_group: Optional[StrictStr] = Field( + default=None, + description="Remove the members in a specified group from this group. Must be a valid group ID.", + ) + auto_update: Optional[AutoUpdate] = Field( + default=None, + description="Configuration for auto-subscription via MO keywords.", + ) diff --git a/sinch/domains/sms/models/v1/shared/group_id_mixin.py b/sinch/domains/sms/models/v1/shared/group_id_mixin.py new file mode 100644 index 00000000..96e9e3b3 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/group_id_mixin.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field, StrictStr + + +class GroupIdMixin(BaseModel): + """Mixin that adds group_id field to request models.""" + + group_id: StrictStr = Field( + default=..., + description="ID of the group.", + ) From 25bd73123b4bc9eeb1eeb7e7853d2f99459080d0 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 29 May 2026 16:30:15 +0200 Subject: [PATCH 05/20] feat(sms): fix ruff format --- sinch/domains/sms/api/v1/groups.py | 6 ++-- .../sms/api/v1/internal/groups_endpoints.py | 31 ++++++++++--------- .../models/v1/internal/group_id_request.py | 2 +- .../sms/models/v1/internal/group_request.py | 2 +- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/sinch/domains/sms/api/v1/groups.py b/sinch/domains/sms/api/v1/groups.py index 5ae2f4eb..77bd843f 100644 --- a/sinch/domains/sms/api/v1/groups.py +++ b/sinch/domains/sms/api/v1/groups.py @@ -1,4 +1,3 @@ - from typing import List, Optional from sinch.core.pagination import Paginator, SMSPaginator @@ -30,7 +29,6 @@ class Groups(BaseSms): - def create( self, name: Optional[str] = None, @@ -75,7 +73,7 @@ def create( **kwargs, ) return self._request(CreateGroupEndpoint, request_data) - + def list( self, page: Optional[int] = None, @@ -268,4 +266,4 @@ def list_members(self, group_id: str) -> List[str]: For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request_data = GroupIdRequest(group_id=group_id) - return self._request(ListGroupMembersEndpoint, request_data) \ No newline at end of file + return self._request(ListGroupMembersEndpoint, request_data) diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 4ce1cace..97adbf9a 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -1,6 +1,3 @@ - - - import json from typing import List @@ -35,7 +32,6 @@ class CreateGroupEndpoint(SmsEndpoint): HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: GroupRequest): super(CreateGroupEndpoint, self).__init__(project_id, request_data) self.project_id = project_id @@ -58,7 +54,8 @@ def handle_response(self, response: HTTPResponse) -> GroupResponse: is_from_server=e.is_from_server, ) return self.process_response_model(response.body, GroupResponse) - + + class ListGroupsEndpoint(SmsEndpoint): ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups" HTTP_METHOD = HTTPMethods.GET.value @@ -72,9 +69,7 @@ def __init__(self, project_id: str, request_data: ListGroupsRequest): def build_query_params(self) -> dict: return model_dump_for_query_params(self.request_data) - def handle_response( - self, response: HTTPResponse - ) -> ListGroupsResponse: + def handle_response(self, response: HTTPResponse) -> ListGroupsResponse: try: super(ListGroupsEndpoint, self).handle_response(response) except SmsException as e: @@ -83,9 +78,7 @@ def handle_response( response=e.http_response, is_from_server=e.is_from_server, ) - return self.process_response_model( - response.body, ListGroupsResponse - ) + return self.process_response_model(response.body, ListGroupsResponse) class GetGroupEndpoint(SmsEndpoint): @@ -123,7 +116,10 @@ def __init__(self, project_id: str, request_data: ReplaceGroupRequest): def request_body(self): path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude=path_params, + mode="json", + by_alias=True, + exclude_none=True, + exclude=path_params, ) return json.dumps(request_data) @@ -152,7 +148,10 @@ def __init__(self, project_id: str, request_data: UpdateGroupRequest): def request_body(self): path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude=path_params, + mode="json", + by_alias=True, + exclude_none=True, + exclude=path_params, ) return json.dumps(request_data) @@ -196,7 +195,9 @@ class ListGroupMembersEndpoint(SmsEndpoint): HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: GroupIdRequest): - super(ListGroupMembersEndpoint, self).__init__(project_id, request_data) + super(ListGroupMembersEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data @@ -209,4 +210,4 @@ def handle_response(self, response: HTTPResponse) -> List[str]: response=e.http_response, is_from_server=e.is_from_server, ) - return TypeAdapter(List[str]).validate_python(response.body) \ No newline at end of file + return TypeAdapter(List[str]).validate_python(response.body) diff --git a/sinch/domains/sms/models/v1/internal/group_id_request.py b/sinch/domains/sms/models/v1/internal/group_id_request.py index da92395e..4d36be92 100644 --- a/sinch/domains/sms/models/v1/internal/group_id_request.py +++ b/sinch/domains/sms/models/v1/internal/group_id_request.py @@ -9,4 +9,4 @@ class GroupIdRequest(BaseModelConfigurationRequest): group_id: StrictStr = Field( default=..., description="ID of the group.", - ) \ No newline at end of file + ) diff --git a/sinch/domains/sms/models/v1/internal/group_request.py b/sinch/domains/sms/models/v1/internal/group_request.py index 2813b595..b538b847 100644 --- a/sinch/domains/sms/models/v1/internal/group_request.py +++ b/sinch/domains/sms/models/v1/internal/group_request.py @@ -13,7 +13,7 @@ class GroupRequest(BaseModelConfigurationRequest): default=None, description="Name of group", ) - members: Optional[conlist(StrictStr)] = Field( + members: Optional[conlist(StrictStr)] = Field( description="Initial list of phone numbers in [E.164 format] for the group.", ) child_groups: Optional[conlist(StrictStr)] = Field( From 3de66e332bfc0d2bdddbf83cfa8ad74a689f010b Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 1 Jun 2026 15:00:26 +0200 Subject: [PATCH 06/20] test(sms): add groups unit tests --- .gitignore | 1 + .../sms/api/v1/internal/groups_endpoints.py | 5 +- .../get_batch_delivery_report_request.py | 8 +- .../sms/models/v1/internal/group_request.py | 1 + .../sms/models/v1/response/group_response.py | 8 +- .../groups/test_create_group_endpoint.py | 124 ++++++++++ .../groups/test_delete_group_endpoint.py | 61 +++++ .../groups/test_get_group_endpoint.py | 97 ++++++++ .../test_list_group_members_endpoint.py | 63 ++++++ .../groups/test_list_groups_endpoint.py | 114 ++++++++++ .../groups/test_replace_group_endpoint.py | 126 +++++++++++ .../groups/test_update_group_endpoint.py | 124 ++++++++++ .../internal/test_group_id_request_model.py | 31 +++ .../internal/test_group_request_model.py | 58 +++++ .../test_list_groups_request_model.py | 29 +++ .../test_replace_group_request_model.py | 50 +++++ .../test_update_group_request_model.py | 56 +++++ .../response/test_group_response_model.py | 81 +++++++ .../test_list_groups_response_model.py | 71 ++++++ tests/unit/domains/sms/v1/test_groups.py | 211 ++++++++++++++++++ 20 files changed, 1309 insertions(+), 10 deletions(-) create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_group_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_group_response_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py create mode 100644 tests/unit/domains/sms/v1/test_groups.py diff --git a/.gitignore b/.gitignore index 3dc2799f..6542ef59 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +lcov.info # E2E features *.feature diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 97adbf9a..207b803f 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -1,7 +1,7 @@ import json from typing import List -from pydantic import TypeAdapter +from pydantic import StrictStr, TypeAdapter, conlist from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse @@ -38,7 +38,6 @@ def __init__(self, project_id: str, request_data: GroupRequest): self.request_data = request_data def request_body(self): - # Use mode='json' to serialize datetime objects to ISO-8601 strings request_data = self.request_data.model_dump( mode="json", by_alias=True, exclude_none=True ) @@ -210,4 +209,4 @@ def handle_response(self, response: HTTPResponse) -> List[str]: response=e.http_response, is_from_server=e.is_from_server, ) - return TypeAdapter(List[str]).validate_python(response.body) + return TypeAdapter(conlist(StrictStr)).validate_python(response.body) diff --git a/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py index e6f951e7..ece7c294 100644 --- a/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py +++ b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py @@ -1,5 +1,5 @@ -from typing import Optional, List -from pydantic import StrictStr, Field +from typing import Optional +from pydantic import StrictStr, Field, conlist from sinch.domains.sms.models.v1.types import ( DeliveryReceiptStatusCodeType, DeliveryReportType, @@ -16,11 +16,11 @@ class GetBatchDeliveryReportRequest(BaseModelConfigurationRequest): default=None, description="The type of delivery report.", ) - status: Optional[List[DeliveryStatusType]] = Field( + status: Optional[conlist(DeliveryStatusType)] = Field( default=None, description="Comma separated list of delivery_report_statuses to include", ) - code: Optional[List[DeliveryReceiptStatusCodeType]] = Field( + code: Optional[conlist(DeliveryReceiptStatusCodeType)] = Field( default=None, description="Comma separated list of delivery receipt error codes to include", ) diff --git a/sinch/domains/sms/models/v1/internal/group_request.py b/sinch/domains/sms/models/v1/internal/group_request.py index b538b847..de3a14b4 100644 --- a/sinch/domains/sms/models/v1/internal/group_request.py +++ b/sinch/domains/sms/models/v1/internal/group_request.py @@ -14,6 +14,7 @@ class GroupRequest(BaseModelConfigurationRequest): description="Name of group", ) members: Optional[conlist(StrictStr)] = Field( + default=None, description="Initial list of phone numbers in [E.164 format] for the group.", ) child_groups: Optional[conlist(StrictStr)] = Field( diff --git a/sinch/domains/sms/models/v1/response/group_response.py b/sinch/domains/sms/models/v1/response/group_response.py index ddb29d98..f5a1b96d 100644 --- a/sinch/domains/sms/models/v1/response/group_response.py +++ b/sinch/domains/sms/models/v1/response/group_response.py @@ -1,6 +1,8 @@ -from typing import List, Optional from datetime import datetime -from pydantic import Field, StrictInt, StrictStr +from typing import Optional + +from pydantic import Field, StrictInt, StrictStr, conlist + from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) @@ -28,7 +30,7 @@ class GroupResponse(BaseModelConfigurationResponse): default=None, description="Timestamp for when the group was last updated. Format: YYYY-MM-DDThh:mm:ss.SSSZ", ) - child_groups: Optional[List[StrictStr]] = Field( + child_groups: Optional[conlist(StrictStr)] = Field( default=None, description="MSISDNs of child groups will be included in this group. Elements must be group IDs.", ) diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py new file mode 100644 index 00000000..275abcdc --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py @@ -0,0 +1,124 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import CreateGroupEndpoint +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return GroupRequest(name="Test Group") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=201, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return CreateGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Test Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + +def test_request_body_excludes_none_fields(endpoint): + """Test that None fields are excluded from the serialized request body.""" + body = json.loads(endpoint.request_body()) + assert body["name"] == "Test Group" + assert "members" not in body + assert "child_groups" not in body + assert "auto_update" not in body + + +def test_request_body_expects_correct_serialization(): + """Test that all fields serialize correctly to the request body.""" + request_data = GroupRequest( + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + endpoint = CreateGroupEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["name"] == "Test Group" + assert body["members"] == ["+46701234567", "+46709876543"] + assert body["child_groups"] == ["01FC66621VHDBN119Z8PMV1AHY"] + assert body["auto_update"]["to"] == "+15551231234" + assert body["auto_update"]["add"]["first_word"] == "JOIN" + assert body["auto_update"]["remove"]["first_word"] == "LEAVE" \ No newline at end of file diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py new file mode 100644 index 00000000..93016351 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py @@ -0,0 +1,61 @@ +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.groups_endpoints import DeleteGroupEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest + + +@pytest.fixture +def request_data(): + return GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=204, + body={}, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Group not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return DeleteGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_returns_none(endpoint, mock_response): + """Test that handle_response returns None for a successful delete.""" + result = endpoint.handle_response(mock_response) + assert result is None + + +def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): + """Test that SmsException is raised when server returns an error.""" + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py new file mode 100644 index 00000000..0260abdb --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py @@ -0,0 +1,97 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import GetGroupEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Group not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Test Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py new file mode 100644 index 00000000..49b7d6b5 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py @@ -0,0 +1,63 @@ +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.groups_endpoints import ListGroupMembersEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest + + +@pytest.fixture +def request_data(): + return GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body=["+46701234567", "+46709876543"], + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Group not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListGroupMembersEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ/members" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Check if response is handled and mapped to a list of MSISDNs correctly.""" + result = endpoint.handle_response(mock_response) + + assert isinstance(result, list) + assert result == ["+46701234567", "+46709876543"] + + +def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): + """Test that SmsException is raised when server returns an error.""" + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py new file mode 100644 index 00000000..8d9d38e2 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py @@ -0,0 +1,114 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import ListGroupsEndpoint +from sinch.domains.sms.models.v1.internal.list_groups_request import ListGroupsRequest +from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse + + +@pytest.fixture +def request_data(): + return ListGroupsRequest(page=1, page_size=10) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 1, + "page": 0, + "page_size": 10, + "groups": [{ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListGroupsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, ListGroupsResponse) + assert parsed_response.count == 1 + assert parsed_response.page == 0 + assert parsed_response.page_size == 10 + assert len(parsed_response.groups) == 1 + group = parsed_response.groups[0] + assert group.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert group.name == "Test Group" + assert group.size == 2 + assert group.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert group.auto_update.to == "+15551231234" + assert group.auto_update.add.first_word == "JOIN" + assert group.auto_update.remove.first_word == "LEAVE" + + assert group.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert group.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + + +def test_build_query_params(endpoint): + """Test that query params are built correctly from request data.""" + params = endpoint.build_query_params() + assert params == {"page": 1, "page_size": 10} + diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py new file mode 100644 index 00000000..f1ebba37 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py @@ -0,0 +1,126 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import ReplaceGroupEndpoint +from sinch.domains.sms.models.v1.internal.replace_group_request import ReplaceGroupRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return ReplaceGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ", name="Test Group") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ReplaceGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Test Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + +def test_request_body_excludes_none_fields(endpoint): + """Test that None fields are excluded from the serialized request body.""" + body = json.loads(endpoint.request_body()) + assert body["name"] == "Test Group" + assert "members" not in body + assert "child_groups" not in body + assert "auto_update" not in body + + +def test_request_body_expects_correct_serialization(): + """Test that all fields serialize correctly to the request body.""" + request_data = ReplaceGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + endpoint = ReplaceGroupEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert not body.get("group_id") + assert body["name"] == "Test Group" + assert body["members"] == ["+46701234567", "+46709876543"] + assert body["child_groups"] == ["01FC66621VHDBN119Z8PMV1AHY"] + assert body["auto_update"]["to"] == "+15551231234" + assert body["auto_update"]["add"]["first_word"] == "JOIN" + assert body["auto_update"]["remove"]["first_word"] == "LEAVE" \ No newline at end of file diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py new file mode 100644 index 00000000..def4b698 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py @@ -0,0 +1,124 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import UpdateGroupEndpoint +from sinch.domains.sms.models.v1.internal.update_group_request import UpdateGroupRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return UpdateGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ", name="Updated Group") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Updated Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return UpdateGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Check if response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Updated Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + assert parsed_response.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert parsed_response.modified_at == datetime(2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc) + + +def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): + """Test that SmsException is raised when server returns an error.""" + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + + +def test_request_body_excludes_none_fields(endpoint): + """Test that None fields are excluded from the serialized request body.""" + body = json.loads(endpoint.request_body()) + + assert body["name"] == "Updated Group" + assert "group_id" not in body + assert "add" not in body + assert "remove" not in body + assert "add_from_group" not in body + assert "remove_from_group" not in body + assert "auto_update" not in body + + +def test_request_body_expects_correct_serialization(): + """Test that all fields serialize correctly to the request body.""" + request_data = UpdateGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Updated Group", + add=["+46701234567", "+46709876543"], + remove=["+46701111111"], + add_from_group="01FC66621VHDBN119Z8PMV1AHY", + remove_from_group="01FC66621VHDBN119Z8PMV1AHZ", + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + endpoint = UpdateGroupEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert "group_id" not in body + assert body["name"] == "Updated Group" + assert body["add"] == ["+46701234567", "+46709876543"] + assert body["remove"] == ["+46701111111"] + assert body["add_from_group"] == "01FC66621VHDBN119Z8PMV1AHY" + assert body["remove_from_group"] == "01FC66621VHDBN119Z8PMV1AHZ" + assert body["auto_update"]["to"] == "+15551231234" + assert body["auto_update"]["add"]["first_word"] == "JOIN" + assert body["auto_update"]["remove"]["first_word"] == "LEAVE" diff --git a/tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py new file mode 100644 index 00000000..724b5bb0 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py @@ -0,0 +1,31 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest + + +def test_group_id_request_expects_valid_group_id(): + """Test that the model correctly parses a valid group_id.""" + request = GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert request.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_group_id_request_expects_group_id_as_string(): + """Test that group_id must be a string.""" + with pytest.raises(ValidationError): + GroupIdRequest(group_id=12345) + + with pytest.raises(ValidationError): + GroupIdRequest(group_id=None) + + +def test_group_id_request_expects_model_dump(): + """Test that model_dump correctly serializes the request.""" + request = GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + dumped = request.model_dump(by_alias=True) + assert dumped["group_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + + dumped_no_alias = request.model_dump(by_alias=False) + assert dumped_no_alias["group_id"] == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/domains/sms/v1/models/internal/test_group_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_group_request_model.py new file mode 100644 index 00000000..125cf593 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_group_request_model.py @@ -0,0 +1,58 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest + + +def test_group_request_expects_all_defaults_to_none(): + """Test that all optional fields default to None.""" + model = GroupRequest() + + assert model.name is None + assert model.members is None + assert model.child_groups is None + assert model.auto_update is None + + +def test_group_request_expects_parsed_input(): + """Test that the model correctly parses a full valid input.""" + model = GroupRequest( + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.name == "Test Group" + assert model.members == ["+46701234567", "+46709876543"] + assert model.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_group_request_expects_strict_str_rejects_int(): + """Test that StrictStr fields reject integer values.""" + with pytest.raises(ValidationError): + GroupRequest(name=123) + + +def test_group_request_expects_auto_update_nested_parsing(): + """Test that auto_update parses nested add and remove keywords correctly.""" + model = GroupRequest( + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN", "second_word": "NOW"}, + "remove": {"first_word": "LEAVE"}, + } + ) + + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.add.second_word == "NOW" + assert model.auto_update.remove.first_word == "LEAVE" + assert model.auto_update.remove.second_word is None diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py new file mode 100644 index 00000000..0d42dcfc --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py @@ -0,0 +1,29 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.list_groups_request import ListGroupsRequest + + +def test_list_groups_request_expects_defaults(): + """Test that all optional fields default to None.""" + model = ListGroupsRequest() + + assert model.page is None + assert model.page_size is None + + +def test_list_groups_request_expects_parsed_input(): + """Test that the model correctly parses page and page_size.""" + model = ListGroupsRequest(page=1, page_size=10) + + assert model.page == 1 + assert model.page_size == 10 + + +def test_list_groups_request_expects_strict_int_rejects_str(): + """Test that StrictInt fields reject string values.""" + with pytest.raises(ValidationError): + ListGroupsRequest(page="one") + + with pytest.raises(ValidationError): + ListGroupsRequest(page_size="ten") diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py new file mode 100644 index 00000000..76723f8a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py @@ -0,0 +1,50 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.replace_group_request import ReplaceGroupRequest + + +def test_replace_group_request_expects_required_group_id(): + """Test that group_id is required.""" + with pytest.raises(ValidationError): + ReplaceGroupRequest() + + +def test_replace_group_request_expects_optional_fields_default_to_none(): + """Test that all optional fields default to None when only group_id is provided.""" + model = ReplaceGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name is None + assert model.members is None + assert model.child_groups is None + assert model.auto_update is None + + +def test_replace_group_request_expects_parsed_input(): + """Test that the model correctly parses a full valid input.""" + model = ReplaceGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Replaced Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name == "Replaced Group" + assert model.members == ["+46701234567", "+46709876543"] + assert model.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_replace_group_request_expects_group_id_as_string(): + """Test that group_id must be a string.""" + with pytest.raises(ValidationError): + ReplaceGroupRequest(group_id=123) diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py new file mode 100644 index 00000000..10826d5d --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py @@ -0,0 +1,56 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.update_group_request import UpdateGroupRequest + + +def test_update_group_request_expects_required_group_id(): + """Test that group_id is required.""" + with pytest.raises(ValidationError): + UpdateGroupRequest() + + +def test_update_group_request_expects_optional_fields_default_to_none(): + """Test that all optional fields default to None when only group_id is provided.""" + model = UpdateGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.add is None + assert model.remove is None + assert model.name is None + assert model.add_from_group is None + assert model.remove_from_group is None + assert model.auto_update is None + + +def test_update_group_request_expects_parsed_input(): + """Test that the model correctly parses a full valid input.""" + model = UpdateGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Updated Group", + add=["+46701234567", "+46709876543"], + remove=["+46701111111"], + add_from_group="01FC66621VHDBN119Z8PMV1AHY", + remove_from_group="01FC66621VHDBN119Z8PMV1AHZ", + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name == "Updated Group" + assert model.add == ["+46701234567", "+46709876543"] + assert model.remove == ["+46701111111"] + assert model.add_from_group == "01FC66621VHDBN119Z8PMV1AHY" + assert model.remove_from_group == "01FC66621VHDBN119Z8PMV1AHZ" + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_update_group_request_expects_strict_str_rejects_int(): + """Test that StrictStr fields reject integer values.""" + with pytest.raises(ValidationError): + UpdateGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ", name=123) diff --git a/tests/unit/domains/sms/v1/models/response/test_group_response_model.py b/tests/unit/domains/sms/v1/models/response/test_group_response_model.py new file mode 100644 index 00000000..02b07392 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_group_response_model.py @@ -0,0 +1,81 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +def test_group_response_expects_all_defaults_to_none(): + """Test that all optional fields default to None.""" + model = GroupResponse() + + assert model.id is None + assert model.name is None + assert model.size is None + assert model.created_at is None + assert model.modified_at is None + assert model.child_groups is None + assert model.auto_update is None + + +def test_group_response_expects_valid_input(): + """Test that the model correctly parses a full valid input.""" + model = GroupResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + name="Test Group", + size=2, + created_at="2024-06-06T09:22:14.304Z", + modified_at="2024-06-06T09:22:48.054Z", + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name == "Test Group" + assert model.size == 2 + assert model.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_group_response_expects_datetime_parsing(): + """Test that ISO 8601 timestamp strings are parsed to datetime objects.""" + model = GroupResponse( + created_at="2024-06-06T09:22:14.304Z", + modified_at="2024-06-06T09:22:48.054Z", + ) + + assert model.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert model.modified_at == datetime(2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc) + + +def test_group_response_expects_auto_update_without_keywords(): + """Test that auto_update parses correctly when add and remove are omitted.""" + model = GroupResponse( + auto_update={"to": "+15551231234"}, + ) + + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add is None + assert model.auto_update.remove is None + + +def test_group_response_expects_strict_str_rejects_int(): + """Test that StrictStr fields reject integer values.""" + with pytest.raises(ValidationError): + GroupResponse(id=123) + + with pytest.raises(ValidationError): + GroupResponse(name=456) + + +def test_group_response_expects_strict_int_rejects_str(): + """Test that StrictInt fields reject string values.""" + with pytest.raises(ValidationError): + GroupResponse(size="two") diff --git a/tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py b/tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py new file mode 100644 index 00000000..929c5824 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py @@ -0,0 +1,71 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse + + +def test_list_groups_response_expects_all_defaults_to_none(): + """Test that all optional fields default to None.""" + model = ListGroupsResponse() + + assert model.count is None + assert model.page is None + assert model.page_size is None + assert model.groups is None + + +def test_list_groups_response_expects_valid_input(): + """Test that the model correctly parses a full valid input.""" + model = ListGroupsResponse( + count=1, + page=0, + page_size=10, + groups=[{"id": "01FC66621XXXXX119Z8PMV1QPQ", "name": "Test Group", "size": 2}], + ) + + assert model.count == 1 + assert model.page == 0 + assert model.page_size == 10 + assert len(model.groups) == 1 + assert isinstance(model.groups[0], GroupResponse) + assert model.groups[0].id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.groups[0].name == "Test Group" + assert model.groups[0].size == 2 + + +def test_list_groups_response_expects_empty_groups_list(): + """Test that an empty groups list is handled correctly.""" + model = ListGroupsResponse(count=0, page=0, page_size=10, groups=[]) + + assert model.groups == [] + assert model.count == 0 + + +def test_list_groups_response_expects_content_returns_groups(): + """Test that content property returns the groups list when populated.""" + model = ListGroupsResponse( + groups=[{"id": "01FC66621XXXXX119Z8PMV1QPQ"}], + ) + + assert model.content == model.groups + assert len(model.content) == 1 + + +def test_list_groups_response_expects_content_returns_empty_list_when_no_groups(): + """Test that content property returns [] when groups is None.""" + model = ListGroupsResponse() + + assert model.content == [] + + +def test_list_groups_response_expects_strict_int_rejects_str(): + """Test that StrictInt fields reject string values.""" + with pytest.raises(ValidationError): + ListGroupsResponse(count="one") + + with pytest.raises(ValidationError): + ListGroupsResponse(page="zero") + + with pytest.raises(ValidationError): + ListGroupsResponse(page_size="ten") diff --git a/tests/unit/domains/sms/v1/test_groups.py b/tests/unit/domains/sms/v1/test_groups.py new file mode 100644 index 00000000..bc4f6467 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_groups.py @@ -0,0 +1,211 @@ +import pytest +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1.groups import Groups +from sinch.domains.sms.api.v1.internal.groups_endpoints import CreateGroupEndpoint, DeleteGroupEndpoint, GetGroupEndpoint, ListGroupMembersEndpoint, ListGroupsEndpoint, ReplaceGroupEndpoint, UpdateGroupEndpoint +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse + +@pytest.fixture +def mock_group_response(): + """Sample GroupResponse for testing.""" + return GroupResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + ) + + +def test_groups_create_correct_request( + mock_sinch_client_sms, mocker, mock_group_response +): + """Test that create sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(CreateGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + response = groups.create( + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].name == "Test Group" + assert kwargs["request_data"].members == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert kwargs["request_data"].auto_update.to == "+15551231234" + assert kwargs["request_data"].auto_update.add.first_word == "JOIN" + assert kwargs["request_data"].auto_update.remove.first_word == "LEAVE" + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_list_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and handles the response properly.""" + + mock_list_response = ListGroupsResponse(count=0, page=1, page_size=0, groups=[]) + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_list_response + + spy_endpoint = mocker.spy(ListGroupsEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.list( + page=0, + page_size=10 + ) + + 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 == 10 + + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_list_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_get_correct_request(mock_sinch_client_sms, mocker, mock_group_response): + """Test that get sends the correct request and handles the response properly.""" + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(GetGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.get(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_replace_correct_request(mock_sinch_client_sms, mocker, mock_group_response): + """Test that replace sends the correct request and handles the response properly.""" + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(ReplaceGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.replace( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Replaced Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].name == "Replaced Group" + assert kwargs["request_data"].members == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert kwargs["request_data"].auto_update.to == "+15551231234" + assert kwargs["request_data"].auto_update.add.first_word == "JOIN" + assert kwargs["request_data"].auto_update.remove.first_word == "LEAVE" + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_update_correct_request(mock_sinch_client_sms, mocker, mock_group_response): + """Test that update sends the correct request and handles the response properly.""" + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(UpdateGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.update( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Updated Group", + add=["+46701234567", "+46709876543"], + remove=["+46701111111"], + add_from_group="01FC66621VHDBN119Z8PMV1AHY", + remove_from_group="01FC66621VHDBN119Z8PMV1AHZ", + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].name == "Updated Group" + assert kwargs["request_data"].add == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].remove == ["+46701111111"] + assert kwargs["request_data"].add_from_group == "01FC66621VHDBN119Z8PMV1AHY" + assert kwargs["request_data"].remove_from_group == "01FC66621VHDBN119Z8PMV1AHZ" + assert kwargs["request_data"].auto_update.to == "+15551231234" + assert kwargs["request_data"].auto_update.add.first_word == "JOIN" + assert kwargs["request_data"].auto_update.remove.first_word == "LEAVE" + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_delete_correct_request(mock_sinch_client_sms, mocker): + """Test that delete sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = None + + spy_endpoint = mocker.spy(DeleteGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + response = groups.delete(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert response is None + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_list_members_correct_request(mock_sinch_client_sms, mocker): + """Test that list_members sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = [ + "+46701234567", + "+46709876543", + ] + + spy_endpoint = mocker.spy(ListGroupMembersEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + response = groups.list_members(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert response == ["+46701234567", "+46709876543"] + mock_sinch_client_sms.configuration.transport.request.assert_called_once() From a8053e4e5acc7554f8306de74719935e963a2468 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 1 Jun 2026 16:19:22 +0200 Subject: [PATCH 07/20] test(sms): add groups e2e tests --- .github/workflows/ci.yml | 2 + .../sms/api/v1/internal/groups_endpoints.py | 1 - tests/e2e/sms/features/steps/batches.steps.py | 38 +--- tests/e2e/sms/features/steps/common.steps.py | 24 +++ .../features/steps/delivery_reports.steps.py | 28 +-- tests/e2e/sms/features/steps/groups.steps.py | 173 ++++++++++++++++++ 6 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 tests/e2e/sms/features/steps/common.steps.py create mode 100644 tests/e2e/sms/features/steps/groups.steps.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0534969d..f97feb3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,8 @@ jobs: cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/groups.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/groups_servicePlanId.feature ./tests/e2e/sms/features/ 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/ diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 207b803f..8e9e4a24 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -149,7 +149,6 @@ def request_body(self): request_data = self.request_data.model_dump( mode="json", by_alias=True, - exclude_none=True, exclude=path_params, ) return json.dumps(request_data) diff --git a/tests/e2e/sms/features/steps/batches.steps.py b/tests/e2e/sms/features/steps/batches.steps.py index bf59f4a0..d51af0d2 100644 --- a/tests/e2e/sms/features/steps/batches.steps.py +++ b/tests/e2e/sms/features/steps/batches.steps.py @@ -1,46 +1,10 @@ from datetime import datetime, timezone -from behave import given, when, then +from behave import when, then from sinch.domains.sms.models.v1.types import BatchResponse from sinch.domains.sms.models.v1.response.dry_run_response import DryRunResponse from sinch.domains.sms.models.v1.shared.text_response import TextResponse -def _setup_sinch_client(context, use_service_plan_auth=False): - """Helper function to setup Sinch client""" - from sinch import SinchClient - - if use_service_plan_auth: - sinch = SinchClient( - service_plan_id='CappyPremiumPlan', - sms_api_token='HappyCappyToken', - ) - sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' - else: - sinch = SinchClient( - project_id='tinyfrog-jump-high-over-lilypadbasin', - key_id='keyId', - key_secret='keySecret', - ) - - sinch.configuration.auth_origin = 'http://localhost:3011' - sinch.configuration.sms_origin = 'http://localhost:3017' - - context.sinch = sinch - context.sms = sinch.sms - - -@given('the SMS service "Batches" is available') -def step_sms_service_batches_available(context): - """Ensures the Sinch client is initialized""" - _setup_sinch_client(context, use_service_plan_auth=False) - - -@given('the SMS service "Batches" is available and is configured for servicePlanId authentication') -def step_sms_service_batches_available_with_service_plan(context): - """Ensures the Sinch client is initialized with service_plan_id authentication""" - _setup_sinch_client(context, use_service_plan_auth=True) - - @when('I send a request to send a text message') def step_send_text_message(context): """Send a text message""" diff --git a/tests/e2e/sms/features/steps/common.steps.py b/tests/e2e/sms/features/steps/common.steps.py new file mode 100644 index 00000000..6fb073ff --- /dev/null +++ b/tests/e2e/sms/features/steps/common.steps.py @@ -0,0 +1,24 @@ +from behave import given +from sinch.domains.sms.sms import SMS + + +@given('the SMS service "{service_name}" is available') +def step_sms_service_available(context, service_name): + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms + + +@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') +def step_sms_service_available_with_service_plan(context, service_name): + from sinch import SinchClient + + context.sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + context.sinch.configuration.auth_origin = 'http://localhost:3011' + context.sinch.configuration.sms_origin = 'http://localhost:3017' + context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms diff --git a/tests/e2e/sms/features/steps/delivery_reports.steps.py b/tests/e2e/sms/features/steps/delivery_reports.steps.py index 5234723e..a192e971 100644 --- a/tests/e2e/sms/features/steps/delivery_reports.steps.py +++ b/tests/e2e/sms/features/steps/delivery_reports.steps.py @@ -1,32 +1,6 @@ from datetime import datetime, timezone -from behave import given, when, then +from behave import when, then from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport -from sinch.domains.sms.sms import SMS - - -@given('the SMS service "{service_name}" is available') -def step_sms_service_available(context, service_name): - """Ensures the Sinch client is initialized""" - assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' - assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' - context.sms = context.sinch.sms - - -@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') -def step_sms_service_available_with_service_plan(context, service_name): - """Ensures the Sinch client is initialized with service_plan_id authentication""" - from sinch import SinchClient - - # Create a new client with service_plan_id authentication - context.sinch = SinchClient( - service_plan_id='CappyPremiumPlan', - sms_api_token='HappyCappyToken', - ) - context.sinch.configuration.auth_origin = 'http://localhost:3011' - context.sinch.configuration.sms_origin = 'http://localhost:3017' - context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' - assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' - context.sms = context.sinch.sms @when('I send a request to retrieve a summary SMS delivery report') diff --git a/tests/e2e/sms/features/steps/groups.steps.py b/tests/e2e/sms/features/steps/groups.steps.py new file mode 100644 index 00000000..8a647cc3 --- /dev/null +++ b/tests/e2e/sms/features/steps/groups.steps.py @@ -0,0 +1,173 @@ +from behave import when, then +from datetime import datetime, timezone + + +@when('I send a request to create an SMS group') +def step_create_sms_group(context): + context.response = context.sms.groups.create( + name='Group master', + members=['+12017778888', '+12018887777'], + child_groups=['01W4FFL35P4NC4K35SUBGROUP1'], + ) + +@when('I send a request to retrieve an SMS group') +def step_retrieve_sms_group(context): + context.response = context.sms.groups.get( + group_id='01W4FFL35P4NC4K35SMSGROUP1' + ) + + + +@then('the response contains the SMS group details') +def step_validate_sms_group_details(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP1' + assert data.name == 'Group master' + assert data.size == 2 + assert data.created_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.child_groups == ['01W4FFL35P4NC4K35SUBGROUP1'] + + + + +@when('I send a request to list the existing SMS groups') +def step_list_existing_sms_groups(context): + context.response = context.sms.groups.list() + +@when('I send a request to list all the SMS groups') +def step_list_all_sms_groups(context): + response = context.sms.groups.list(page_size=2) + groups_list = [] + for group in response.iterator(): + groups_list.append(group) + context.groups_list = groups_list + + +@then('the response contains "{count}" SMS groups') +def step_validate_groups_count(context, count): + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + + +@when('I iterate manually over the SMS groups pages') +def step_iterate_manually_sms_groups(context): + context.list_response = context.sms.groups.list(page_size=2) + context.groups_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.groups_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_end_of_pages = True + + +@then('the SMS groups list contains "{count}" SMS groups') +def step_validate_groups_list_count(context, count): + expected_count = int(count) + assert len(context.groups_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.groups_list)}' + + +@then('the SMS groups iteration result contains the data from "{count}" pages') +def step_validate_groups_pages_count(context, count): + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + + +@when('I send a request to update an SMS group') +def step_update_sms_group(context): + context.response = context.sms.groups.update( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + name='Updated group name', + add=['+12017771111', '+12017772222'], + remove=['+12017773333', '+12017774444'], + add_from_group='01W4FFL35P4NC4K35SMSGROUP2', + remove_from_group='01W4FFL35P4NC4K35SMSGROUP3', + ) + + +@then('the response contains the updated SMS group details') +def step_validate_updated_sms_group_details(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP1' + assert data.name == 'Updated group name' + assert data.size == 6 + assert data.created_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 9, 19, 58, 147000, tzinfo=timezone.utc) + assert data.child_groups == ['01W4FFL35P4NC4K35SUBGROUP1'] + + +@when('I send a request to update an SMS group to remove its name') +def step_update_sms_group_remove_name(context): + context.response = context.sms.groups.update( + group_id='01W4FFL35P4NC4K35SMSGROUP2', + name=None, + ) + + +@then('the response contains the updated SMS group details where the name has been removed') +def step_validate_updated_sms_group_name_removed(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP2' + assert data.name is None + assert data.size == 5 + assert data.created_at == datetime(2024, 6, 6, 12, 45, 18, 761000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 13, 12, 5, 137000, tzinfo=timezone.utc) + assert data.child_groups == [] + + +@when('I send a request to replace an SMS group') +def step_replace_sms_group(context): + context.response = context.sms.groups.replace( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + name='Replacement group', + members=['+12018881111', '+12018882222', '+12018883333'], + ) + + +@then('the response contains the replaced SMS group details') +def step_validate_replaced_sms_group_details(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP1' + assert data.name == 'Replacement group' + assert data.size == 3 + assert data.created_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 8, 21, 9, 39, 36, 679000, tzinfo=timezone.utc) + assert data.child_groups == ['01W4FFL35P4NC4K35SUBGROUP1'] + + +@when('I send a request to delete an SMS group') +def step_delete_sms_group(context): + context.response = context.sms.groups.delete( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + ) + + +@then('the delete SMS group response contains no data') +def step_validate_delete_sms_group_response(context): + assert context.response is None + + +@when('I send a request to list the members of an SMS group') +def step_list_sms_group_members(context): + context.response = context.sms.groups.list_members( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + ) + + +@then('the response contains the phone numbers of the SMS group') +def step_validate_sms_group_members(context): + assert isinstance(context.response, list) + assert context.response == ['12018881111', '12018882222', '12018883333'] From de86292b57ec7b3db75408b082b25b1db85a7a17 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 1 Jun 2026 16:27:56 +0200 Subject: [PATCH 08/20] test(sms): fix update group excludes none --- .../endpoints/groups/test_update_group_endpoint.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py index def4b698..fa340031 100644 --- a/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py @@ -86,17 +86,17 @@ def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_res assert exc_info.value.http_response.status_code == 400 -def test_request_body_excludes_none_fields(endpoint): - """Test that None fields are excluded from the serialized request body.""" +def test_request_body_not_excludes_none_fields(endpoint): + """Test that None fields are not excluded from the serialized request body.""" body = json.loads(endpoint.request_body()) assert body["name"] == "Updated Group" assert "group_id" not in body - assert "add" not in body - assert "remove" not in body - assert "add_from_group" not in body - assert "remove_from_group" not in body - assert "auto_update" not in body + assert body["add"] is None + assert body["remove"] is None + assert body["add_from_group"] is None + assert body["remove_from_group"] is None + assert body["auto_update"] is None def test_request_body_expects_correct_serialization(): From f808e08481d17e44a6655cffbe1a0b54d9cc1ed9 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 1 Jun 2026 17:06:08 +0200 Subject: [PATCH 09/20] feat(sms): fix kwargs in list_members and delete --- sinch/domains/sms/api/v1/groups.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sinch/domains/sms/api/v1/groups.py b/sinch/domains/sms/api/v1/groups.py index 77bd843f..9713cfd3 100644 --- a/sinch/domains/sms/api/v1/groups.py +++ b/sinch/domains/sms/api/v1/groups.py @@ -238,7 +238,7 @@ def update( ) return self._request(UpdateGroupEndpoint, request_data) - def delete(self, group_id: str) -> None: + def delete(self, group_id: str, **kwargs) -> None: """ This operation deletes the group with the provided group ID. @@ -250,10 +250,10 @@ def delete(self, group_id: str) -> None: For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ - request_data = GroupIdRequest(group_id=group_id) + request_data = GroupIdRequest(group_id=group_id, **kwargs) return self._request(DeleteGroupEndpoint, request_data) - def list_members(self, group_id: str) -> List[str]: + def list_members(self, group_id: str, **kwargs) -> List[str]: """ This operation retrieves the members of the group with the provided group ID. @@ -265,5 +265,5 @@ def list_members(self, group_id: str) -> List[str]: For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ - request_data = GroupIdRequest(group_id=group_id) + request_data = GroupIdRequest(group_id=group_id, **kwargs) return self._request(ListGroupMembersEndpoint, request_data) From c27ff7833115442c0c7fc468fc660d9f50751171 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Tue, 2 Jun 2026 15:58:52 +0200 Subject: [PATCH 10/20] feat(sms): fix group list snippet --- examples/snippets/sms/groups/list/snippet.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/snippets/sms/groups/list/snippet.py b/examples/snippets/sms/groups/list/snippet.py index af6ee3cf..cb756e83 100644 --- a/examples/snippets/sms/groups/list/snippet.py +++ b/examples/snippets/sms/groups/list/snippet.py @@ -21,9 +21,7 @@ sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" ) -groups: Paginator[GroupResponse] = sinch_client.sms.groups.list( - name="Test Group", members=["+1234567890", "+1987654321"] -) +groups: Paginator[GroupResponse] = sinch_client.sms.groups.list() for group in groups: print(f"Group:\n{group}") From 1ce96ac7a1d2deee9a99425b5104b59859e060fc Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 3 Jun 2026 10:38:41 +0200 Subject: [PATCH 11/20] feat(sms): fix group list snippet --- examples/snippets/sms/groups/list/snippet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/snippets/sms/groups/list/snippet.py b/examples/snippets/sms/groups/list/snippet.py index cb756e83..8490d2c5 100644 --- a/examples/snippets/sms/groups/list/snippet.py +++ b/examples/snippets/sms/groups/list/snippet.py @@ -23,5 +23,5 @@ groups: Paginator[GroupResponse] = sinch_client.sms.groups.list() -for group in groups: +for group in groups.iterator(): print(f"Group:\n{group}") From b94020d664af10e3cb2cbeaa22e5bf8210609ecd Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 4 Jun 2026 10:54:08 +0200 Subject: [PATCH 12/20] feat(sms): address PR review comments --- .../snippets/sms/groups/create/snippet.py | 5 +-- examples/snippets/sms/groups/get/snippet.py | 4 +- examples/snippets/sms/groups/list/snippet.py | 7 ++-- .../sms/groups/list_members/snippet.py | 6 ++- .../snippets/sms/groups/replace/snippet.py | 4 +- .../snippets/sms/groups/update/snippet.py | 2 +- .../sms/api/v1/{groups.py => groups_apis.py} | 13 ++++-- .../sms/api/v1/internal/groups_endpoints.py | 8 +++- .../sms/models/v1/response/__init__.py | 4 ++ .../response/list_group_members_response.py | 13 ++++++ sinch/domains/sms/sms.py | 2 +- tests/e2e/sms/features/steps/groups.steps.py | 4 +- .../test_list_group_members_endpoint.py | 9 ++-- .../test_list_group_members_response_model.py | 41 +++++++++++++++++++ tests/unit/domains/sms/v1/test_groups.py | 18 ++++---- 15 files changed, 106 insertions(+), 34 deletions(-) rename sinch/domains/sms/api/v1/{groups.py => groups_apis.py} (96%) create mode 100644 sinch/domains/sms/models/v1/response/list_group_members_response.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py diff --git a/examples/snippets/sms/groups/create/snippet.py b/examples/snippets/sms/groups/create/snippet.py index a3c30f3c..fd60244c 100644 --- a/examples/snippets/sms/groups/create/snippet.py +++ b/examples/snippets/sms/groups/create/snippet.py @@ -9,7 +9,6 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.domains.sms.api.v1.groups import GroupResponse load_dotenv() @@ -20,8 +19,8 @@ sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" ) -response: GroupResponse = sinch_client.sms.groups.create( - name="Test Group", members=["+1234567890", "+1987654321"] +response = sinch_client.sms.groups.create( + name="Sinch Python SDK group", members=["+1234567890", "+1987654321"] ) print(f"Group created:\n{response}") diff --git a/examples/snippets/sms/groups/get/snippet.py b/examples/snippets/sms/groups/get/snippet.py index 045da6b3..2c30fba8 100644 --- a/examples/snippets/sms/groups/get/snippet.py +++ b/examples/snippets/sms/groups/get/snippet.py @@ -9,7 +9,6 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.domains.sms.api.v1.groups import GroupResponse load_dotenv() @@ -20,9 +19,10 @@ sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" ) +# The ID of the group to retrieve GROUP_ID = "GROUP_ID" -response: GroupResponse = sinch_client.sms.groups.get( +response = sinch_client.sms.groups.get( group_id=GROUP_ID ) diff --git a/examples/snippets/sms/groups/list/snippet.py b/examples/snippets/sms/groups/list/snippet.py index 8490d2c5..51ac6f36 100644 --- a/examples/snippets/sms/groups/list/snippet.py +++ b/examples/snippets/sms/groups/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.api.v1.groups import GroupResponse load_dotenv() @@ -21,7 +19,8 @@ sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" ) -groups: Paginator[GroupResponse] = sinch_client.sms.groups.list() +groups = sinch_client.sms.groups.list() +print("Available groups:\n") for group in groups.iterator(): - print(f"Group:\n{group}") + print(group) diff --git a/examples/snippets/sms/groups/list_members/snippet.py b/examples/snippets/sms/groups/list_members/snippet.py index ee9bb2c2..d3478aea 100644 --- a/examples/snippets/sms/groups/list_members/snippet.py +++ b/examples/snippets/sms/groups/list_members/snippet.py @@ -23,6 +23,8 @@ # The ID of the group to list members for group_id = "GROUP_ID" -members: List[str] = sinch_client.sms.groups.list_members(group_id=group_id) +members = sinch_client.sms.groups.list_members(group_id=group_id) -print(f"Group {group_id} members: {members}") +print("Available members:\n") +for member in members.iterator(): + print(member) diff --git a/examples/snippets/sms/groups/replace/snippet.py b/examples/snippets/sms/groups/replace/snippet.py index 12f457ed..ed43b521 100644 --- a/examples/snippets/sms/groups/replace/snippet.py +++ b/examples/snippets/sms/groups/replace/snippet.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.domains.sms.api.v1.groups import GroupResponse +from sinch.domains.sms.api.v1.groups_apis import GroupResponse load_dotenv() @@ -24,8 +24,6 @@ group_id = "GROUP_ID" response: GroupResponse = sinch_client.sms.groups.replace( - group_id=group_id, - name="Replaced Group", members=["+1234567890", "+1987654321"], ) diff --git a/examples/snippets/sms/groups/update/snippet.py b/examples/snippets/sms/groups/update/snippet.py index 0a0fbaad..7ac08841 100644 --- a/examples/snippets/sms/groups/update/snippet.py +++ b/examples/snippets/sms/groups/update/snippet.py @@ -9,7 +9,7 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.domains.sms.api.v1.groups import GroupResponse +from sinch.domains.sms.api.v1.groups_apis import GroupResponse load_dotenv() diff --git a/sinch/domains/sms/api/v1/groups.py b/sinch/domains/sms/api/v1/groups_apis.py similarity index 96% rename from sinch/domains/sms/api/v1/groups.py rename to sinch/domains/sms/api/v1/groups_apis.py index 9713cfd3..69532f47 100644 --- a/sinch/domains/sms/api/v1/groups.py +++ b/sinch/domains/sms/api/v1/groups_apis.py @@ -253,17 +253,22 @@ def delete(self, group_id: str, **kwargs) -> None: request_data = GroupIdRequest(group_id=group_id, **kwargs) return self._request(DeleteGroupEndpoint, request_data) - def list_members(self, group_id: str, **kwargs) -> List[str]: + def list_members(self, group_id: str, **kwargs) -> Paginator[str]: """ This operation retrieves the members of the group with the provided group ID. :param group_id: ID of the group whose members are being retrieved. :type group_id: str - :returns: List of phone numbers (MSISDNs) in E.164 format. - :rtype: List[str] + :returns: Paginator[str] + :rtype: Paginator[str] For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request_data = GroupIdRequest(group_id=group_id, **kwargs) - return self._request(ListGroupMembersEndpoint, request_data) + endpoint = ListGroupMembersEndpoint( + project_id=self._get_path_identifier(), + request_data=request_data, + ) + endpoint.set_authentication_method(self._sinch) + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 8e9e4a24..75f5f29a 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -22,6 +22,9 @@ UpdateGroupRequest, ) from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.response.list_group_members_response import ( + ListGroupMembersResponse, +) from sinch.domains.sms.models.v1.response.list_groups_response import ( ListGroupsResponse, ) @@ -199,7 +202,7 @@ def __init__(self, project_id: str, request_data: GroupIdRequest): self.project_id = project_id self.request_data = request_data - def handle_response(self, response: HTTPResponse) -> List[str]: + def handle_response(self, response: HTTPResponse) -> ListGroupMembersResponse: try: super(ListGroupMembersEndpoint, self).handle_response(response) except SmsException as e: @@ -208,4 +211,5 @@ def handle_response(self, response: HTTPResponse) -> List[str]: response=e.http_response, is_from_server=e.is_from_server, ) - return TypeAdapter(conlist(StrictStr)).validate_python(response.body) + members = TypeAdapter(conlist(StrictStr)).validate_python(response.body) + return ListGroupMembersResponse(members=members) diff --git a/sinch/domains/sms/models/v1/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py index 410918de..5fee6d37 100644 --- a/sinch/domains/sms/models/v1/response/__init__.py +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -11,6 +11,9 @@ from sinch.domains.sms.models.v1.response.list_groups_response import ( ListGroupsResponse, ) +from sinch.domains.sms.models.v1.response.list_group_members_response import ( + ListGroupMembersResponse, +) from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( RecipientDeliveryReport, ) @@ -20,6 +23,7 @@ "DryRunResponse", "GroupResponse", "ListBatchesResponse", + "ListGroupMembersResponse", "ListGroupsResponse", "RecipientDeliveryReport", ] diff --git a/sinch/domains/sms/models/v1/response/list_group_members_response.py b/sinch/domains/sms/models/v1/response/list_group_members_response.py new file mode 100644 index 00000000..f0d5c59f --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_group_members_response.py @@ -0,0 +1,13 @@ +from typing import List + +from pydantic import StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import BaseModelConfigurationResponse + + +class ListGroupMembersResponse(BaseModelConfigurationResponse): + members: conlist(StrictStr) + + @property + def content(self) -> List[str]: + return self.members diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index 06c5553e..967cef35 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,7 +2,7 @@ Batches, DeliveryReports, ) -from sinch.domains.sms.api.v1.groups import Groups +from sinch.domains.sms.api.v1.groups_apis import Groups from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent diff --git a/tests/e2e/sms/features/steps/groups.steps.py b/tests/e2e/sms/features/steps/groups.steps.py index 8a647cc3..cd817f4b 100644 --- a/tests/e2e/sms/features/steps/groups.steps.py +++ b/tests/e2e/sms/features/steps/groups.steps.py @@ -169,5 +169,5 @@ def step_list_sms_group_members(context): @then('the response contains the phone numbers of the SMS group') def step_validate_sms_group_members(context): - assert isinstance(context.response, list) - assert context.response == ['12018881111', '12018882222', '12018883333'] + assert context.response.has_next_page is False + assert context.response.content() == ['12018881111', '12018882222', '12018883333'] diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py index 49b7d6b5..93002408 100644 --- a/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py @@ -47,11 +47,14 @@ def test_build_url(endpoint, mock_sinch_client_sms): def test_handle_response_expects_correct_mapping(endpoint, mock_response): - """Check if response is handled and mapped to a list of MSISDNs correctly.""" + """Check if response is handled and mapped to ListGroupMembersResponse correctly.""" + from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + result = endpoint.handle_response(mock_response) - assert isinstance(result, list) - assert result == ["+46701234567", "+46709876543"] + assert isinstance(result, ListGroupMembersResponse) + assert result.members == ["+46701234567", "+46709876543"] + assert result.content == ["+46701234567", "+46709876543"] def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): diff --git a/tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py b/tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py new file mode 100644 index 00000000..59f7562b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + + +def test_list_group_members_response_expects_valid_input(): + """Test that the model correctly parses a list of MSISDNs.""" + model = ListGroupMembersResponse(members=["+46701234567", "+46709876543"]) + + assert model.members == ["+46701234567", "+46709876543"] + + +def test_list_group_members_response_expects_content_returns_members(): + """Test that content property returns the members list.""" + model = ListGroupMembersResponse(members=["+46701234567"]) + + assert model.content == model.members + + +def test_list_group_members_response_expects_empty_members_list(): + """Test that an empty members list is handled correctly.""" + model = ListGroupMembersResponse(members=[]) + + assert model.members == [] + assert model.content == [] + + +def test_list_group_members_response_expects_no_pagination_fields(): + """Test that count, page, page_size are absent so SMSPaginator sets has_next_page=False.""" + model = ListGroupMembersResponse(members=["+46701234567"]) + + assert getattr(model, "count", None) is None + assert getattr(model, "page", None) is None + assert getattr(model, "page_size", None) is None + + +def test_list_group_members_response_expects_strict_str_rejects_non_string(): + """Test that non-string members are rejected.""" + with pytest.raises(ValidationError): + ListGroupMembersResponse(members=[123]) diff --git a/tests/unit/domains/sms/v1/test_groups.py b/tests/unit/domains/sms/v1/test_groups.py index bc4f6467..569d8cd1 100644 --- a/tests/unit/domains/sms/v1/test_groups.py +++ b/tests/unit/domains/sms/v1/test_groups.py @@ -1,6 +1,6 @@ import pytest from sinch.core.pagination import SMSPaginator -from sinch.domains.sms.api.v1.groups import Groups +from sinch.domains.sms.api.v1.groups_apis import Groups from sinch.domains.sms.api.v1.internal.groups_endpoints import CreateGroupEndpoint, DeleteGroupEndpoint, GetGroupEndpoint, ListGroupMembersEndpoint, ListGroupsEndpoint, ReplaceGroupEndpoint, UpdateGroupEndpoint from sinch.domains.sms.models.v1.response.group_response import GroupResponse from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse @@ -190,11 +190,11 @@ def test_groups_delete_correct_request(mock_sinch_client_sms, mocker): def test_groups_list_members_correct_request(mock_sinch_client_sms, mocker): - """Test that list_members sends the correct request and handles the response properly.""" - mock_sinch_client_sms.configuration.transport.request.return_value = [ - "+46701234567", - "+46709876543", - ] + """Test that list_members sends the correct request and returns an SMSPaginator.""" + from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + + mock_members_response = ListGroupMembersResponse(members=["+46701234567", "+46709876543"]) + mock_sinch_client_sms.configuration.transport.request.return_value = mock_members_response spy_endpoint = mocker.spy(ListGroupMembersEndpoint, "__init__") @@ -207,5 +207,9 @@ def test_groups_list_members_correct_request(mock_sinch_client_sms, mocker): assert kwargs["project_id"] == "test_project_id" assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" - assert response == ["+46701234567", "+46709876543"] + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.has_next_page is False + assert response.result == mock_members_response + assert response.content() == ["+46701234567", "+46709876543"] mock_sinch_client_sms.configuration.transport.request.assert_called_once() From 4794003fa29a66425da0c300e1c1847e98b3515e Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 4 Jun 2026 10:55:15 +0200 Subject: [PATCH 13/20] feat(sms): address PR review comments --- sinch/domains/sms/api/v1/internal/groups_endpoints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 75f5f29a..5072fec6 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -1,5 +1,4 @@ import json -from typing import List from pydantic import StrictStr, TypeAdapter, conlist From e44635be77ef7016b123822edb25bf878b94d8d9 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 4 Jun 2026 10:57:23 +0200 Subject: [PATCH 14/20] feat(sms): address PR review comments --- examples/snippets/sms/groups/replace/snippet.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/snippets/sms/groups/replace/snippet.py b/examples/snippets/sms/groups/replace/snippet.py index ed43b521..87ef0b7d 100644 --- a/examples/snippets/sms/groups/replace/snippet.py +++ b/examples/snippets/sms/groups/replace/snippet.py @@ -24,6 +24,7 @@ group_id = "GROUP_ID" response: GroupResponse = sinch_client.sms.groups.replace( + group_id=group_id, members=["+1234567890", "+1987654321"], ) From 415bb4edb6755ef8df9f596fbd01d3bf81f9d6d3 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 4 Jun 2026 11:00:07 +0200 Subject: [PATCH 15/20] feat(sms): address PR review comments --- sinch/domains/sms/api/v1/internal/groups_endpoints.py | 8 ++++++-- .../sms/models/v1/response/list_group_members_response.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py index 5072fec6..fa06ff12 100644 --- a/sinch/domains/sms/api/v1/internal/groups_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -201,7 +201,9 @@ def __init__(self, project_id: str, request_data: GroupIdRequest): self.project_id = project_id self.request_data = request_data - def handle_response(self, response: HTTPResponse) -> ListGroupMembersResponse: + def handle_response( + self, response: HTTPResponse + ) -> ListGroupMembersResponse: try: super(ListGroupMembersEndpoint, self).handle_response(response) except SmsException as e: @@ -210,5 +212,7 @@ def handle_response(self, response: HTTPResponse) -> ListGroupMembersResponse: response=e.http_response, is_from_server=e.is_from_server, ) - members = TypeAdapter(conlist(StrictStr)).validate_python(response.body) + members = TypeAdapter(conlist(StrictStr)).validate_python( + response.body + ) return ListGroupMembersResponse(members=members) diff --git a/sinch/domains/sms/models/v1/response/list_group_members_response.py b/sinch/domains/sms/models/v1/response/list_group_members_response.py index f0d5c59f..ba251f50 100644 --- a/sinch/domains/sms/models/v1/response/list_group_members_response.py +++ b/sinch/domains/sms/models/v1/response/list_group_members_response.py @@ -2,7 +2,9 @@ from pydantic import StrictStr, conlist -from sinch.domains.sms.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) class ListGroupMembersResponse(BaseModelConfigurationResponse): From 764a42474e340b75c124cf792c0b7a2a52cf64c0 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 4 Jun 2026 11:15:42 +0200 Subject: [PATCH 16/20] feat(sms): change list groups and members print on snippet --- examples/snippets/sms/groups/list/snippet.py | 2 +- examples/snippets/sms/groups/list_members/snippet.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/snippets/sms/groups/list/snippet.py b/examples/snippets/sms/groups/list/snippet.py index 51ac6f36..a3b9cd75 100644 --- a/examples/snippets/sms/groups/list/snippet.py +++ b/examples/snippets/sms/groups/list/snippet.py @@ -21,6 +21,6 @@ groups = sinch_client.sms.groups.list() -print("Available groups:\n") +print("List of groups:\n") for group in groups.iterator(): print(group) diff --git a/examples/snippets/sms/groups/list_members/snippet.py b/examples/snippets/sms/groups/list_members/snippet.py index d3478aea..2b4e5171 100644 --- a/examples/snippets/sms/groups/list_members/snippet.py +++ b/examples/snippets/sms/groups/list_members/snippet.py @@ -25,6 +25,6 @@ members = sinch_client.sms.groups.list_members(group_id=group_id) -print("Available members:\n") +print("List of members:\n") for member in members.iterator(): print(member) From db0a8ff7252b1149122528ee0f223a3f0cf44745 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 08:27:57 +0200 Subject: [PATCH 17/20] feat(sms): missing Groups in apis __init__ --- sinch/domains/sms/api/v1/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index db903927..b322cc84 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,7 +1,9 @@ from sinch.domains.sms.api.v1.batches_apis import Batches from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +from sinch.domains.sms.api.v1.groups_apis import Groups __all__ = [ "Batches", "DeliveryReports", + "Groups", ] From e912020f86a6336107a17efc68553493fe80bb77 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 13:19:09 +0200 Subject: [PATCH 18/20] feat(sms): delete strong typing in snippets --- examples/snippets/sms/groups/replace/snippet.py | 3 +-- examples/snippets/sms/groups/update/snippet.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/snippets/sms/groups/replace/snippet.py b/examples/snippets/sms/groups/replace/snippet.py index 87ef0b7d..0a0a5339 100644 --- a/examples/snippets/sms/groups/replace/snippet.py +++ b/examples/snippets/sms/groups/replace/snippet.py @@ -9,7 +9,6 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.domains.sms.api.v1.groups_apis import GroupResponse load_dotenv() @@ -23,7 +22,7 @@ # The ID of the group to replace group_id = "GROUP_ID" -response: GroupResponse = sinch_client.sms.groups.replace( +response = sinch_client.sms.groups.replace( group_id=group_id, members=["+1234567890", "+1987654321"], ) diff --git a/examples/snippets/sms/groups/update/snippet.py b/examples/snippets/sms/groups/update/snippet.py index 7ac08841..4edab517 100644 --- a/examples/snippets/sms/groups/update/snippet.py +++ b/examples/snippets/sms/groups/update/snippet.py @@ -9,7 +9,6 @@ from dotenv import load_dotenv from sinch import SinchClient -from sinch.domains.sms.api.v1.groups_apis import GroupResponse load_dotenv() @@ -23,7 +22,7 @@ # The ID of the group to update group_id = "GROUP_ID" -response: GroupResponse = sinch_client.sms.groups.update( +response = sinch_client.sms.groups.update( group_id=group_id, add=["+1234567890"], remove=["+1987654321"], From 0d15c221ab17599944b56a030b6c4cff37a19928 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 13:26:54 +0200 Subject: [PATCH 19/20] feat(sms): update CHANGELOG.md and MIGRATION_GUIDE.md --- CHANGELOG.md | 8 ++++++++ MIGRATION_GUIDE.md | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea401a01..3c6a2617 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.1.0 – 2026-06-05 + +### SMS + +- **[feature]** SMS Groups API: `create`, `list`, `get`, `update`, `replace`, `delete`, and `list_members` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#groups-api)). + +--- + ## v2.0.1 – 2026-06-02 ### SMS diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 19148549..effa3b33 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -207,7 +207,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2 The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. -Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. +Note that `sinch.sms.inbounds` is not supported yet and will be available in a future minor version. `sinch.sms.groups` is now available — see [Groups API](#groups-api) below. ##### Batches API @@ -230,6 +230,41 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and | `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | | `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | +##### Groups API + +> **Added in v2.1.0.** `sinch.sms.groups` is now fully supported. + +###### Replacement models + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.groups.requests.CreateSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupRequest`](sinch/domains/sms/models/v1/internal/group_request.py) | +| `sinch.domains.sms.models.groups.requests.ListSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.ListGroupsRequest`](sinch/domains/sms/models/v1/internal/list_groups_request.py) | +| `sinch.domains.sms.models.groups.requests.GetSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) | +| `sinch.domains.sms.models.groups.requests.DeleteSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) | +| `sinch.domains.sms.models.groups.requests.GetSMSGroupPhoneNumbersRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) | +| `sinch.domains.sms.models.groups.requests.UpdateSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.UpdateGroupRequest`](sinch/domains/sms/models/v1/internal/update_group_request.py) | +| `sinch.domains.sms.models.groups.requests.ReplaceSMSGroupPhoneNumbersRequest` | [`sinch.domains.sms.models.v1.internal.ReplaceGroupRequest`](sinch/domains/sms/models/v1/internal/replace_group_request.py) | +| `sinch.domains.sms.models.groups.responses.CreateSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.GetSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.UpdateSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.ReplaceSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.SinchListSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.ListGroupsResponse`](sinch/domains/sms/models/v1/response/list_groups_response.py) | +| `sinch.domains.sms.models.groups.responses.SinchGetSMSGroupPhoneNumbersResponse` | [`sinch.domains.sms.models.v1.response.ListGroupMembersResponse`](sinch/domains/sms/models/v1/response/list_group_members_response.py) | +| `sinch.domains.sms.models.groups.responses.SinchDeleteSMSGroupResponse` | `None` (method returns `None`) | + +###### Replacement APIs + +| Old method | New method in `sms.groups` | +|------------|---------------------------| +| `create()` with `CreateSMSGroupRequest` | `create()` with individual parameters: `name`, `members`, `child_groups`, `auto_update` | +| `list()` with `ListSMSGroupRequest` | `list()` with individual parameters: `page`, `page_size`. Returns **`Paginator[GroupResponse]`** | +| `get()` with `GetSMSGroupRequest` | `get()` with `group_id: str` parameter | +| `update()` with `UpdateSMSGroupRequest` | `update()` with `group_id: str` and optional parameters: `add`, `remove`, `name`, `add_from_group`, `remove_from_group`, `auto_update` | +| `replace()` with `ReplaceSMSGroupPhoneNumbersRequest` | `replace()` with `group_id: str` and optional parameters: `name`, `members`, `child_groups`, `auto_update` | +| `delete()` with `DeleteSMSGroupRequest` | `delete()` with `group_id: str` parameter | +| `get_phone_numbers()` / phone number listing | `list_members()` with `group_id: str`. Returns **`Paginator[str]`** | + --- ### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) From 092f0232d1700d8fda2fd65a5880058157ef0594 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 13:34:12 +0200 Subject: [PATCH 20/20] feat(sms): update MIGRATION_GUIDE.md version --- MIGRATION_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index effa3b33..b14c6535 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,6 +1,6 @@ # Sinch Python SDK Migration Guide -## 2.0.0 +## 2.1.0 This release removes legacy SDK support.