diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0534969d..9b4ead71 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,18 +1,6 @@
name: Test Python SDK
on: [ push ]
-env:
- AUTH_ORIGIN: ${{ secrets.AUTH_ORIGIN }}
- CONVERSATION_ORIGIN: ${{ secrets.CONVERSATION_ORIGIN }}
- DISABLE_SSL: ${{ secrets.DISABLE_SSL }}
- KEY_ID: ${{ secrets.KEY_ID }}
- KEY_SECRET: ${{ secrets.KEY_SECRET }}
- NUMBERS_ORIGIN: ${{ secrets.NUMBERS_ORIGIN }}
- PROJECT_ID: ${{ secrets.PROJECT_ID }}
- SERVICE_PLAN_ID: ${{ secrets.SERVICE_PLAN_ID }}
- SMS_ORIGIN: ${{ secrets.SMS_ORIGIN }}
- TEMPLATES_ORIGIN: ${{ secrets.TEMPLATES_ORIGIN }}
-
jobs:
build:
runs-on: ubuntu-latest
@@ -45,6 +33,10 @@ jobs:
run: |
pip install python-dotenv
python scripts/check_snippet_coverage.py
+
+ - name: Generate Documentation
+ run: |
+ make docs
- name: Lint and format check with Ruff
run: |
@@ -69,7 +61,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: sinch/sinch-sdk-mockserver
- token: ${{ secrets.PAT_CI }}
+ token: ${{ secrets.MOCKSERVER_REPO_PAT_CI }}
fetch-depth: 0
path: sinch-sdk-mockserver
@@ -84,11 +76,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/.github/workflows/documentation-upload.yaml b/.github/workflows/documentation-upload.yaml
new file mode 100644
index 00000000..05f3ce93
--- /dev/null
+++ b/.github/workflows/documentation-upload.yaml
@@ -0,0 +1,82 @@
+name: Generate and upload documentation
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to use for the documentation package in semver format (e.g. 1.2.3)'
+ required: true
+
+jobs:
+ upload-documentation:
+ runs-on: ubuntu-latest
+ env:
+ SDK_NAME: sinch-sdk-python
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Resolve Version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" = "release" ]; then
+ VERSION="${{ github.event.release.tag_name }}"
+ else
+ VERSION="${{ inputs.version }}"
+ fi
+ # Strip leading 'v' if present (e.g. v1.2.3 → 1.2.3)
+ VERSION="${VERSION#v}"
+ echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate Version Format
+ run: |
+ VERSION="${{ steps.version.outputs.value }}"
+ SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((alpha|beta|preview)(\.[0-9]+)?))?$'
+ if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
+ echo "::error::Invalid version format: '$VERSION'. Expected semver (e.g. 1.2.3, 1.2.3-alpha, 1.2.3-beta.1, 1.2.3-preview)"
+ exit 1
+ fi
+ echo "Version '$VERSION' is valid"
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dev dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Generate Documentation
+ run: |
+ make docs
+
+ - name: Package Documentation
+ run: |
+ cd docs/build/html
+ zip -r "../../../${{ env.SDK_NAME }}-${{ steps.version.outputs.value }}.zip" .
+
+ - name: Upload to GitLab Registry
+ run: |
+ echo "Uploading documentation package to GitLab Registry..."
+ VERSION="${{ steps.version.outputs.value }}"
+ curl --fail --show-error --location --header "PRIVATE-TOKEN: ${{ secrets.GITLAB_REGISTRY_UPLOAD_DOC_TOKEN }}" \
+ --upload-file "./${{ env.SDK_NAME }}-${VERSION}.zip" \
+ "https://gitlab.com/api/v4/projects/63164411/packages/generic/${{ env.SDK_NAME }}/${VERSION}/${{ env.SDK_NAME }}-${VERSION}.zip"
+ echo "Documentation package for version ${VERSION} uploaded to GitLab Registry"
+
+ - name: Trigger Downstream GitLab Pipeline
+ run: |
+ echo "Triggering downstream GitLab pipeline to notify about new documentation package version..."
+ VERSION="${{ steps.version.outputs.value }}"
+ curl --fail --show-error --location --request POST \
+ --form "token=${{ secrets.GITLAB_NOTIFY_REGISTRY_UPLOADED_DOC_TOKEN }}" \
+ --form "ref=main" \
+ --form "variables[UPSTREAM_PACKAGE_NAME]=${{ env.SDK_NAME }}" \
+ --form "variables[UPSTREAM_PACKAGE_VERSION]=${VERSION}" \
+ "https://gitlab.com/api/v4/projects/63164411/trigger/pipeline"
+ echo "Documentation repo notified about new package version ${VERSION}"
diff --git a/.gitignore b/.gitignore
index 3246a258..9441fa0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
+lcov.info
# E2E features
*.feature
@@ -73,7 +74,9 @@ instance/
.scrapy
# Sphinx documentation
-docs/_build/
+docs/build/
+docs/api/
+
# PyBuilder
.pybuilder/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9858cfaf..de41da68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,12 +16,29 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
---
-## v2.0.1 – 2026-06-02
+## v2.1.0 – 2026-06-05
### SDK
+- **[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).
+- **[tech]** Removed unused GitHub environment secrets from CI workflow and simplified test fixtures to use hardcoded test values (#162).
- **[doc]** Improve README structure and content(#155).
+
+### 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
- **[fix]** SMS paginator fix (#145).
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/Makefile b/Makefile
new file mode 100644
index 00000000..9d123075
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+.PHONY: docs
+
+docs:
+ rm -rf docs/build
+ rm -rf docs/api
+ sphinx-apidoc --force --separate --no-toc --maxdepth 2 \
+ --templatedir docs/_templates/apidoc -o docs/api sinch \
+ "sinch/core/models/base_model.py" \
+ "sinch/core/models/utils.py" \
+ "sinch/core/deserializers.py" \
+ "sinch/core/endpoint.py" \
+ "sinch/core/enums.py" \
+ "sinch/core/types.py" \
+ "sinch/domains/sms/enums.py" \
+ "sinch/*/internal" "sinch/*/internal/*" \
+ "sinch/*/api/v1/base" "sinch/*/api/v1/base/*" \
+ "sinch/*/api/v1/utils" "sinch/*/api/v1/utils/*" \
+ "sinch/domains/authentication/endpoints" "sinch/domains/authentication/endpoints/*" \
+ "sinch/domains/authentication/sinch_events" "sinch/domains/authentication/sinch_events/*" \
+ "sinch/domains/numbers/models/v1/utils" "sinch/domains/numbers/models/v1/utils/*"
+ sphinx-build -b html docs docs/build/html
diff --git a/README.md b/README.md
index ba8aa37b..aa9a1c6c 100644
--- a/README.md
+++ b/README.md
@@ -255,7 +255,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
@@ -266,9 +266,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/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 00000000..91609f05
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,21 @@
+/* ---------------------------------------------------------------------------
+ Multi-line signatures (one parameter per line).
+
+ When a signature is wrapped, Sphinx renders each parameter as a
inside
+ a nested . The Read the Docs theme styles every /- with borders,
+ background tint and vertical margins, which leak into the signature and draw
+ ugly "lines" between parameters. Flatten that nested list so the parameters
+ read as a clean indented column.
+--------------------------------------------------------------------------- */
+.rst-content .sig dl,
+.rst-content .sig dd {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: none;
+}
+
+/* Keep each parameter indented under the opening parenthesis. */
+.rst-content .sig dd {
+ margin-left: 2em;
+}
diff --git a/docs/_templates/apidoc/module.rst.jinja b/docs/_templates/apidoc/module.rst.jinja
new file mode 100644
index 00000000..c694aaee
--- /dev/null
+++ b/docs/_templates/apidoc/module.rst.jinja
@@ -0,0 +1,8 @@
+{%- if show_headings %}
+{{- [basename, "module"] | join(" ") | e | heading }}
+
+{% endif -%}
+.. automodule:: {{ qualname }}
+{%- for option in automodule_options %}
+ :{{ option }}:
+{%- endfor %}
diff --git a/docs/_templates/apidoc/package.rst.jinja b/docs/_templates/apidoc/package.rst.jinja
new file mode 100644
index 00000000..4e34d501
--- /dev/null
+++ b/docs/_templates/apidoc/package.rst.jinja
@@ -0,0 +1,39 @@
+{%- macro automodule(modname, options) -%}
+.. automodule:: {{ modname }}
+{%- for option in options %}
+ :{{ option }}:
+{%- endfor %}
+{%- endmacro %}
+
+{%- macro toctree(docnames) -%}
+.. toctree::
+ :maxdepth: {{ maxdepth }}
+{% for docname in docnames %}
+ {{ docname }}
+{%- endfor %}
+{%- endmacro %}
+
+{{- [pkgname, "package"] | join(" ") | e | heading }}
+
+{%- if is_namespace %}
+.. py:module:: {{ pkgname }}
+{% endif %}
+
+{%- if subpackages %}
+{{ toctree(subpackages) }}
+{% endif %}
+
+{%- if submodules %}
+{% if separatemodules %}
+{{ toctree(submodules) }}
+{% else %}
+{% for submodule in submodules %}
+{{ [submodule.split(".")[-1], "module"] | join(" ") | e | heading(2) }}
+{{ automodule(submodule, automodule_options) }}
+{% endfor %}
+{%- endif %}
+{%- endif %}
+
+{%- if not is_namespace %}
+{{ automodule(pkgname, automodule_options) }}
+{% endif %}
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 00000000..5d8b159e
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,62 @@
+import os
+import sys
+
+# Allow autodoc to import the sinch package from the project root
+sys.path.insert(0, os.path.abspath(".."))
+from sinch import __version__
+
+# -- Project information -------------------------------------------------------
+
+project = "Sinch Python SDK"
+copyright = "2026, Sinch Developer Experience Team"
+author = "Sinch Developer Experience Team"
+release = __version__
+
+# -- General configuration -----------------------------------------------------
+
+extensions = [
+ # Pulls docstrings from Python source into the generated .rst files
+ "sphinx.ext.autodoc",
+ # Adds [source] links that open the highlighted source file
+ "sphinx.ext.viewcode",
+]
+
+# The .rst files under api/ are generated by the `sphinx-apidoc` CLI invoked
+# from the Makefile (`make docs`), using the custom templates in
+# _templates/apidoc/ to strip the "package" suffix and the
+# "Subpackages"/"Submodules"/"Module contents" headings. The
+# sphinx.ext.apidoc extension is intentionally NOT used because it ignores
+# the template directory.
+
+# -- sphinx.ext.autodoc --------------------------------------------------------
+
+autodoc_default_options = {
+ # Document all public members (methods, attributes, nested classes)
+ "members": True,
+ 'undoc-members': True,
+ # Show the class inheritance chain
+ "show-inheritance": True,
+ # Preserve the order in which members appear in the source file
+ "member-order": "bysource",
+}
+
+# Render type hints as part of the parameter/return descriptions, not the signature
+autodoc_typehints = "both"
+python_maximum_signature_line_length = 88
+
+# -- HTML output ---------------------------------------------------------------
+
+html_theme = "sphinx_rtd_theme"
+
+html_theme_options = {
+ # Maximum depth of the navigation sidebar (-1 = unlimited)
+ "navigation_depth": -1,
+ # Keep all navigation entries expanded by default
+ "collapse_navigation": False,
+}
+
+# Directory with extra static files (custom CSS, etc.), relative to this conf.py
+html_static_path = ["_static"]
+
+# Extra stylesheets loaded after the theme's own CSS
+html_css_files = ["custom.css"]
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 00000000..9cb6c3fe
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,8 @@
+Sinch Python SDK
+================
+
+.. toctree::
+ :maxdepth: 3
+ :caption: API Reference
+
+ api/sinch
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..9f83aafa 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -8,7 +8,12 @@ behave
ruff
# HTTP Libraries
-requests
+requests>=2.0.0
# Data Validation
-pydantic >= 2.0.0
\ No newline at end of file
+pydantic >= 2.0.0
+
+# Documentation
+# Sphinx 7.1 introduced python_maximum_signature_line_length and 7.x is also the last series supporting Python 3.9.
+sphinx >= 7.1
+sphinx-rtd-theme >= 2.0
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/pagination.py b/sinch/core/pagination.py
index 326a4c9c..5c1bad78 100644
--- a/sinch/core/pagination.py
+++ b/sinch/core/pagination.py
@@ -5,7 +5,10 @@
class Paginator(ABC, Generic[BM]):
"""
- Pagination response object.
+ Public interface for paginated list responses.
+
+ Use :meth:`content`, :meth:`next_page` and :meth:`iterator` to traverse the
+ result set without dealing with the underlying pagination scheme.
"""
def __init__(self, sinch, endpoint, result: BM):
self._sinch = sinch
@@ -19,16 +22,34 @@ def __repr__(self):
# TODO: Make content() method abstract in Parent class as we implement in the other domains:
# - Refactor pydantic models in other domains to have a content property.
- def content(self):
+ def content(self) -> list[BM]:
+ """
+ Return the items contained in the current page.
+
+ :returns: The list of items in the current page.
+ :rtype: list[BM]
+ """
pass
# TODO: Make iterator() method abstract in Parent class as we implement in the other domains:
# - Refactor pydantic models in other domains to have a content property.
def iterator(self) -> Iterator[BM]:
+ """
+ Iterate over individual items across all pages, fetching each page on demand.
+
+ :returns: An iterator over every item in the result set.
+ :rtype: Iterator[BM]
+ """
pass
@abstractmethod
def next_page(self):
+ """
+ Advance to the next page of results.
+
+ :returns: This paginator positioned on the next page, or ``None`` if there is no further page.
+ :rtype: Paginator | None
+ """
pass
@abstractmethod
@@ -42,7 +63,10 @@ def _initialize(cls, sinch, endpoint):
class SMSPaginator(Paginator[BM]):
- """Base paginator for integer-based pagination with explicit page navigation and metadata."""
+ """Base paginator for integer-based pagination with explicit page navigation and metadata.
+
+ :meta private:
+ """
def __init__(self, sinch, endpoint, result=None):
super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint))
@@ -107,7 +131,10 @@ def _initialize(cls, sinch, endpoint):
class TokenBasedPaginator(Paginator[BM]):
- """Base paginator for token-based pagination with explicit page navigation and metadata."""
+ """Base paginator for token-based pagination with explicit page navigation and metadata.
+
+ :meta private:
+ """
def __init__(self, sinch, endpoint, result=None):
super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint))
diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py
index ec0edafc..6e97cf08 100644
--- a/sinch/core/ports/http_transport.py
+++ b/sinch/core/ports/http_transport.py
@@ -1,70 +1,119 @@
-from abc import ABC, abstractmethod
+import warnings
+from abc import ABC
from platform import python_version
+from typing import Optional
+
+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 ``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.
+ 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.
+
+ .. 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.
"""
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.
+
+ .. deprecated:: 2.1
+ This hook is deprecated. Implement :meth:`send_request` instead;
+ the ``send`` override path will be removed in 3.0.
- 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``.
+ :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_request(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_request(request_data)
return endpoint.handle_response(http_response)
- # ------------------------------------------------------------------
- # Internals
- # ------------------------------------------------------------------
- def authenticate(self, endpoint, request_data):
+ 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 +136,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 +147,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 +175,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 +198,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(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(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/enums.py b/sinch/domains/conversation/models/v1/messages/__init__.py
similarity index 100%
rename from sinch/domains/sms/enums.py
rename to sinch/domains/conversation/models/v1/messages/__init__.py
diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/__init__.py b/sinch/domains/conversation/models/v1/sinch_events/events/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/api/__init__.py b/sinch/domains/number_lookup/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/models/v1/__init__.py b/sinch/domains/number_lookup/models/v1/__init__.py
new file mode 100644
index 00000000..e69de29b
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/__init__.py b/sinch/domains/sms/sinch_events/__init__.py
new file mode 100644
index 00000000..e69de29b
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/conftest.py b/tests/conftest.py
index 321893a9..1681756b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -34,72 +34,21 @@ class TokenBasedPaginationRequest(BaseModel):
page_token: str = None
-def configure_origin(
- sinch_client,
- numbers_origin,
- conversation_origin,
- auth_origin,
- sms_origin
-):
- if auth_origin:
- sinch_client.configuration.auth_origin = auth_origin
-
- if numbers_origin:
- sinch_client.configuration.numbers_origin = numbers_origin
-
- if conversation_origin:
- sinch_client.configuration.conversation_origin = conversation_origin
-
- if sms_origin:
- sinch_client.configuration.sms_origin = sms_origin
- sinch_client.configuration.sms_origin_with_service_plan_id = sms_origin
-
- return sinch_client
-
-
@pytest.fixture
def key_id():
- return os.getenv("KEY_ID")
-
+ return "test_key_id"
@pytest.fixture
def key_secret():
- return os.getenv("KEY_SECRET")
-
+ return "test_key_secret"
@pytest.fixture
def project_id():
- return os.getenv("PROJECT_ID")
-
-
-@pytest.fixture
-def numbers_origin():
- return os.getenv("NUMBERS_ORIGIN")
-
-
-@pytest.fixture
-def conversation_origin():
- return os.getenv("CONVERSATION_ORIGIN")
-
-
-@pytest.fixture
-def auth_origin():
- return os.getenv("AUTH_ORIGIN")
-
-
-@pytest.fixture
-def sms_origin():
- return os.getenv("SMS_ORIGIN")
-
-
-@pytest.fixture
-def disable_ssl():
- return os.getenv("DISABLE_SSL")
-
+ return "test_project_id"
@pytest.fixture
def service_plan_id():
- return os.getenv("SERVICE_PLAN_ID")
+ return "test_service_plan_id"
@pytest.fixture
def http_response():
@@ -155,29 +104,17 @@ def sms_pagination_request_data_with_page_and_page_size_none():
return SMSBasePaginationRequest()
-
@pytest.fixture
def sinch_client_sync(
key_id,
key_secret,
- numbers_origin,
- conversation_origin,
- auth_origin,
- sms_origin,
project_id
):
- return configure_origin(
- SinchClient(
+ return SinchClient(
key_id=key_id,
key_secret=key_secret,
project_id=project_id
- ),
- numbers_origin,
- conversation_origin,
- auth_origin,
- sms_origin
- )
-
+ )
@pytest.fixture
def mock_sinch_client_numbers():
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..2636e9ce
--- /dev/null
+++ b/tests/unit/test_http_transport.py
@@ -0,0 +1,342 @@
+import json
+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 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 _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)