diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0534969d..cee9dc23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,11 +84,7 @@ jobs: cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ 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/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/* ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/ cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/ diff --git a/.gitignore b/.gitignore index 3246a258..fc884a92 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +lcov.info # E2E features *.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index ea401a01..1a467864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,26 @@ All notable changes to the **Sinch Python SDK** are documented in this file. --- +## v2.1.0 – 2026-06-05 + +### SDK + +- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff. When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#158). +- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). +- **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156). +- **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156). +- **[deprecation notice]** `HTTPTransport.send(endpoint)` is deprecated in favour of `send_request(request_data)`; the legacy method still works for backward compatibility, but will be removed in 3.0 (#156). +- **[deprecation notice]** `TokenManagerBase.invalidate_expired_token()` and `handle_invalid_token()` (and the `TokenState.EXPIRED` value) are deprecated and will be removed in 3.0, as token renewal now goes through `refresh_auth_token()` (#156). + + +### SMS + +- **[feature]** SMS Groups API: `create`, `list`, `get`, `update`, `replace`, `delete`, and `list_members` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#groups-api)). +- **[feature]** SMS Inbounds API: `get` and `list` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#inbounds-api)). +- **[design]** SMS Sinch Events inbound payload models unified with the Inbounds API: `MOTextSinchEvent`, `MOBinarySinchEvent`, `MOMediaSinchEvent`, `MediaBody`, and `MediaItem` removed from `sinch_events`; use `InboundMessage` (and its variants) from `sinch.domains.sms.models.v1.types` instead (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#sms-sinch-events)). + +--- + ## v2.0.1 – 2026-06-02 ### SMS diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 19148549..326cf343 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. @@ -205,9 +205,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2 #### Replacement APIs -The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. - -Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. +The SMS domain API access remains the same: `sinch.sms.batches`, `sinch.sms.delivery_reports`, `sinch.sms.inbounds` and `sinch.sms.groups`. However, the underlying models and method signatures have changed. See the sections below for the full list of changes: [Batches](#batches-api), [Delivery Reports](#delivery-reports-api), [Groups](#groups-api), [Inbounds](#inbounds-api). ##### Batches API @@ -230,6 +228,71 @@ 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 + +###### 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]`** | + +##### Inbounds API + +###### Replacement models + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.inbounds.requests.ListSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.ListInboundsRequest`](sinch/domains/sms/models/v1/internal/list_inbounds_request.py) | +| `sinch.domains.sms.models.inbounds.requests.GetSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.InboundIdRequest`](sinch/domains/sms/models/v1/internal/inbound_id_request.py) | +| `sinch.domains.sms.models.inbounds.responses.SinchListInboundMessagesResponse` | [`sinch.domains.sms.models.v1.internal.ListInboundsResponse`](sinch/domains/sms/models/v1/internal/list_inbounds_response.py) | +| `sinch.domains.sms.models.inbounds.responses.GetInboundMessagesResponse` | [`sinch.domains.sms.models.v1.types.InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (Union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`) | + +###### Replacement APIs + +| Old method | New method in `sms.inbounds` | +|------------|------------------------------| +| `list()` with `ListSMSInboundMessageRequest` | `list()` with individual parameters: `page`, `page_size`, `to`, `start_date`, `end_date`, `client_reference`. Returns **`Paginator[InboundMessage]`** | +| `get()` with `GetSMSInboundMessageRequest` | `get()` with `inbound_id: str` parameter | + +##### SMS Sinch Events + +The inbound payload models in `sinch_events` have been unified with the Inbounds API models. The following classes have been removed: + +| Removed class | Replacement | +|---------------|-------------| +| `sinch.domains.sms.sinch_events.v1.events.MOTextSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOTextMessage`](sinch/domains/sms/models/v1/shared/mo_text_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MOBinarySinchEvent` | [`sinch.domains.sms.models.v1.shared.MOBinaryMessage`](sinch/domains/sms/models/v1/shared/mo_binary_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MOMediaSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOMediaMessage`](sinch/domains/sms/models/v1/shared/mo_media_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MediaBody` | Embedded in `MOMediaMessage` | +| `sinch.domains.sms.sinch_events.v1.events.MediaItem` | Embedded in `MOMediaMessage` | + +`IncomingSMSSinchEvent` is now a type alias for [`InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (discriminated union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`). Code that previously type-checked against `MOTextSinchEvent` and siblings should switch to their `MO*Message` equivalents. + --- ### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) @@ -244,6 +307,7 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and ##### Replacement models + | Old class | New class | |-----------|-----------| | `UpdateNumbersCallbackConfigurationRequest` | `UpdateEventDestinationRequest` | diff --git a/README.md b/README.md index 92811ffa..1339db45 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ The following example replaces the default `requests` backend with `httpx` and r import httpx from sinch import SinchClient from sinch.core.ports.http_transport import HTTPTransport -from sinch.core.endpoint import HTTPEndpoint +from sinch.core.models.http_request import HttpRequest from sinch.core.models.http_response import HTTPResponse @@ -204,9 +204,7 @@ class MyHTTPImplementation(HTTPTransport): proxy=f"http://{proxy_user}:{proxy_password}@{proxy_url}" ) - def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: - request_data = self.prepare_request(endpoint) - request_data = self.authenticate(endpoint, request_data) + def send_request(self, request_data: HttpRequest) -> HTTPResponse: body = request_data.request_body response = self.http_client.request( diff --git a/examples/snippets/sms/groups/create/snippet.py b/examples/snippets/sms/groups/create/snippet.py new file mode 100644 index 00000000..fd60244c --- /dev/null +++ b/examples/snippets/sms/groups/create/snippet.py @@ -0,0 +1,26 @@ +""" +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="Sinch Python SDK 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..2c30fba8 --- /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 + +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 retrieve +GROUP_ID = "GROUP_ID" + +response = 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..a3b9cd75 --- /dev/null +++ b/examples/snippets/sms/groups/list/snippet.py @@ -0,0 +1,26 @@ +""" +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" +) + +groups = sinch_client.sms.groups.list() + +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 new file mode 100644 index 00000000..2b4e5171 --- /dev/null +++ b/examples/snippets/sms/groups/list_members/snippet.py @@ -0,0 +1,30 @@ +""" +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 = sinch_client.sms.groups.list_members(group_id=group_id) + +print("List of 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 new file mode 100644 index 00000000..0a0a5339 --- /dev/null +++ b/examples/snippets/sms/groups/replace/snippet.py @@ -0,0 +1,30 @@ +""" +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 replace +group_id = "GROUP_ID" + +response = sinch_client.sms.groups.replace( + group_id=group_id, + 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..4edab517 --- /dev/null +++ b/examples/snippets/sms/groups/update/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 + +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 = 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/examples/snippets/sms/inbounds/get/snippet.py b/examples/snippets/sms/inbounds/get/snippet.py new file mode 100644 index 00000000..126c4a3a --- /dev/null +++ b/examples/snippets/sms/inbounds/get/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 inbound message to retrieve +inbound_id = "INBOUND_ID" + +response = sinch_client.sms.inbounds.get(inbound_id=inbound_id) + +print(f"Inbound message:\n{response}") diff --git a/examples/snippets/sms/inbounds/list/snippet.py b/examples/snippets/sms/inbounds/list/snippet.py new file mode 100644 index 00000000..b5042671 --- /dev/null +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + + +inbound_messages = sinch_client.sms.inbounds.list() + +print("List of inbound messages:\n") +for message in inbound_messages.iterator(): + print(message) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 289f153f..b82833df 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.1" +version = "2.1.0" license = "Apache 2.0" readme = "README.md" authors = [ @@ -28,7 +28,7 @@ keywords = ["sinch", "sdk"] [tool.poetry.dependencies] python = ">=3.9" -requests = "*" +requests = ">=2.0.0" pydantic = ">=2.0.0" [build-system] diff --git a/requirements-dev.txt b/requirements-dev.txt index 384715db..095b2f00 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ behave ruff # HTTP Libraries -requests +requests>=2.0.0 # Data Validation pydantic >= 2.0.0 \ No newline at end of file diff --git a/sinch/__init__.py b/sinch/__init__.py index a3df16eb..14ccc3e0 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,5 +1,5 @@ """ Sinch Python SDK""" -__version__ = "2.0.1" +__version__ = "2.1.0" from sinch.core.clients.sinch_client_sync import SinchClient diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 62c0a3cb..926791e8 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -1,21 +1,28 @@ import requests from sinch.core.ports.http_transport import HTTPTransport, HttpRequest -from sinch.core.endpoint import HTTPEndpoint from sinch.core.models.http_response import HTTPResponse - class HTTPTransportRequests(HTTPTransport): + """ + Sync HTTP transport using the requests library. + """ + def __init__(self, sinch): super().__init__(sinch) self.http_session = requests.Session() - def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: - request_data: HttpRequest = self.prepare_request(endpoint) - request_data: HttpRequest = self.authenticate(endpoint, request_data) + def send_request(self, request_data: HttpRequest) -> HTTPResponse: + """ + Performs the HTTP call with requests and maps the result to an HTTPResponse. + :param request_data: The prepared request to send. + :type request_data: HttpRequest + :returns: The HTTP response. + :rtype: 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}" + "Sync HTTP request %s call with headers: %s and body: %s to URL: %s", + request_data.http_method, request_data.headers, request_data.request_body, request_data.url ) response = self.http_session.request( method=request_data.http_method, @@ -30,8 +37,8 @@ def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: response_body = self.deserialize_json_response(response) self.sinch.configuration.logger.debug( - f"Sync HTTP {response.status_code} response with headers: {response.headers}" - f"and body: {response_body} from URL: {request_data.url}" + "Sync HTTP response %s with headers: %s and body: %s from URL: %s", + response.status_code, response.headers, response_body, request_data.url ) return HTTPResponse( diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index ec0edafc..ca27ee60 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,70 +1,218 @@ -from abc import ABC, abstractmethod +import random +import time +import warnings +from abc import ABC +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from platform import python_version +from typing import Optional, Union + +from requests import Response from sinch.core.endpoint import HTTPEndpoint from sinch.core.models.http_request import HttpRequest from sinch.core.models.http_response import HTTPResponse from sinch.core.exceptions import ValidationException, SinchException from sinch.core.enums import HTTPAuthentication -from sinch.core.token_manager import TokenState from sinch import __version__ as sdk_version class HTTPTransport(ABC): - """Base class for HTTP transports. + """ + Base class for HTTP transports. + + Subclasses implement :meth:`send_request` to perform the raw HTTP call. The public + :meth:`request` method adds cross-cutting concerns on top: request + preparation, authentication, and automatic token refresh on 401. - Subclasses implement ``send`` to perform the raw HTTP call. - The public ``request`` method adds cross-cutting concerns on top: - authentication, logging hooks, and automatic token refresh on 401. + .. deprecated:: 2.1 + Overriding :meth:`send` (the old ``send(endpoint)`` hook) is still + honored but deprecated; implement :meth:`send_request` instead. The + ``send`` override path will be removed in 3.0. """ + MAX_RETRIES = 3 + RETRYABLE_STATUS_CODES = frozenset({429}) + BACKOFF_BASE_SECONDS = 1.0 + BACKOFF_GROWTH = 4 + def __init__(self, sinch): self.sinch = sinch + self._legacy_send = self._uses_legacy_send() + if self._legacy_send: + warnings.warn( + f"{type(self).__name__} overrides `send(endpoint)`, which is deprecated and " + "will be removed in 3.0. Implement `send_request(request_data)` instead.", + DeprecationWarning, + stacklevel=2, + ) - # ------------------------------------------------------------------ - # Subclass contract - # ------------------------------------------------------------------ + def send_request(self, request_data: HttpRequest) -> HTTPResponse: + """ + Performs a single HTTP round-trip for an already-prepared, authenticated request. - @abstractmethod + :param request_data: The prepared request to send. + :type request_data: HttpRequest + :returns: The HTTP response. + :rtype: HTTPResponse + """ + raise NotImplementedError( + "Transport subclasses must implement `send_request(request_data)`." + ) + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: - """Execute a single HTTP round-trip and return the response. + """ + Prepares, authenticates and performs a single round-trip for an endpoint. - Implementations must prepare the request, authenticate, perform the - HTTP call, deserialize the response, and return an ``HTTPResponse``. - They should **not** handle token refresh — that is done by - ``request``. + .. deprecated:: 2.1 + This hook is deprecated. Implement :meth:`send_request` instead; + the ``send`` override path will be removed in 3.0. + + :param endpoint: The endpoint to call. + :type endpoint: HTTPEndpoint + :returns: The HTTP response. + :rtype: HTTPResponse + """ + raise NotImplementedError( + "`send(endpoint)` is deprecated. " + "Implement `send_request(request_data)` instead." + ) + + def _uses_legacy_send(self) -> bool: """ + Returns True when a subclass overrides the deprecated ``send`` hook but + not the new ``send_request`` hook. + """ + cls = type(self) + return cls.send is not HTTPTransport.send and cls.send_request is HTTPTransport.send_request - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - """Send a request with automatic OAuth token refresh on 401. + """ + Sends a request, renewing the token and retrying once on an expired-token 401. - If the server responds with 401 *and* the token is detected as - expired, the token is invalidated and **one** retry is attempted - with a fresh token. A second consecutive 401 is handed straight - to the endpoint's error handler — no further retries. + :param endpoint: The endpoint to call. + :type endpoint: HTTPEndpoint + :returns: The handled HTTP response. + :rtype: HTTPResponse """ - http_response = self.send(endpoint) + if self._legacy_send: + return self._legacy_request(endpoint) + + request_data = self.prepare_request(endpoint) + request_data = self.authenticate(endpoint, request_data) + + http_response = self._send_with_retries(request_data) if self._should_refresh_token(endpoint, http_response): - self.sinch.configuration.token_manager.handle_invalid_token( - http_response - ) - if ( - self.sinch.configuration.token_manager.token_state - == TokenState.EXPIRED - ): - http_response = self.send(endpoint) + used_token = self._get_bearer_token_from_request(request_data) + new_token = self.sinch.configuration.token_manager.refresh_auth_token(used_token) + self._set_bearer_token(request_data, new_token.access_token) + http_response = self._send_with_retries(request_data) return endpoint.handle_response(http_response) + + + def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> HTTPResponse: + """ + Sends a request, retrying rate-limited responses up to + MAX_RETRIES times with backoff between attempts. + + :param request_data: The prepared request to send, or, on the legacy + ``send`` path, the endpoint to call. + :type request_data: Union[HttpRequest, HTTPEndpoint] + :returns: The HTTP response from the last attempt. + :rtype: HTTPResponse + """ + num_retries = 0 + while True: + if isinstance(request_data, HTTPEndpoint): + http_response = self.send(request_data) + else: + http_response = self.send_request(request_data) + + if self._should_retry(http_response, num_retries): + time.sleep(self._compute_backoff(http_response, num_retries)) + num_retries += 1 + else: + return http_response + + def _should_retry(self, http_response: HTTPResponse, num_retries: int) -> bool: + """ + Returns True when the response is a transient error and + retries remain. + + :param http_response: The response received. + :type http_response: HTTPResponse + :param num_retries: Number of retries already performed. + :type num_retries: int + :returns: Whether the request should be retried. + :rtype: bool + """ + if num_retries >= self.MAX_RETRIES: + return False + return http_response.status_code in self.RETRYABLE_STATUS_CODES + + def _compute_backoff(self, http_response: HTTPResponse, num_retries: int) -> float: + """ + Computes how long to wait before the next retry. + + :param http_response: The response received. + :type http_response: HTTPResponse + :param num_retries: Number of retries already performed. + :type num_retries: int + :returns: The delay in seconds. + :rtype: float + """ + + headers = {key.lower(): value for key, value in http_response.headers.items()} + retry_after_seconds = self._parse_retry_after(headers.get("retry-after")) + if retry_after_seconds is not None: + return retry_after_seconds + random.uniform(0, 0.25) - # ------------------------------------------------------------------ - # Internals - # ------------------------------------------------------------------ + max_delay = self.BACKOFF_BASE_SECONDS * (self.BACKOFF_GROWTH ** num_retries) + return random.uniform(0, max_delay) - def authenticate(self, endpoint, request_data): + @staticmethod + def _parse_retry_after(value: Optional[str]) -> Optional[float]: + """ + Parses the Retry-After header into a + delay in seconds, or None if absent/unparseable. + + :param value: The raw header value. + :type value: Optional[str] + :returns: The delay in seconds, or None if absent/invalid. + :rtype: Optional[float] + """ + if not value: + return None + + try: + seconds = float(value) + return seconds if seconds >= 0 else None + except ValueError: + pass + + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()) + + + def authenticate(self, endpoint: HTTPEndpoint, request_data: HttpRequest) -> HttpRequest: + """ + Stamps the credentials required by the endpoint's auth scheme onto the request. + + :param endpoint: The endpoint being called, whose HTTP_AUTHENTICATION selects the scheme. + :type endpoint: HTTPEndpoint + :param request_data: The request to authenticate, mutated in place. + :type request_data: HttpRequest + :returns: The same request, with auth applied. + :rtype: HttpRequest + :raises ValidationException: If the credentials required by the scheme are missing. + """ if endpoint.HTTP_AUTHENTICATION in (HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value): if ( not self.sinch.configuration.key_id @@ -87,10 +235,7 @@ def authenticate(self, endpoint, request_data): if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value: token = self.sinch.authentication.get_auth_token().access_token - request_data.headers.update({ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }) + self._set_bearer_token(request_data, token) elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SMS_TOKEN.value: if not self.sinch.configuration.sms_api_token or not self.sinch.configuration.service_plan_id: raise ValidationException( @@ -101,14 +246,19 @@ def authenticate(self, endpoint, request_data): is_from_server=False, response=None ) - request_data.headers.update({ - "Authorization": f"Bearer {self.sinch.configuration.sms_api_token}", - "Content-Type": "application/json" - }) + self._set_bearer_token(request_data, self.sinch.configuration.sms_api_token) return request_data def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest: + """ + Builds the HttpRequest for an endpoint. + + :param endpoint: The endpoint to build the request for. + :type endpoint: HTTPEndpoint + :returns: The prepared request. + :rtype: HttpRequest + """ url_query_params = endpoint.build_query_params() return HttpRequest( @@ -124,7 +274,16 @@ def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest: ) @staticmethod - def deserialize_json_response(response): + def deserialize_json_response(response: Response) -> dict: + """ + Parses the JSON body of a response. + + :param response: The raw HTTP response. + :type response: Response + :returns: The parsed body. + :rtype: dict + :raises SinchException: If the body is present but not valid JSON. + """ if response.content: try: response_body = response.json() @@ -138,12 +297,75 @@ def deserialize_json_response(response): response_body = {} return response_body + + def _legacy_request(self, endpoint: HTTPEndpoint) -> HTTPResponse: + """ + Backward-compatible request loop for subclasses that override ``send``. + + On an expired-token 401 the cached token is renewed through + :meth:`TokenManagerBase.refresh_auth_token`, which dedupes concurrent + renewals. The legacy ``send(endpoint)`` re-prepares and re-authenticates + on every call, so the second ``send`` picks up the refreshed token from + the cache automatically. + + :param endpoint: The endpoint to call. + :type endpoint: HTTPEndpoint + :returns: The handled HTTP response. + :rtype: HTTPResponse + """ + token_before = self.sinch.configuration.token_manager.token + http_response = self._send_with_retries(endpoint) + + if self._should_refresh_token(endpoint, http_response): + used_token = token_before.access_token if token_before else None + self.sinch.configuration.token_manager.refresh_auth_token(used_token) + http_response = self._send_with_retries(endpoint) + + return endpoint.handle_response(http_response) @staticmethod - def _should_refresh_token(endpoint, http_response): - """Return True when a 401 response should trigger a token refresh.""" - return ( - http_response.status_code == 401 - and endpoint.HTTP_AUTHENTICATION - == HTTPAuthentication.OAUTH.value - ) + def _should_refresh_token(endpoint: HTTPEndpoint, http_response: HTTPResponse) -> bool: + """ + Returns True for an OAuth endpoint that got a 401 with an expired-token header. + + :param endpoint: The endpoint that was called. + :type endpoint: HTTPEndpoint + :param http_response: The response received. + :type http_response: HTTPResponse + :returns: Whether the token should be refreshed and the request retried. + :rtype: bool + """ + if endpoint.HTTP_AUTHENTICATION != HTTPAuthentication.OAUTH.value: + return False + if http_response.status_code != 401: + return False + www_authenticate = http_response.headers.get("www-authenticate") or "" + return "expired" in www_authenticate + + @staticmethod + def _get_bearer_token_from_request(request_data: HttpRequest) -> Optional[str]: + """ + Extracts the bearer token from the request's Authorization header. + + :param request_data: The request. + :type request_data: HttpRequest + :returns: The bearer token, or None if absent or not a bearer. + :rtype: Optional[str] + """ + auth = request_data.headers.get("Authorization", "") + return auth.removeprefix("Bearer ") if auth.startswith("Bearer ") else None + + @staticmethod + def _set_bearer_token(request_data: HttpRequest, token: str) -> None: + """ + Stamps the bearer token onto the request's Authorization header. + + :param request_data: The request. + :type request_data: HttpRequest + :param token: The bearer token. + :type token: str + """ + request_data.headers.update({ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + }) diff --git a/sinch/core/token_manager.py b/sinch/core/token_manager.py index b66bba85..000cb19e 100644 --- a/sinch/core/token_manager.py +++ b/sinch/core/token_manager.py @@ -1,38 +1,113 @@ +import warnings from enum import Enum from abc import ABC, abstractmethod +import threading from sinch.domains.authentication.models.v1.authentication import OAuthToken from sinch.domains.authentication.endpoints.v1.oauth import OAuthEndpoint from sinch.core.exceptions import ValidationException class TokenState(Enum): + """ + Lifecycle state of the cached OAuth token. + """ + VALID = "VALID" + """ + A usable token is currently cached. + """ INVALID = "INVALID" + """ + No token has been obtained yet. + """ EXPIRED = "EXPIRED" + """ + .. deprecated:: 2.1 + Kept for backward compatibility; will be removed in 3.0. No longer used + by the SDK's own renewal path (see :meth:`TokenManager.refresh_auth_token`). + """ class TokenManagerBase(ABC): + """ + Base class for OAuth token managers. + + Holds the cached access token together with the lock that guards every + token mutation. + """ + def __init__(self, sinch): self.sinch = sinch - self.token = None - self.token_state = TokenState.INVALID + self.token: OAuthToken | None = None + self.token_state: TokenState = TokenState.INVALID + self._lock: threading.Lock = threading.Lock() @abstractmethod def get_auth_token(self) -> OAuthToken: pass - def invalidate_expired_token(self): + def refresh_auth_token(self, used_token: str) -> OAuthToken: + """ + Renews the token after an expired-token 401, deduping concurrent renewals. + + :param used_token: The access token used by the request that received the 401. + :type used_token: str + :returns: A valid token. + :rtype: OAuthToken + """ + with self._lock: + if self.token is not None and self.token.access_token != used_token: + return self.token + token = self._request_token() + self._set_valid_token(token) + return token + + def invalidate_expired_token(self) -> None: + """ + .. deprecated:: 2.1 + Token renewal is handled by :meth:`refresh_auth_token`; this method + will be removed in 3.0. + + Clears the cached token so the next call fetches a new one. + """ + warnings.warn( + "TokenManagerBase.invalidate_expired_token() is deprecated and will be " + "removed in 3.0. Token renewal is handled by refresh_auth_token().", + DeprecationWarning, + stacklevel=2, + ) self.token = None self.token_state = TokenState.EXPIRED - def handle_invalid_token(self, http_response): - if http_response.headers.get("www-authenticate") and "expired" in http_response.headers["www-authenticate"]: - self.invalidate_expired_token() + def handle_invalid_token(self, http_response) -> None: + """ + .. deprecated:: 2.1 + Expired-token handling now lives in the HTTP transport's request loop; + this method will be removed in 3.0. + + Invalidates the cached token if the response signals an expired token. + """ + warnings.warn( + "TokenManagerBase.handle_invalid_token() is deprecated and will be " + "removed in 3.0. Expired-token handling now lives in the transport.", + DeprecationWarning, + stacklevel=2, + ) + www_authenticate = http_response.headers.get("www-authenticate") or "" + if "expired" in www_authenticate: + self.token = None + self.token_state = TokenState.EXPIRED + + def set_auth_token(self, token: dict): + """ + Sets the OAuth token and marks the token_state as VALID. - def set_auth_token(self, token) -> None: + :param token: The token fields. + :type token: dict + :raises ValidationException: If the fields do not match the OAuthToken structure. + """ try: - self.token = OAuthToken(**token) - self.token_state = TokenState.VALID + self._set_valid_token(OAuthToken(**token)) except TypeError: raise ValidationException( "Invalid authentication token structure", @@ -40,12 +115,50 @@ def set_auth_token(self, token) -> None: response=None ) + def _request_token(self) -> OAuthToken: + """ + Requests a fresh token from the OAuth endpoint. No side effects. + + :returns: The freshly fetched token. + :rtype: OAuthToken + """ + return self.sinch.configuration.transport.request(OAuthEndpoint()) + + def _set_valid_token(self, token: OAuthToken) -> None: + """ + Caches the given token as the current valid one. + + :param token: The token to cache. + :type token: OAuthToken + """ + self.token = token + self.token_state = TokenState.VALID + class TokenManager(TokenManagerBase): + """ + Thread-safe synchronous OAuth token manager. + """ + def get_auth_token(self) -> OAuthToken: - if self.token: + """ + Returns a valid token, fetching and caching one if none is cached yet. + + This function is not a pure getter (not idempotent): on the first call it + requests a token from the OAuth endpoint and stores it on the instance. + Subsequent calls return the cached token. Caching is guarded by + double-checked locking, so concurrent callers share a single token + request instead of each issuing their own. + + :returns: A valid OAuth token. + :rtype: OAuthToken + """ + if self.token is not None: return self.token - self.token = self.sinch.configuration.transport.request(OAuthEndpoint()) - self.token_state = TokenState.VALID - return self.token + with self._lock: + if self.token is not None: + return self.token + token = self._request_token() + self._set_valid_token(token) + return token diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index db903927..fa632f0b 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,7 +1,11 @@ from sinch.domains.sms.api.v1.batches_apis import Batches from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds +from sinch.domains.sms.api.v1.groups_apis import Groups __all__ = [ "Batches", "DeliveryReports", + "Inbounds", + "Groups", ] diff --git a/sinch/domains/sms/api/v1/groups_apis.py b/sinch/domains/sms/api/v1/groups_apis.py new file mode 100644 index 00000000..69532f47 --- /dev/null +++ b/sinch/domains/sms/api/v1/groups_apis.py @@ -0,0 +1,274 @@ +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, + 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 + + +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) + + 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, **kwargs) -> 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, **kwargs) + return self._request(DeleteGroupEndpoint, request_data) + + 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: Paginator[str] + :rtype: Paginator[str] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id, **kwargs) + 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/inbounds_apis.py b/sinch/domains/sms/api/v1/inbounds_apis.py new file mode 100644 index 00000000..0d141ed6 --- /dev/null +++ b/sinch/domains/sms/api/v1/inbounds_apis.py @@ -0,0 +1,94 @@ +from datetime import datetime +from typing import List, Optional + +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.api.v1.base.base_sms import BaseSms +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ( + GetInboundEndpoint, + ListInboundsEndpoint, +) +from sinch.domains.sms.models.v1.internal.inbound_id_request import ( + InboundIdRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ( + ListInboundsRequest, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class Inbounds(BaseSms): + def get(self, inbound_id: str, **kwargs) -> InboundMessage: + """ + This operation retrieves a specific inbound message using the provided inbound ID. + + :param inbound_id: The inbound ID found when listing inbound messages. (required) + :type inbound_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: InboundMessage + :rtype: InboundMessage + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = InboundIdRequest(inbound_id=inbound_id, **kwargs) + return self._request(GetInboundEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + to: Optional[List[str]] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[InboundMessage]: + """ + With the list operation, + you can list all inbound messages that you have received. This operation supports pagination. Inbounds are returned in reverse chronological order. + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page (optional) + :type page_size: Optional[int] + :param to: Only list messages sent to this destination. Multiple phone numbers formatted as either + [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) or short codes can be comma separated. + (optional) + :type to: Optional[List[str]] + :param start_date: Only list messages received at or after this date/time. Formatted as + [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Default: Now-24 + (optional) + :type start_date: Optional[datetime] + + :param end_date: Only list messages received before this date/time. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. (optional) + :type end_date: Optional[datetime] + :param client_reference: Using a client reference in inbound messages requires additional setup on your account. + Contact your [account manager](https://dashboard.sinch.com/settings/account-details) to enable this feature. + Only list inbound messages that are in response to messages with a previously provided client reference. + (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[InboundMessage] + :rtype: Paginator[InboundMessage] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListInboundsEndpoint( + project_id=self._get_path_identifier(), + request_data=ListInboundsRequest( + page=page, + page_size=page_size, + to=to, + start_date=start_date, + end_date=end_date, + client_reference=client_reference, + **kwargs, + ), + ) + + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py new file mode 100644 index 00000000..fa06ff12 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -0,0 +1,218 @@ +import json + +from pydantic import StrictStr, TypeAdapter, conlist + +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_group_members_response import ( + ListGroupMembersResponse, +) +from sinch.domains.sms.models.v1.response.list_groups_response import ( + ListGroupsResponse, +) + + +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): + 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) + + +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=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 + ) -> ListGroupMembersResponse: + 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, + ) + members = TypeAdapter(conlist(StrictStr)).validate_python( + response.body + ) + return ListGroupMembersResponse(members=members) diff --git a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py new file mode 100644 index 00000000..f2037bb3 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -0,0 +1,62 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.base.sms_endpoint import SmsEndpoint +from sinch.domains.sms.models.v1.internal.inbound_id_request import ( + InboundIdRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ( + ListInboundsRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ( + ListInboundsResponse, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class GetInboundEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds/{inbound_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: InboundIdRequest): + super(GetInboundEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> InboundMessage: + try: + super(GetInboundEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, InboundMessage) + + +class ListInboundsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListInboundsRequest): + super(ListInboundsEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response(self, response: HTTPResponse) -> ListInboundsResponse: + try: + super(ListInboundsEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListInboundsResponse) diff --git a/sinch/domains/sms/models/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 diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py index 24ca21db..d026436e 100644 --- a/sinch/domains/sms/models/v1/internal/__init__.py +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -19,19 +19,26 @@ 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", + "ReplaceGroupRequest", "SendSMSRequest", "UpdateBatchMessageRequest", + "UpdateGroupRequest", ] @@ -49,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, @@ -61,4 +74,16 @@ 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, + ) + + return GroupRequest raise AttributeError(f"module {__name__!r} has no attribute {name!r}") 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_id_request.py b/sinch/domains/sms/models/v1/internal/group_id_request.py new file mode 100644 index 00000000..4d36be92 --- /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.", + ) 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..de3a14b4 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/group_request.py @@ -0,0 +1,27 @@ +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( + default=None, + 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/inbound_id_request.py b/sinch/domains/sms/models/v1/internal/inbound_id_request.py new file mode 100644 index 00000000..050c86de --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/inbound_id_request.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class InboundIdRequest(BaseModelConfigurationRequest): + inbound_id: str = Field( + default=..., + description="The unique identifier of the inbound message.", + ) diff --git a/sinch/domains/sms/models/v1/internal/list_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/internal/list_inbounds_request.py b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py new file mode 100644 index 00000000..409f3062 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py @@ -0,0 +1,15 @@ +from typing import Optional +from datetime import datetime +from pydantic import StrictInt, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListInboundsRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None + to: Optional[conlist(StrictStr)] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py new file mode 100644 index 00000000..61bb3bc1 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import Field, StrictInt, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class ListInboundsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of inbounds matching the given filters", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + inbounds: Optional[conlist(InboundMessage)] = Field( + default=None, + description="The page of inbounds matching the given filters.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of inbounds returned in this request.", + ) + + @property + def content(self): + """Returns the content of the inbounds list.""" + return self.inbounds or [] diff --git a/sinch/domains/sms/models/v1/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/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py index 9648cf44..5fee6d37 100644 --- a/sinch/domains/sms/models/v1/response/__init__.py +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -4,9 +4,16 @@ 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.list_group_members_response import ( + ListGroupMembersResponse, +) from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( RecipientDeliveryReport, ) @@ -14,6 +21,9 @@ __all__ = [ "BatchDeliveryReport", "DryRunResponse", + "GroupResponse", "ListBatchesResponse", + "ListGroupMembersResponse", + "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..f5a1b96d --- /dev/null +++ b/sinch/domains/sms/models/v1/response/group_response.py @@ -0,0 +1,40 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictInt, StrictStr, conlist + +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[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/response/list_group_members_response.py b/sinch/domains/sms/models/v1/response/list_group_members_response.py new file mode 100644 index 00000000..ba251f50 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_group_members_response.py @@ -0,0 +1,15 @@ +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/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..e5cc9163 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 ( @@ -11,8 +16,18 @@ ) from sinch.domains.sms.models.v1.shared.text_request import TextRequest from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage +from sinch.domains.sms.models.v1.shared.mo_binary_message import ( + MOBinaryMessage, +) +from sinch.domains.sms.models.v1.shared.mo_media_item import MOMediaItem +from sinch.domains.sms.models.v1.shared.mo_media_body import MOMediaBody +from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage __all__ = [ + "AddKeyword", + "AutoUpdate", "BinaryRequest", "BinaryResponse", "DryRunPerRecipientDetails", @@ -20,6 +35,13 @@ "MediaRequest", "MediaResponse", "MessageDeliveryStatus", + "RemoveKeyword", "TextRequest", "TextResponse", + "BaseMOMessage", + "MOTextMessage", + "MOBinaryMessage", + "MOMediaItem", + "MOMediaBody", + "MOMediaMessage", ] 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/shared/base_mo_message.py b/sinch/domains/sms/models/v1/shared/base_mo_message.py new file mode 100644 index 00000000..7b5d6252 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/base_mo_message.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BaseMOMessage(BaseModelConfigurationResponse): + from_: StrictStr = Field( + ..., + alias="from", + description="The phone number that sent the message.", + ) + id: StrictStr = Field(..., description="The ID of this inbound message.") + received_at: datetime = Field( + ..., + description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + to: StrictStr = Field( + ..., + description="The Sinch phone number or short code to which the message was sent.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", + ) + operator_id: Optional[StrictStr] = Field( + default=None, + description="The MCC/MNC of the sender's operator if known.", + ) + sent_at: Optional[datetime] = Field( + default=None, + description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) diff --git a/sinch/domains/sms/models/v1/shared/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.", + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_binary_message.py b/sinch/domains/sms/models/v1/shared/mo_binary_message.py new file mode 100644 index 00000000..50c17623 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_binary_message.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage + + +class MOBinaryMessage(BaseMOMessage): + body: StrictStr = Field( + ..., description="The incoming message body (Base64 encoded)." + ) + type: Literal["mo_binary"] = Field( + ..., description="The type of incoming message. Binary SMS." + ) + udh: StrictStr = Field( + ..., description="The UDH header of a binary message HEX encoded." + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_media_body.py b/sinch/domains/sms/models/v1/shared/mo_media_body.py new file mode 100644 index 00000000..6e35ccbc --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_body.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.shared.mo_media_item import MOMediaItem + + +class MOMediaBody(BaseModelConfigurationResponse): + subject: Optional[StrictStr] = Field( + default=None, description="The subject of the MMS media message." + ) + message: Optional[StrictStr] = Field( + default=None, + description="The text message content of the MMS media message.", + ) + media: Optional[conlist(MOMediaItem)] = Field( + default=None, + description="Collection of attachments in incoming message.", + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_media_item.py b/sinch/domains/sms/models/v1/shared/mo_media_item.py new file mode 100644 index 00000000..47d72547 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_item.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional, Union +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MOMediaItem(BaseModelConfigurationResponse): + url: Optional[StrictStr] = Field( + default=None, description="URL to the media file." + ) + content_type: StrictStr = Field( + ..., description="Content type of the media file." + ) + status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( + ..., description="Status of the media upload." + ) + code: StrictInt = Field(..., description="The result code.") diff --git a/sinch/domains/sms/models/v1/shared/mo_media_message.py b/sinch/domains/sms/models/v1/shared/mo_media_message.py new file mode 100644 index 00000000..4d3f6be8 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_message.py @@ -0,0 +1,14 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage +from sinch.domains.sms.models.v1.shared.mo_media_body import MOMediaBody + + +class MOMediaMessage(BaseMOMessage): + body: MOMediaBody = Field( + ..., + description="The media message body.", + ) + type: Literal["mo_media"] = Field( + ..., description="The type of incoming message. MMS." + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_text_message.py b/sinch/domains/sms/models/v1/shared/mo_text_message.py new file mode 100644 index 00000000..42b9f6db --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_text_message.py @@ -0,0 +1,13 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage + + +class MOTextMessage(BaseMOMessage): + body: StrictStr = Field( + ..., + description="The incoming message body. Maximum 2000 characters.", + ) + type: Literal["mo_text"] = Field( + ..., description="The type of incoming message. Regular SMS." + ) diff --git a/sinch/domains/sms/models/v1/types/__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/models/v1/types/inbound_message.py b/sinch/domains/sms/models/v1/types/inbound_message.py new file mode 100644 index 00000000..c2f511f2 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/inbound_message.py @@ -0,0 +1,11 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage +from sinch.domains.sms.models.v1.shared.mo_binary_message import ( + MOBinaryMessage, +) +from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage + +_InboundMessageUnion = Union[MOTextMessage, MOBinaryMessage, MOMediaMessage] + +InboundMessage = Annotated[_InboundMessageUnion, Field(discriminator="type")] diff --git a/sinch/domains/sms/sinch_events/v1/events/__init__.py b/sinch/domains/sms/sinch_events/v1/events/__init__.py index 00aba842..4ad09541 100644 --- a/sinch/domains/sms/sinch_events/v1/events/__init__.py +++ b/sinch/domains/sms/sinch_events/v1/events/__init__.py @@ -1,17 +1,6 @@ from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( IncomingSMSSinchEvent, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, - MediaBody, - MediaItem, + SmsSinchEventPayload, ) -__all__ = [ - "IncomingSMSSinchEvent", - "MOTextSinchEvent", - "MOBinarySinchEvent", - "MOMediaSinchEvent", - "MediaBody", - "MediaItem", -] +__all__ = ["IncomingSMSSinchEvent", "SmsSinchEventPayload"] diff --git a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py index fc87e608..a376c4da 100644 --- a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py @@ -1,97 +1,15 @@ -from datetime import datetime -from typing import Optional, Union, Literal, Annotated -from pydantic import Field, StrictStr, StrictInt, conlist -from sinch.domains.sms.sinch_events.v1.internal import SinchEvent +from typing import Union +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage -class MediaItem(SinchEvent): - url: StrictStr = Field(..., description="URL to the media file") - content_type: StrictStr = Field( - ..., description="Content type of the media file" - ) - status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( - ..., description="Status of the media upload" - ) - code: StrictInt = Field(..., description="Status code") +IncomingSMSSinchEvent = InboundMessage - -class MediaBody(SinchEvent): - subject: Optional[StrictStr] = Field( - default=None, description="The subject text" - ) - message: Optional[StrictStr] = Field( - default=None, description="The message text" - ) - media: conlist(MediaItem) = Field(..., description="Array of media items") - - -class BaseIncomingSMSSinchEvent(SinchEvent): - from_: StrictStr = Field( - ..., - alias="from", - description="The phone number that sent the message.", - ) - id: StrictStr = Field(..., description="The ID of this inbound message.") - received_at: datetime = Field( - ..., - description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", - ) - to: StrictStr = Field( - ..., - description="The Sinch phone number or short code to which the message was sent.", - ) - client_reference: Optional[StrictStr] = Field( - default=None, - description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", - ) - operator_id: Optional[StrictStr] = Field( - default=None, - description="The MCC/MNC of the sender's operator if known.", - ) - sent_at: Optional[datetime] = Field( - default=None, - description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", - ) - - -class MOTextSinchEvent(BaseIncomingSMSSinchEvent): - body: StrictStr = Field( - ..., - description="The incoming message body. Maximum 2000 characters.", - ) - type: Literal["mo_text"] = Field( - ..., description="The type of incoming message. Regular SMS." - ) - - -class MOBinarySinchEvent(BaseIncomingSMSSinchEvent): - body: StrictStr = Field( - ..., description="The incoming message body (Base64 encoded)." - ) - type: Literal["mo_binary"] = Field( - ..., description="The type of incoming message. Binary SMS." - ) - udh: StrictStr = Field( - ..., description="The UDH header of a binary message HEX encoded." - ) - - -class MOMediaSinchEvent(BaseIncomingSMSSinchEvent): - body: MediaBody = Field( - ..., - description="The media message body containing subject, message, and media items.", - ) - type: Literal["mo_media"] = Field( - ..., description="The type of incoming message. MMS." - ) - - -# Union type for isinstance checks -_IncomingSMSSinchEventUnion = Union[ - MOTextSinchEvent, MOBinarySinchEvent, MOMediaSinchEvent -] - -# Discriminated union for validation -IncomingSMSSinchEvent = Annotated[ - _IncomingSMSSinchEventUnion, Field(discriminator="type") +SmsSinchEventPayload = Union[ + InboundMessage, + BatchDeliveryReport, + RecipientDeliveryReport, ] diff --git a/sinch/domains/sms/sinch_events/v1/internal/__init__.py b/sinch/domains/sms/sinch_events/v1/internal/__init__.py deleted file mode 100644 index 43b3a8dd..00000000 --- a/sinch/domains/sms/sinch_events/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.sms.sinch_events.v1.internal.sinch_event import ( - SinchEvent, -) - -__all__ = ["SinchEvent"] diff --git a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py deleted file mode 100644 index 184012f9..00000000 --- a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py +++ /dev/null @@ -1,7 +0,0 @@ -from sinch.domains.sms.models.v1.internal.base import ( - BaseModelConfigurationResponse, -) - - -class SinchEvent(BaseModelConfigurationResponse): - pass diff --git a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py index 03f52892..2fd5e3f3 100644 --- a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -1,33 +1,24 @@ import json -from typing import Any, Dict, Union, Optional +from typing import Any, Dict, Optional, Union + from pydantic import TypeAdapter + from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( validate_sinch_event_signature_with_nonce, ) from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, - parse_json, normalize_iso_timestamp, -) -from sinch.domains.sms.sinch_events.v1.events import ( - IncomingSMSSinchEvent, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, + parse_json, ) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, RecipientDeliveryReport, ) - - -SmsSinchEventPayload = Union[ - BatchDeliveryReport, - RecipientDeliveryReport, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, -] +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, + SmsSinchEventPayload, +) class SmsSinchEvent: diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index c312c2de..3e762142 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,6 +2,8 @@ Batches, DeliveryReports, ) +from sinch.domains.sms.api.v1.groups_apis import Groups +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent @@ -16,6 +18,8 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + self.inbounds = Inbounds(self._sinch) + self.groups = Groups(self._sinch) def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: """ diff --git a/tests/e2e/sms/features/steps/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..cd817f4b --- /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 context.response.has_next_page is False + assert context.response.content() == ['12018881111', '12018882222', '12018883333'] diff --git a/tests/e2e/sms/features/steps/inbounds.steps.py b/tests/e2e/sms/features/steps/inbounds.steps.py new file mode 100644 index 00000000..b40d71df --- /dev/null +++ b/tests/e2e/sms/features/steps/inbounds.steps.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +from behave import when, then +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@when('I send a request to retrieve an inbound message') +def step_retrieve_inbound_message(context): + """Retrieve a single inbound message by ID""" + context.response = context.sms.inbounds.get( + inbound_id='01W4FFL35P4NC4K35INBOUND01' + ) + + +@then('the response contains the inbound message details') +def step_validate_inbound_message(context): + """Validate the inbound message response""" + data: MOTextMessage = context.response + assert isinstance(data, MOTextMessage) + assert data.id == '01W4FFL35P4NC4K35INBOUND01' + assert data.from_ == '12015555555' + assert data.to == '12017777777' + assert data.body == 'Hello John!' + assert data.type == 'mo_text' + assert data.operator_id == '311071' + assert data.received_at == datetime(2024, 6, 6, 14, 16, 54, 777000, tzinfo=timezone.utc) + + +@when('I send a request to list the inbound messages') +def step_list_inbound_messages(context): + """List a page of inbound messages""" + context.response = context.sms.inbounds.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + + +@then('the response contains "{count}" inbound messages') +def step_validate_inbound_messages_count(context, count): + """Validate the count of inbound messages in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the inbound messages') +def step_list_all_inbound_messages(context): + """List all inbound messages using iterator""" + response = context.sms.inbounds.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + inbound_messages_list = [] + + for inbound_message in response.iterator(): + inbound_messages_list.append(inbound_message) + + context.inbound_messages_list = inbound_messages_list + + +@then('the inbound messages list contains "{count}" inbound messages') +def step_validate_inbound_messages_list_count(context, count): + """Validate the count of inbound messages in the full list""" + expected_count = int(count) + assert len(context.inbound_messages_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.inbound_messages_list)}' + + +@when('I iterate manually over the inbound messages pages') +def step_iterate_manually_inbound_messages(context): + """Manually iterate over inbound messages pages""" + context.list_response = context.sms.inbounds.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + + context.inbound_messages_list = [] + context.pages_iteration = 0 + reached_last_page = False + + while not reached_last_page: + context.inbound_messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_last_page = True + + +@then('the inbound messages iteration result contains the data from "{count}" pages') +def step_validate_inbound_messages_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 99907fc4..16c85332 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -1,10 +1,8 @@ import requests from datetime import datetime, timezone from behave import given, when, then +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent -from sinch.domains.sms.sinch_events.v1.events import ( - MOTextSinchEvent, -) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, RecipientDeliveryReport, @@ -36,7 +34,7 @@ def step_check_valid_signature(context, event_type, status=None): @then('the SMS event describes an "incoming SMS" event') def step_check_incoming_sms_event(context): - incoming_sms_event: MOTextSinchEvent = context.event + incoming_sms_event: MOTextMessage = context.event assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' assert incoming_sms_event.from_ == '12015555555' assert incoming_sms_event.to == '12017777777' diff --git a/tests/unit/domains/sms/v1/endpoints/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..93002408 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py @@ -0,0 +1,66 @@ +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 ListGroupMembersResponse correctly.""" + from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + + result = endpoint.handle_response(mock_response) + + 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): + """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..fa340031 --- /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_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 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(): + """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/endpoints/inbounds/test_get_inbound_endpoint.py b/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py new file mode 100644 index 00000000..b8d1a8f3 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py @@ -0,0 +1,89 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import GetInboundEndpoint +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest +from sinch.domains.sms.models.v1.shared import MOBinaryMessage, MOTextMessage + + +@pytest.fixture +def request_data(): + return InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_mo_text_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Test inbound message", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetInboundEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/inbounds/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_mo_text_message(endpoint, mock_mo_text_response): + """Test that the response is correctly parsed as MOTextMessage.""" + parsed = endpoint.handle_response(mock_mo_text_response) + + assert isinstance(parsed, MOTextMessage) + assert parsed.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.from_ == "+46701234567" + assert parsed.to == "+46709876543" + assert parsed.body == "Test inbound message" + assert parsed.type == "mo_text" + assert parsed.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_handle_response_expects_mo_binary_message(request_data): + """Test that the response is correctly parsed as MOBinaryMessage.""" + mock_binary_response = HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + }, + headers={"Content-Type": "application/json"}, + ) + endpoint = GetInboundEndpoint("test_project_id", request_data) + parsed = endpoint.handle_response(mock_binary_response) + + assert isinstance(parsed, MOBinaryMessage) + assert parsed.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.body == "SGVsbG8gV29ybGQ=" + assert parsed.udh == "050003010201" + assert parsed.type == "mo_binary" + + +def test_handle_response_expects_sms_exception_on_error(endpoint): + """Test that SmsException is raised when server returns an error.""" + error_response = HTTPResponse(status_code=404, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py b/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py new file mode 100644 index 00000000..c5c39ef6 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py @@ -0,0 +1,123 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ListInboundsEndpoint +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ListInboundsRequest +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def request_data(): + return ListInboundsRequest( + page=0, + page_size=2, + to=["+46709876543"], + client_reference="ref123", + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 1, + "page": 0, + "page_size": 2, + "inbounds": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Test inbound message", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListInboundsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/inbounds" + ) + + +def test_build_query_params_expects_all_params(endpoint): + query_params = endpoint.build_query_params() + + assert query_params["page"] == 0 + assert query_params["page_size"] == 2 + assert query_params["to"] == "+46709876543" + assert query_params["client_reference"] == "ref123" + + +def test_build_query_params_expects_excludes_none_values(): + """Test that None values are excluded from query parameters.""" + endpoint = ListInboundsEndpoint( + "test_project_id", ListInboundsRequest() + ) + query_params = endpoint.build_query_params() + + assert len(query_params) == 0 + assert "page" not in query_params + assert "page_size" not in query_params + assert "to" not in query_params + assert "start_date" not in query_params + assert "end_date" not in query_params + assert "client_reference" not in query_params + + +def test_build_query_params_expects_date_filters(): + """Test that date filters are included when provided.""" + request_data = ListInboundsRequest( + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + ) + endpoint = ListInboundsEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert "start_date" in query_params + assert "end_date" in query_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed = endpoint.handle_response(mock_response) + + assert isinstance(parsed, ListInboundsResponse) + assert parsed.count == 1 + assert parsed.page == 0 + assert parsed.page_size == 2 + assert parsed.inbounds is not None + assert len(parsed.inbounds) == 1 + + first_inbound = parsed.inbounds[0] + assert isinstance(first_inbound, MOTextMessage) + assert first_inbound.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first_inbound.from_ == "+46701234567" + assert first_inbound.body == "Test inbound message" + assert first_inbound.type == "mo_text" + + +def test_handle_response_expects_sms_exception_on_error(endpoint): + """Test that SmsException is raised when server returns an error.""" + error_response = HTTPResponse(status_code=404, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 404" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 404 diff --git a/tests/unit/domains/sms/v1/models/internal/test_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_inbound_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py new file mode 100644 index 00000000..a5980ddc --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py @@ -0,0 +1,18 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest + + +def test_inbound_id_request_expects_valid_inbound_id(): + """Test that the model correctly parses a valid inbound ID.""" + model = InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.inbound_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_inbound_id_request_expects_validation_error_for_missing_inbound_id(): + """Test that missing required inbound_id field raises a ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + InboundIdRequest() + + assert "inbound_id" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_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_list_inbounds_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py new file mode 100644 index 00000000..0140fe8a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ListInboundsRequest + + +def test_list_inbounds_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListInboundsRequest() + + assert model.page is None + assert model.page_size is None + assert model.to is None + assert model.start_date is None + assert model.end_date is None + assert model.client_reference is None + + +def test_list_inbounds_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + model = ListInboundsRequest( + page=1, + page_size=50, + to=["+46701234567", "+46709876543"], + start_date=start, + end_date=end, + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.to == ["+46701234567", "+46709876543"] + assert model.start_date == start + assert model.end_date == end + assert model.client_reference == "my-client-ref" diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py new file mode 100644 index 00000000..4f606cd7 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py @@ -0,0 +1,64 @@ +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +def test_list_inbounds_response_empty_content_expects_empty_list(): + """Test that empty inbounds list returns empty content.""" + model = ListInboundsResponse(count=0, page=0, page_size=30, inbounds=None) + + assert model.count == 0 + assert model.page == 0 + assert model.page_size == 30 + assert model.content == [] + + +def test_list_inbounds_response_expects_correct_mapping(): + """Test that response is handled and mapped to the appropriate fields correctly.""" + data = { + "count": 2, + "page": 0, + "page_size": 2, + "inbounds": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Hello from test", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "ref-001", + }, + { + "id": "01FC66621XXXXX119Z8PMV1QPR", + "from": "+46701234568", + "to": "+46709876543", + "body": "Second message", + "type": "mo_text", + "received_at": "2024-06-06T09:25:00.000Z", + }, + ], + } + response = ListInboundsResponse(**data) + + assert response.count == 2 + assert response.page == 0 + assert response.page_size == 2 + + content = response.content + assert isinstance(content, list) + assert len(content) == 2 + + first = content[0] + assert isinstance(first, MOTextMessage) + assert first.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first.from_ == "+46701234567" + assert first.body == "Hello from test" + assert first.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert first.client_reference == "ref-001" + + second = content[1] + assert isinstance(second, MOTextMessage) + assert second.id == "01FC66621XXXXX119Z8PMV1QPR" + assert second.body == "Second message" + assert second.client_reference is None diff --git a/tests/unit/domains/sms/v1/models/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_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/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/models/response/test_mo_binary_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py new file mode 100644 index 00000000..3b26cfed --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py @@ -0,0 +1,90 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOBinaryMessage + + +@pytest.fixture +def sample_mo_binary_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + + +def test_mo_binary_message_expects_valid_input(sample_mo_binary_data): + """Test that the model correctly parses valid input.""" + msg = MOBinaryMessage(**sample_mo_binary_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.body == "SGVsbG8gV29ybGQ=" + assert msg.udh == "050003010201" + assert msg.type == "mo_binary" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_mo_binary_message_expects_optional_fields_none(sample_mo_binary_data): + """Test that optional fields default to None when not provided.""" + msg = MOBinaryMessage(**sample_mo_binary_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_binary_message_expects_optional_fields_populated(): + """Test that optional fields are parsed correctly when provided.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "my-client-ref", + "operator_id": "24001", + } + msg = MOBinaryMessage(**data) + + assert msg.client_reference == "my-client-ref" + assert msg.operator_id == "24001" + + +def test_mo_binary_message_expects_validation_error_for_missing_udh(): + """Test that missing required udh field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOBinaryMessage(**data) + + assert "udh" in str(exc_info.value) + + +def test_mo_binary_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOBinaryMessage(**data) + + assert "body" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py new file mode 100644 index 00000000..6e89f49a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOMediaBody, MOMediaItem, MOMediaMessage + + +@pytest.fixture +def sample_mo_media_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + "body": { + "subject": "MMS subject", + "message": "Hello media", + "media": [ + { + "url": "https://example.com/img.jpg", + "content_type": "image/jpeg", + "status": "Uploaded", + "code": 200, + } + ], + }, + } + + +def test_mo_media_message_expects_valid_input(sample_mo_media_data): + """Test that the model correctly parses valid input including nested body.""" + msg = MOMediaMessage(**sample_mo_media_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.type == "mo_media" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + assert isinstance(msg.body, MOMediaBody) + assert msg.body.subject == "MMS subject" + assert msg.body.message == "Hello media" + assert msg.body.media is not None + assert len(msg.body.media) == 1 + + item = msg.body.media[0] + assert isinstance(item, MOMediaItem) + assert item.url == "https://example.com/img.jpg" + assert item.content_type == "image/jpeg" + assert item.status == "Uploaded" + assert item.code == 200 + + +def test_mo_media_message_expects_body_with_no_media(): + """Test that body with no media attachment is valid.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + "body": { + "subject": "Hi", + "message": "Text only MMS", + }, + } + msg = MOMediaMessage(**data) + + assert msg.body.subject == "Hi" + assert msg.body.message == "Text only MMS" + assert msg.body.media is None + + +def test_mo_media_message_expects_optional_fields_none(sample_mo_media_data): + """Test that optional base fields default to None.""" + msg = MOMediaMessage(**sample_mo_media_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_media_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOMediaMessage(**data) + + assert "body" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py new file mode 100644 index 00000000..59dac69b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def sample_mo_text_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + + +def test_mo_text_message_expects_valid_input(sample_mo_text_data): + """Test that the model correctly parses valid input.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.body == "Hello world" + assert msg.type == "mo_text" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_mo_text_message_expects_optional_fields_none(sample_mo_text_data): + """Test that optional fields default to None when not provided.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_text_message_expects_from_alias(sample_mo_text_data): + """Test that the model accepts 'from' alias and exposes it as 'from_'.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.from_ == "+46701234567" + + +def test_mo_text_message_expects_optional_fields_populated(): + """Test that optional fields are parsed correctly when provided.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "my-client-ref", + "operator_id": "24001", + "sent_at": "2024-06-06T09:20:00.000Z", + } + msg = MOTextMessage(**data) + + assert msg.client_reference == "my-client-ref" + assert msg.operator_id == "24001" + assert msg.sent_at == datetime(2024, 6, 6, 9, 20, 0, tzinfo=timezone.utc) + + +def test_mo_text_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOTextMessage(**data) + + assert "body" in str(exc_info.value) + + +def test_mo_text_message_expects_validation_error_for_missing_id(): + """Test that missing required id field raises a ValidationError.""" + data = { + "from": "+46701234567", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOTextMessage(**data) + + assert "id" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/test_groups.py b/tests/unit/domains/sms/v1/test_groups.py new file mode 100644 index 00000000..569d8cd1 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_groups.py @@ -0,0 +1,215 @@ +import pytest +from sinch.core.pagination import SMSPaginator +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 + +@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 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__") + + 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 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() diff --git a/tests/unit/domains/sms/v1/test_inbounds.py b/tests/unit/domains/sms/v1/test_inbounds.py new file mode 100644 index 00000000..97588e3b --- /dev/null +++ b/tests/unit/domains/sms/v1/test_inbounds.py @@ -0,0 +1,126 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ( + GetInboundEndpoint, + ListInboundsEndpoint, +) +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def mock_mo_text_response(): + """Sample MOTextMessage for testing.""" + return MOTextMessage( + id="01FC66621XXXXX119Z8PMV1QPQ", + from_="+46701234567", + to="+46709876543", + body="Test inbound message", + type="mo_text", + received_at=datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc), + ) + + +def test_inbounds_get_correct_request( + mock_sinch_client_sms, mocker, mock_mo_text_response +): + """Test that get sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = mock_mo_text_response + + spy_endpoint = mocker.spy(GetInboundEndpoint, "__init__") + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.get(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].inbound_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, MOTextMessage) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_inbounds_list_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and handles the response properly.""" + mock_response = ListInboundsResponse(count=1, page=0, page_size=2, inbounds=[]) + mock_sinch_client_sms.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ListInboundsEndpoint, "__init__") + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.list( + page=0, + page_size=2, + to=["+46709876543"], + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].page == 0 + assert kwargs["request_data"].page_size == 2 + assert kwargs["request_data"].to == ["+46709876543"] + assert kwargs["request_data"].start_date == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert kwargs["request_data"].end_date == datetime(2025, 1, 31, tzinfo=timezone.utc) + assert kwargs["request_data"].client_reference == "test_client_ref" + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_sms_endpoint_handle_response_raises_exception_on_error( + mock_sinch_client_sms, +): + """ + Test that SmsEndpoint.handle_response raises SmsException when status_code >= 400. + """ + request_data = InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + endpoint = GetInboundEndpoint("test_project_id", request_data) + + error_response = HTTPResponse(status_code=400, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 400" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 400 + + +def test_inbounds_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, mock_mo_text_response +): + """ + Test that SMS requests validate authentication and recalculate auth method + when credentials change after initialization. + """ + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + config.transport.request.return_value = mock_mo_text_response + config.sms_api_token = "test_sms_token" + + assert config.authentication_method == "project_auth" + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.get(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert config.authentication_method == "sms_auth" + assert isinstance(response, MOTextMessage) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py deleted file mode 100644 index c567dcb9..00000000 --- a/tests/unit/http_transport_tests.py +++ /dev/null @@ -1,253 +0,0 @@ -import pytest -from unittest.mock import Mock, call -from sinch.core.enums import HTTPAuthentication -from sinch.core.exceptions import ValidationException -from sinch.core.models.http_request import HttpRequest -from sinch.core.endpoint import HTTPEndpoint -from sinch.core.models.http_response import HTTPResponse -from sinch.core.ports.http_transport import HTTPTransport -from sinch.core.token_manager import TokenState - - -# Mock classes and fixtures -def _make_mock_endpoint(auth_type, error_on_4xx=False): - """Create a MockEndpoint that satisfies the abstract property contract.""" - - class _Endpoint(HTTPEndpoint): - HTTP_AUTHENTICATION = auth_type - HTTP_METHOD = "GET" - - def __init__(self): - # Skip super().__init__ — we don't need project_id / request_data - pass - - def build_url(self, sinch): - return "api.sinch.com/test" - - def get_url_without_origin(self, sinch): - return "/test" - - def request_body(self): - return {} - - def build_query_params(self): - return {} - - def handle_response(self, response: HTTPResponse): - if error_on_4xx and response.status_code >= 400: - raise ValidationException( - message=f"HTTP {response.status_code}", - is_from_server=True, - response=response, - ) - return response - - return _Endpoint() - - -@pytest.fixture -def mock_sinch(): - sinch = Mock() - sinch.configuration = Mock() - sinch.configuration.key_id = "test_key_id" - sinch.configuration.key_secret = "test_key_secret" - sinch.configuration.project_id = "test_project_id" - sinch.configuration.sms_api_token = "test_sms_token" - sinch.configuration.service_plan_id = "test_service_plan" - return sinch - - -@pytest.fixture -def base_request(): - return HttpRequest( - headers={}, - url="https://api.sinch.com/test", - http_method="GET", - request_body={}, - query_params={}, - auth=() - ) - - -class MockHTTPTransport(HTTPTransport): - """Transport whose send() returns from a pre-configured list of responses.""" - - def __init__(self, sinch, responses=None): - super().__init__(sinch) - self._responses = list(responses or []) - self._call_count = 0 - - def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: - if self._call_count < len(self._responses): - resp = self._responses[self._call_count] - else: - resp = HTTPResponse(status_code=200, body={}, headers={}) - self._call_count += 1 - return resp - - @property - def call_count(self): - return self._call_count - - -# Synchronous Transport Tests -class TestHTTPTransport: - @pytest.mark.parametrize("auth_type", [ - HTTPAuthentication.BASIC.value, - HTTPAuthentication.OAUTH.value, - HTTPAuthentication.SMS_TOKEN.value - ]) - def test_authenticate(self, mock_sinch, base_request, auth_type): - transport = MockHTTPTransport(mock_sinch) - endpoint = _make_mock_endpoint(auth_type) - - if auth_type == HTTPAuthentication.BASIC.value: - result = transport.authenticate(endpoint, base_request) - assert result.auth == ("test_key_id", "test_key_secret") - - elif auth_type == HTTPAuthentication.OAUTH.value: - mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" - result = transport.authenticate(endpoint, base_request) - assert result.headers["Authorization"] == "Bearer test_token" - assert result.headers["Content-Type"] == "application/json" - - elif auth_type == HTTPAuthentication.SMS_TOKEN.value: - result = transport.authenticate(endpoint, base_request) - assert result.headers["Authorization"] == "Bearer test_sms_token" - assert result.headers["Content-Type"] == "application/json" - - @pytest.mark.parametrize("auth_type,missing_creds", [ - (HTTPAuthentication.BASIC.value, {"key_id": None}), - (HTTPAuthentication.OAUTH.value, {"key_secret": None}), - (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) - ]) - def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): - transport = MockHTTPTransport(mock_sinch) - endpoint = _make_mock_endpoint(auth_type) - - for cred, value in missing_creds.items(): - setattr(mock_sinch.configuration, cred, value) - - with pytest.raises(ValidationException): - transport.authenticate(endpoint, base_request) - - -class TestTokenRefreshRetry: - """Tests for the automatic token refresh on 401 expired responses.""" - - @staticmethod - def _expired_401(): - return HTTPResponse( - status_code=401, - body={"error": "token expired"}, - headers={"www-authenticate": "Bearer error=\"expired\""}, - ) - - @staticmethod - def _non_expired_401(): - return HTTPResponse( - status_code=401, - body={"error": "unauthorized"}, - headers={"www-authenticate": "Bearer error=\"invalid_token\""}, - ) - - @staticmethod - def _ok_200(): - return HTTPResponse(status_code=200, body={"ok": True}, headers={}) - - def test_retry_succeeds_after_expired_token(self, mock_sinch): - """A single 401-expired followed by a 200 should retry once and succeed.""" - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - token_manager.token_state = TokenState.VALID - - def mark_expired(http_response): - token_manager.token_state = TokenState.EXPIRED - - token_manager.handle_invalid_token.side_effect = mark_expired - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._expired_401(), self._ok_200()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) - - result = transport.request(endpoint) - - assert result.status_code == 200 - assert transport.call_count == 2 - token_manager.handle_invalid_token.assert_called_once() - - def test_no_infinite_loop_on_persistent_401(self, mock_sinch): - """Two consecutive 401-expired must NOT cause infinite retries. - - The second 401 should be handed to the endpoint's error handler - and send() should be called at most twice. - """ - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - token_manager.token_state = TokenState.VALID - - def mark_expired(http_response): - token_manager.token_state = TokenState.EXPIRED - - token_manager.handle_invalid_token.side_effect = mark_expired - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._expired_401(), self._expired_401()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) - - with pytest.raises(ValidationException, match="401"): - transport.request(endpoint) - - # send() must have been called exactly twice: initial + one retry - assert transport.call_count == 2 - - def test_no_retry_when_401_is_not_expired(self, mock_sinch): - """A 401 without 'expired' in WWW-Authenticate should NOT trigger a retry.""" - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - token_manager.token_state = TokenState.VALID - - # handle_invalid_token inspects the header but does NOT set EXPIRED - # because the header says "invalid_token", not "expired" - token_manager.handle_invalid_token.side_effect = lambda r: None - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._non_expired_401()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) - - with pytest.raises(ValidationException, match="401"): - transport.request(endpoint) - - # send() called only once — no retry - assert transport.call_count == 1 - - def test_no_retry_for_non_oauth_endpoint(self, mock_sinch): - """A 401 on a BASIC-auth endpoint should NOT trigger token refresh.""" - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._expired_401()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, error_on_4xx=True) - - with pytest.raises(ValidationException, match="401"): - transport.request(endpoint) - - assert transport.call_count == 1 - token_manager.handle_invalid_token.assert_not_called() diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py new file mode 100644 index 00000000..cca39c5c --- /dev/null +++ b/tests/unit/test_http_transport.py @@ -0,0 +1,471 @@ +import json +import random +import time +import pytest +from unittest.mock import Mock +from sinch.core.enums import HTTPAuthentication +from sinch.core.exceptions import ValidationException, SinchException +from sinch.core.models.http_request import HttpRequest +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.models.http_response import HTTPResponse +from sinch.core.adapters.requests_http_transport import HTTPTransportRequests +from sinch.core.ports.http_transport import HTTPTransport +from sinch.core.token_manager import TokenManager +from sinch.domains.authentication.models.v1.authentication import OAuthToken + + +# Mock classes and fixtures +def _make_mock_endpoint(auth_type, error_on_4xx=False): + """Create a MockEndpoint that satisfies the abstract property contract.""" + + class _Endpoint(HTTPEndpoint): + HTTP_AUTHENTICATION = auth_type + HTTP_METHOD = "GET" + + def __init__(self): + # Skip super().__init__ — we don't need project_id / request_data + pass + + def build_url(self, sinch): + return "api.sinch.com/test" + + def get_url_without_origin(self, sinch): + return "/test" + + def request_body(self): + return {} + + def build_query_params(self): + return {} + + def handle_response(self, response: HTTPResponse): + if error_on_4xx and response.status_code >= 400: + raise ValidationException( + message=f"HTTP {response.status_code}", + is_from_server=True, + response=response, + ) + return response + + return _Endpoint() + + +def _requests_response(status_code, body=None, headers=None): + """Fake of a requests.Response, just enough for deserialize_json_response.""" + resp = Mock() + resp.status_code = status_code + resp.content = json.dumps(body or {}).encode() + resp.json.return_value = body or {} + resp.headers = headers or {} + return resp + + +def _server_rejecting_expired_token(accepted_token): + """Fake http_session.request: 200 only when the request carries `accepted_token`, + otherwise a 401-expired — like a server that rejects the stale token.""" + def respond(*args, **kwargs): + if kwargs["headers"].get("Authorization") == accepted_token: + return _requests_response(200, body={"ok": True}) + return _requests_response(401, headers={"www-authenticate": 'Bearer error="expired"'}) + return respond + + +def _token_manager(mock_sinch, *, old="old", new="new"): + """Mock TokenManager that hands out `old` and renews to `new`.""" + token_manager = Mock(spec=TokenManager) + token_manager.refresh_auth_token.return_value = OAuthToken( + access_token=new, expires_in=3599, scope="", token_type="bearer" + ) + mock_sinch.configuration.token_manager = token_manager + # authenticate() reads the initial token via sinch.authentication.get_auth_token() + mock_sinch.authentication.get_auth_token.return_value.access_token = old + return token_manager + + +@pytest.fixture +def mock_sinch(): + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.key_id = "test_key_id" + sinch.configuration.key_secret = "test_key_secret" + sinch.configuration.project_id = "test_project_id" + sinch.configuration.sms_api_token = "test_sms_token" + sinch.configuration.service_plan_id = "test_service_plan" + return sinch + + +@pytest.fixture +def no_sleep(mocker): + mocker.patch.object(random, "uniform", return_value=0.0) + return mocker.patch.object(time, "sleep") + + +@pytest.fixture +def base_request(): + return HttpRequest( + headers={}, + url="https://api.sinch.com/test", + http_method="GET", + request_body={}, + query_params={}, + auth=() + ) + + +class TestHTTPTransport: + @pytest.mark.parametrize("auth_type", [ + HTTPAuthentication.BASIC.value, + HTTPAuthentication.OAUTH.value, + HTTPAuthentication.SMS_TOKEN.value + ]) + def test_authenticate(self, mock_sinch, base_request, auth_type): + transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(auth_type) + + if auth_type == HTTPAuthentication.BASIC.value: + result = transport.authenticate(endpoint, base_request) + assert result.auth == ("test_key_id", "test_key_secret") + + elif auth_type == HTTPAuthentication.OAUTH.value: + mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_token" + assert result.headers["Content-Type"] == "application/json" + + elif auth_type == HTTPAuthentication.SMS_TOKEN.value: + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_sms_token" + assert result.headers["Content-Type"] == "application/json" + + @pytest.mark.parametrize("auth_type,missing_creds", [ + (HTTPAuthentication.BASIC.value, {"key_id": None}), + (HTTPAuthentication.OAUTH.value, {"key_secret": None}), + (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) + ]) + def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): + transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(auth_type) + + for cred, value in missing_creds.items(): + setattr(mock_sinch.configuration, cred, value) + + with pytest.raises(ValidationException): + transport.authenticate(endpoint, base_request) + + +class TestSend: + def test_send_maps_requests_response(self, mock_sinch, base_request): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=_requests_response(200, body={"x": 1}) + ) + + result = transport.send_request(base_request) + + assert isinstance(result, HTTPResponse) + assert result.status_code == 200 + assert result.body == {"x": 1} + + def test_send_empty_body_returns_empty_dict(self, mock_sinch, base_request): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=Mock(status_code=204, content=b"", headers={}) + ) + + result = transport.send_request(base_request) + + assert result.status_code == 204 + assert result.body == {} + + def test_send_raises_on_invalid_json(self, mock_sinch, base_request): + transport = HTTPTransportRequests(mock_sinch) + bad_response = Mock(status_code=200, content=b"not json", headers={}) + bad_response.json.side_effect = ValueError("bad json") + transport.http_session.request = Mock(return_value=bad_response) + + with pytest.raises(SinchException): + transport.send_request(base_request) + + +class TestTokenRefreshRetry: + """Tests for the automatic token refresh on 401-expired responses.""" + + @staticmethod + def _expired_401(): + return _requests_response( + 401, + body={"error": "token expired"}, + headers={"www-authenticate": 'Bearer error="expired"'}, + ) + + @staticmethod + def _non_expired_401(): + return _requests_response( + 401, + body={"error": "unauthorized"}, + headers={"www-authenticate": 'Bearer error="invalid_token"'}, + ) + + def test_retry_succeeds_after_expired_token(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + # The server accepts only the renewed token, so a 200 proves the retry re-stamped it. + transport.http_session.request = Mock(side_effect=_server_rejecting_expired_token("Bearer new")) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 2 + token_manager.refresh_auth_token.assert_called_once_with("old") + + def test_no_retry_when_401_is_not_expired(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[self._non_expired_401()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + def test_no_retry_for_non_oauth_endpoint(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[self._expired_401()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + def test_only_one_retry_on_persistent_401(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[self._expired_401(), self._expired_401()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 2 + token_manager.refresh_auth_token.assert_called_once() + + def test_no_refresh_on_successful_request(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=_requests_response(200, body={"ok": True}) + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + def test_no_refresh_on_401_without_www_authenticate(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=_requests_response(401, body={}) + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + +class TestRetryWithBackoff: + """Tests for the automatic retry-with-backoff on rate-limited (429) responses.""" + + @staticmethod + def _rate_limited(headers=None): + return _requests_response(429, body={"error": "rate limited"}, headers=headers) + + def test_retries_on_429_then_succeeds(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[ + self._rate_limited(), + self._rate_limited(), + _requests_response(200, body={"ok": True}), + ]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 3 + assert no_sleep.call_count == 2 + + def test_gives_up_and_returns_last_response_after_max_retries(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=self._rate_limited()) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 429 + assert transport.http_session.request.call_count == HTTPTransport.MAX_RETRIES + 1 + assert no_sleep.call_count == HTTPTransport.MAX_RETRIES + + def test_no_retry_on_success(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=_requests_response(200, body={"ok": True})) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + + def test_no_retry_on_non_retryable_status(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=_requests_response(400, body={"error": "bad"})) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 400 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + + def test_honors_retry_after_header(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[ + self._rate_limited(headers={"Retry-After": "7"}), + _requests_response(200, body={"ok": True}), + ]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + transport.request(endpoint) + + no_sleep.assert_called_once_with(7.0) + + +class TestShouldRetry: + def test_retries_429_while_attempts_remain(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(response, num_retries=0) is True + + def test_stops_when_max_retries_reached(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(response, num_retries=HTTPTransport.MAX_RETRIES) is False + + def test_does_not_retry_non_retryable_status(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=200, headers={}, body={}) + + assert transport._should_retry(response, num_retries=0) is False + + +class TestComputeBackoff: + def test_uses_retry_after_header_when_present(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={"Retry-After": "5"}, body={}) + + backoff = transport._compute_backoff(response, num_retries=0) + + assert 5.0 <= backoff < 5.25 + + def test_exponential_growth_when_no_header(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert 0.0 <= transport._compute_backoff(response, num_retries=0) <= 1.0 + assert 0.0 <= transport._compute_backoff(response, num_retries=1) <= 4.0 + assert 0.0 <= transport._compute_backoff(response, num_retries=2) <= 16.0 + + +class TestParseRetryAfter: + @pytest.mark.parametrize("value,expected", [ + ("5", 5.0), + ("0", 0.0), + ("-3", None), + ("abc", None), + ("", None), + (None, None), + ("Wed, 21 Oct 2015 07:28:00 GMT", 0.0), + ("Wed, 21 Oct 2015 07:28:00", 0.0), + ]) + def test_parse_retry_after(self, value, expected): + assert HTTPTransport._parse_retry_after(value) == expected + + +class _LegacyTransport(HTTPTransport): + """A pre-2.1 transport that overrides the deprecated ``send(endpoint)`` hook.""" + + def __init__(self, sinch, responses): + super().__init__(sinch) + self._responses = list(responses) + self.send_calls = 0 + + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: + self.send_calls += 1 + return self._responses.pop(0) + + +class _NoHookTransport(HTTPTransport): + """A transport that implements neither ``send`` nor ``send_request``.""" + pass + + +class TestLegacySend: + """Tests for the deprecated ``send(endpoint)`` override path (_legacy_request).""" + + @staticmethod + def _expired_401(): + return HTTPResponse( + status_code=401, + headers={"www-authenticate": 'Bearer error="expired"'}, + body={"error": "token expired"}, + ) + + @staticmethod + def _ok_200(): + return HTTPResponse(status_code=200, headers={}, body={"ok": True}) + + def test_legacy_send_emits_deprecation_warning(self, mock_sinch): + with pytest.warns(DeprecationWarning, match="send"): + _LegacyTransport(mock_sinch, [self._ok_200()]) + + def test_legacy_request_retries_on_expired_token(self, mock_sinch): + token_manager = Mock() + token_manager.token = OAuthToken( + access_token="old", expires_in=3599, scope="", token_type="bearer" + ) + token_manager.refresh_auth_token.return_value = OAuthToken( + access_token="new", expires_in=3599, scope="", token_type="bearer" + ) + mock_sinch.configuration.token_manager = token_manager + + with pytest.warns(DeprecationWarning): + transport = _LegacyTransport(mock_sinch, [self._expired_401(), self._ok_200()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + + assert transport.send_calls == 2 + token_manager.refresh_auth_token.assert_called_once_with("old") + + def test_request_raises_when_send_request_not_implemented(self, mock_sinch): + transport = _NoHookTransport(mock_sinch) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + with pytest.raises(NotImplementedError, match="send_request"): + transport.request(endpoint) diff --git a/tests/unit/test_token_manager.py b/tests/unit/test_token_manager.py index 502d5ad1..9219cd75 100644 --- a/tests/unit/test_token_manager.py +++ b/tests/unit/test_token_manager.py @@ -1,3 +1,5 @@ +import threading +import time import pytest from unittest.mock import Mock @@ -30,3 +32,79 @@ def test_get_auth_token_and_check_if_cached(sinch_client_sync, auth_token): assert isinstance(access_token, OAuthToken) assert token_manager.token is auth_token + + +def test_get_auth_token_fetches_once_under_concurrency(auth_token): + num_threads = 20 + barrier = threading.Barrier(num_threads) + sinch = Mock() + + def slow_fetch(endpoint): + time.sleep(0.05) + return auth_token + + sinch.configuration.transport.request.side_effect = slow_fetch + + token_manager = TokenManager(sinch) + + results = [] + + def worker(): + barrier.wait() + results.append(token_manager.get_auth_token()) + + threads = [threading.Thread(target=worker) for _ in range(num_threads)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert sinch.configuration.transport.request.call_count == 1 + assert all(result is auth_token for result in results) + + +def test_refresh_auth_token_renews_once_under_concurrency(auth_token): + num_threads = 20 + barrier = threading.Barrier(num_threads) + sinch = Mock() + + def slow_fetch(endpoint): + time.sleep(0.05) + return auth_token + + sinch.configuration.transport.request.side_effect = slow_fetch + token_manager = TokenManager(sinch) + token_manager.token = OAuthToken( + access_token="old", expires_in=1, scope="", token_type="bearer" + ) + + results = [] + + def worker(): + barrier.wait() + results.append(token_manager.refresh_auth_token("old")) + + threads = [threading.Thread(target=worker) for _ in range(num_threads)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert sinch.configuration.transport.request.call_count == 1 + assert all(result is auth_token for result in results) + + +def test_invalidate_expired_token_emits_deprecation_warning(sinch_client_sync): + token_manager = TokenManager(sinch_client_sync) + + with pytest.warns(DeprecationWarning, match="invalidate_expired_token"): + token_manager.invalidate_expired_token() + + +def test_handle_invalid_token_emits_deprecation_warning(sinch_client_sync): + token_manager = TokenManager(sinch_client_sync) + response = Mock() + response.headers = {} + + with pytest.warns(DeprecationWarning, match="handle_invalid_token"): + token_manager.handle_invalid_token(response)