diff --git a/docs/hub/_index.md b/docs/hub/_index.md index 52004b3..cb5f09e 100644 --- a/docs/hub/_index.md +++ b/docs/hub/_index.md @@ -11,7 +11,7 @@ | [`internal`](./internal.md) | 1 | | [`logs`](./logs.md) | 9 | | [`membership`](./membership.md) | 25 | -| [`notifications`](./notifications.md) | 68 | +| [`notifications`](./notifications.md) | 117 | | [`payments`](./payments.md) | 16 | | [`scheduler`](./scheduler.md) | 8 | | [`webhooks`](./webhooks.md) | 8 | diff --git a/docs/hub/notifications.md b/docs/hub/notifications.md index ea7bed2..c3a39e2 100644 --- a/docs/hub/notifications.md +++ b/docs/hub/notifications.md @@ -50,6 +50,41 @@ Access with `norbix.hub.notifications`. | `preview_email_notification` | `GET` | `/{version}/notifications/email/preview` | `optional` | | `get_email_campaign_message` | `GET` | `/{version}/notifications/emails/campaigns/{campaignId}/messages/{id}` | `project` | | `get_email_campaign_messages` | `GET` | `/{version}/notifications/emails/campaigns/{campaignId}/messages` | `project` | +| `disable_sms` | `GET` | `/{version}/notifications/sms/disable` | `project` | +| `enable_sms` | `GET` | `/{version}/notifications/sms/enable` | `project` | +| `get_sms_disable_dependencies` | `GET` | `/{version}/notifications/sms/disable-dependencies` | `project` | +| `get_sms_settings` | `GET` | `/{version}/notifications/sms/settings` | `project` | +| `get_sms_integrations` | `GET` | `/{version}/notifications/sms/integrations` | `project` | +| `save_sms_integration` | `POST` | `/{version}/notifications/sms/integrations` | `project` | +| `get_sms_integration` | `GET` | `/{version}/notifications/sms/integrations/{id}` | `project` | +| `enable_sms_integration` | `PUT` | `/{version}/notifications/sms/integrations/{Id}/enable` | `project` | +| `disable_sms_integration` | `PUT` | `/{version}/notifications/sms/integrations/{Id}/disable` | `project` | +| `set_sms_integration_as_default` | `PUT` | `/{version}/notifications/sms/integrations/{Id}/default` | `project` | +| `delete_sms_integration` | `DELETE` | `/{version}/notifications/sms/integrations/{Id}` | `project` | +| `test_sms_integration` | `POST` | `/{version}/notifications/sms/integrations/test` | `project` | +| `confirm_sms_integration_human_delivery` | `POST` | `/{version}/notifications/sms/integrations/confirm-human-delivery` | `project` | +| `get_sms_templates` | `GET` | `/{version}/notifications/sms/templates` | `project` | +| `create_sms_template` | `POST` | `/{version}/notifications/sms/templates` | `project` | +| `update_sms_template` | `PUT` | `/{version}/notifications/sms/templates` | `project` | +| `get_sms_template` | `GET` | `/{version}/notifications/sms/templates/{id}` | `project` | +| `delete_sms_template` | `DELETE` | `/{version}/notifications/sms/templates/{Id}` | `project` | +| `archive_sms_template` | `PUT` | `/{version}/notifications/sms/templates/{Id}/archive` | `project` | +| `un_archive_sms_template` | `PUT` | `/{version}/notifications/sms/templates/{Id}/unarchive` | `project` | +| `clone_sms_template` | `POST` | `/{version}/notifications/sms/templates/{Id}/clone` | `project` | +| `get_sms_message_content_tokens` | `GET` | `/{version}/notifications/sms/templates/{id}/tokens` | `project` | +| `render_sms` | `POST` | `/{version}/notifications/sms/templates/render` | `project` | +| `get_sms_campaigns` | `GET` | `/{version}/notifications/sms/campaigns` | `project` | +| `create_sms_campaign` | `POST` | `/{version}/notifications/sms/campaigns` | `project` | +| `get_sms_campaign` | `GET` | `/{version}/notifications/sms/campaigns/{id}` | `project` | +| `delete_sms_campaign` | `DELETE` | `/{version}/notifications/sms/campaigns/{id}` | `project` | +| `stop_sms_campaign` | `POST` | `/{version}/notifications/sms/campaigns/{Id}/stop` | `project` | +| `get_sms_campaign_batches` | `GET` | `/{version}/notifications/sms/campaigns/{id}/batches` | `project` | +| `get_sms_campaign_batch_notifications` | `GET` | `/{version}/notifications/sms/campaigns/{id}/batches/{batchId}` | `project` | +| `get_sms_campaign_batch_notification` | `GET` | `/{version}/notifications/sms/campaigns/{id}/batches/{batchId}/{notificationId}` | `project` | +| `get_sms_campaign_statistics` | `GET` | `/{version}/notifications/sms/campaigns/{id}/stats` | `project` | +| `get_sms_campaign_messages` | `GET` | `/{version}/notifications/sms/campaigns/{campaignId}/messages` | `project` | +| `get_sms_campaign_message` | `GET` | `/{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}` | `project` | +| `preview_sms_notification` | `GET` | `/{version}/notifications/sms/preview` | `optional` | | `disable_push` | `GET` | `/{version}/notifications/push/disable` | `project` | | `enable_push` | `GET` | `/{version}/notifications/push/enable` | `project` | | `archive_push_template` | `PUT` | `/{version}/notifications/push/templates/{Id}/archive` | `project` | @@ -87,6 +122,7 @@ Access with `norbix.hub.notifications`. | `get_push_campaign_message` | `GET` | `/{version}/notifications/push/campaigns/{campaignId}/messages/{id}` | `project` | | `preview_push_notification` | `GET` | `/{version}/notifications/push/preview` | `optional` | -Every push method above has an async twin on `client.hub.notifications` when -the client is an `AsyncNorbix`. See [push.md](./push.md) for how to pick a -campaign audience and a push provider. +Every method above has an async twin on `client.hub.notifications` when the +client is an `AsyncNorbix`. See [push.md](./push.md) for how to pick a push +campaign audience and a push provider, and [sms.md](./sms.md) for the same two +choices on an SMS campaign. diff --git a/docs/hub/sms.md b/docs/hub/sms.md new file mode 100644 index 0000000..5927d09 --- /dev/null +++ b/docs/hub/sms.md @@ -0,0 +1,108 @@ +# SMS — choosing an audience and a provider + +[← Back to the notifications reference](./notifications.md) + +`notifications.md` lists every SMS method with its verb, path and scope. Two of +them take a body whose shape the server picks from a value inside that body, +which the signature cannot show. This page covers those two, plus the three +calls whose required values are not obvious from the route. + +Everything here is asserted in `tests/hub/test_sms.py`. + +## Choosing who a campaign goes to + +`create_sms_campaign` reads the audience from `deliveryType` and then the one +settings object that matches it. The request is flat (no `campaign` wrapper), +and the settings object repeats the audience name in `recipientsSourceType`: + +| audience | `deliveryType` | settings key | own fields | +|---|---|---|---| +| everyone in the project subscribed to SMS | `AllUsers` | `allUsers` | `rolesNames`, `userTags` (both optional filters) | +| a named list of project members | `SpecifiedUsers` | `specifiedUsers` | `recipients` (member ids) | +| rows of a database collection | `Collection` | `collection` | `schemaName`, `fields` (the record fields that hold the recipient), `fieldType` (`User` or `Email`), optional `roleNames`, `languages` | +| raw phone numbers | `PhoneNumbers` | `phoneNumbers` | `phoneNumbers` (international format, `+370…`) | + +Every settings object also takes `campaignTime` (Unix seconds, UTC — set a +future time so the campaign can be reviewed in the dashboard first), and the +optional `mappedTokens` and `respectTimeZoneSettings`. The request itself takes +`templateId` (required), and the optional `language` and `databaseIntegrationId`. +Note the spelling: `rolesNames` on `allUsers`, but `roleNames` on `collection` — +the gateway names them differently. + +```python +client.hub.notifications.create_sms_campaign( + templateId="tpl_123", + deliveryType="PhoneNumbers", + phoneNumbers={ + "recipientsSourceType": "PhoneNumbers", + "phoneNumbers": ["+37060000000"], + "campaignTime": 1_900_000_000, + }, +) +``` + +Send `deliveryType` as the name, not a number — the server reads it as a +string. The generated enum also lists `AccountUsers`; the create request has no +settings object for it, so the gateway refuses it. + +A scheduled campaign can be cancelled with `stop_sms_campaign(id=...)`; it posts +to `…/campaigns/{id}/stop` with no body. + +## Choosing an SMS provider + +`save_sms_integration` works the same way, with `integration["provider"]`: + +| provider | `provider` value | own fields | +|---|---|---| +| Fake (sandbox, never sends) | `Fake` | none | +| Twilio | `Twilio` | `accountSid`, `fromPhoneNumber`, `authToken` | +| Vonage | `Vonage` | `apiKey`, `fromSender`, `apiSecret` | +| Plivo | `Plivo` | `authId`, `fromPhoneNumber`, `authToken` | +| Telnyx | `Telnyx` | `messagingProfileId`, `fromPhoneNumber`, `apiKey` | +| Bird | `Bird` | `originator`, `region`, `apiKey` | +| Telesign | `Telesign` | `customerId`, `fromSender`, `apiKey` | +| Sinch | `Sinch` | `servicePlanId`, `fromPhoneNumber`, `apiKey`, `apiSecret` | + +`integrationName` and `isEnabled` are optional; send `integrationId` to update +an existing one. The Fake provider takes no field at all — this is the whole +request, and the server builds the rest (one Fake per project): + +```python +client.hub.notifications.save_sms_integration(integration={"provider": "Fake"}) +``` + +## Rendering a template without sending + +`render_sms` runs the Razor template and returns the bound text, or the list of +tokens it could not resolve. Tokens are Razor only (`@Model.FirstName`): + +```python +rendered = client.hub.notifications.render_sms( + code="Hi @Model.FirstName", + tokens=[{"key": "FirstName", "value": "Ada"}], + isForPreview=True, +) +``` + +## Reading one message of a campaign + +`get_sms_campaign_message` takes the campaign id and the notification id in the +route; the batch id is required as well but is not part of the route, so it is +sent as a query value: + +```python +message = client.hub.notifications.get_sms_campaign_message( + campaign_id="camp_1", + id="notif_1", + campaignBatchId="batch_1", +) +``` + +Get the ids from `get_sms_campaigns`, `get_sms_campaign_batches` and +`get_sms_campaign_messages`. + +## Preview with a signed link + +`preview_sms_notification(hash=...)` opens with the signed link alone — no API +key and no sign-in are needed (scope `optional`). See the README section +"Preview a notification with its signed link". diff --git a/src/norbix_python/hub/notifications.py b/src/norbix_python/hub/notifications.py index ad725cc..c621ee3 100644 --- a/src/norbix_python/hub/notifications.py +++ b/src/norbix_python/hub/notifications.py @@ -1308,6 +1308,19 @@ def create_sms_campaign(self, *, timeout: float | None = None, bearer_token: str bearer_token=bearer_token, ) + def stop_sms_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/sms/campaigns/{Id}/stop""" + return self._transport.send( + target="hub", + path="/{version}/notifications/sms/campaigns/{Id}/stop", + method="POST", + path_params={"Id": id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def get_sms_campaign_messages(self, campaign_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/sms/campaigns/{campaignId}/messages""" return self._transport.send( @@ -1322,12 +1335,12 @@ def get_sms_campaign_messages(self, campaign_id: str, *, timeout: float | None = ) def get_sms_campaign_message(self, campaign_id: str, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """GET /{version}/notifications/sms/campaigns/{campaignId}/messages/{id}""" + """GET /{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}""" return self._transport.send( target="hub", - path="/{version}/notifications/sms/campaigns/{campaignId}/messages/{id}", + path="/{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}", method="GET", - path_params={"campaignId": campaign_id, "id": id}, + path_params={"campaignId": campaign_id, "notificationId": id}, request=request, scope="project", timeout=timeout, @@ -1425,6 +1438,19 @@ def disable_sms(self, *, timeout: float | None = None, bearer_token: str | None bearer_token=bearer_token, ) + def get_sms_disable_dependencies(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/sms/disable-dependencies""" + return self._transport.send( + target="hub", + path="/{version}/notifications/sms/disable-dependencies", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def enable_sms(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/sms/enable""" return self._transport.send( @@ -1624,11 +1650,11 @@ def update_sms_template(self, *, timeout: float | None = None, bearer_token: str bearer_token=bearer_token, ) - def sms_razor_syntax_check(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """POST /{version}/notifications/sms/templates/razor-syntax-check""" + def render_sms(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/sms/templates/render""" return self._transport.send( target="hub", - path="/{version}/notifications/sms/templates/razor-syntax-check", + path="/{version}/notifications/sms/templates/render", method="POST", path_params={}, request=request, @@ -3019,6 +3045,19 @@ async def create_sms_campaign(self, *, timeout: float | None = None, bearer_toke bearer_token=bearer_token, ) + async def stop_sms_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/sms/campaigns/{Id}/stop""" + return await self._transport.send( + target="hub", + path="/{version}/notifications/sms/campaigns/{Id}/stop", + method="POST", + path_params={"Id": id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def get_sms_campaign_messages(self, campaign_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/sms/campaigns/{campaignId}/messages""" return await self._transport.send( @@ -3033,12 +3072,12 @@ async def get_sms_campaign_messages(self, campaign_id: str, *, timeout: float | ) async def get_sms_campaign_message(self, campaign_id: str, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """GET /{version}/notifications/sms/campaigns/{campaignId}/messages/{id}""" + """GET /{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}""" return await self._transport.send( target="hub", - path="/{version}/notifications/sms/campaigns/{campaignId}/messages/{id}", + path="/{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}", method="GET", - path_params={"campaignId": campaign_id, "id": id}, + path_params={"campaignId": campaign_id, "notificationId": id}, request=request, scope="project", timeout=timeout, @@ -3136,6 +3175,19 @@ async def disable_sms(self, *, timeout: float | None = None, bearer_token: str | bearer_token=bearer_token, ) + async def get_sms_disable_dependencies(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/sms/disable-dependencies""" + return await self._transport.send( + target="hub", + path="/{version}/notifications/sms/disable-dependencies", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def enable_sms(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/sms/enable""" return await self._transport.send( @@ -3335,11 +3387,11 @@ async def update_sms_template(self, *, timeout: float | None = None, bearer_toke bearer_token=bearer_token, ) - async def sms_razor_syntax_check(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """POST /{version}/notifications/sms/templates/razor-syntax-check""" + async def render_sms(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/sms/templates/render""" return await self._transport.send( target="hub", - path="/{version}/notifications/sms/templates/razor-syntax-check", + path="/{version}/notifications/sms/templates/render", method="POST", path_params={}, request=request, diff --git a/tests/hub/test_notifications.py b/tests/hub/test_notifications.py index 6b2e253..d6046a0 100644 --- a/tests/hub/test_notifications.py +++ b/tests/hub/test_notifications.py @@ -575,6 +575,7 @@ def test_hub_notifications_new_endpoints_surface() -> None: assert callable(module.get_push_settings) assert callable(module.get_sms_campaigns) assert callable(module.create_sms_campaign) + assert callable(module.stop_sms_campaign) assert callable(module.get_sms_campaign_messages) assert callable(module.get_sms_campaign_message) assert callable(module.delete_sms_campaign) @@ -584,6 +585,7 @@ def test_hub_notifications_new_endpoints_surface() -> None: assert callable(module.get_sms_campaign_batch_notification) assert callable(module.get_sms_campaign_statistics) assert callable(module.disable_sms) + assert callable(module.get_sms_disable_dependencies) assert callable(module.enable_sms) assert callable(module.get_sms_integrations) assert callable(module.save_sms_integration) @@ -599,7 +601,7 @@ def test_hub_notifications_new_endpoints_surface() -> None: assert callable(module.get_sms_templates) assert callable(module.create_sms_template) assert callable(module.update_sms_template) - assert callable(module.sms_razor_syntax_check) + assert callable(module.render_sms) assert callable(module.delete_sms_template) assert callable(module.archive_sms_template) assert callable(module.clone_sms_template) @@ -816,6 +818,14 @@ def test_hub_notifications_create_sms_campaign_request_shape() -> None: assert transport.last_request['url'].startswith('https://') +def test_hub_notifications_stop_sms_campaign_request_shape() -> None: + client, transport = make_client(account_id=None) + client.hub.notifications.stop_sms_campaign(id="stub-id") + assert transport.last_request['method'] == 'POST' + assert transport.last_request is not None + assert transport.last_request['url'].startswith('https://') + + def test_hub_notifications_get_sms_campaign_messages_request_shape() -> None: client, transport = make_client(account_id=None) client.hub.notifications.get_sms_campaign_messages(campaign_id="stub-campaign_id") @@ -888,6 +898,14 @@ def test_hub_notifications_disable_sms_request_shape() -> None: assert transport.last_request['url'].startswith('https://') +def test_hub_notifications_get_sms_disable_dependencies_request_shape() -> None: + client, transport = make_client(account_id=None) + client.hub.notifications.get_sms_disable_dependencies() + assert transport.last_request['method'] == 'GET' + assert transport.last_request is not None + assert transport.last_request['url'].startswith('https://') + + def test_hub_notifications_enable_sms_request_shape() -> None: client, transport = make_client(account_id=None) client.hub.notifications.enable_sms() @@ -1008,9 +1026,9 @@ def test_hub_notifications_update_sms_template_request_shape() -> None: assert transport.last_request['url'].startswith('https://') -def test_hub_notifications_sms_razor_syntax_check_request_shape() -> None: +def test_hub_notifications_render_sms_request_shape() -> None: client, transport = make_client(account_id=None) - client.hub.notifications.sms_razor_syntax_check() + client.hub.notifications.render_sms() assert transport.last_request['method'] == 'POST' assert transport.last_request is not None assert transport.last_request['url'].startswith('https://') diff --git a/tests/hub/test_sms.py b/tests/hub/test_sms.py new file mode 100644 index 0000000..ad0eb67 --- /dev/null +++ b/tests/hub/test_sms.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Callable +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from norbix_python import AsyncNorbix + +from ..helpers import make_client + +"""SMS coverage for the notifications module. + +The generated test file asserts each SMS method's verb and that the URL is +https. This file adds what it cannot see: the fully-resolved path of every one +of the 35 SMS routes, and the bodies whose shape the server picks from a value +inside the body (the campaign audience and the integration provider). + +Only the Fake provider is used for a send path — it is the sandbox that accepts +a send and contacts no SMS service. Every call here goes to a capture +transport, so nothing leaves the process. +""" + +CAMPAIGN_ID = "camp_1" +BATCH_ID = "batch_1" +NOTIFICATION_ID = "notif_1" +TEMPLATE_ID = "tpl_1" +INTEGRATION_ID = "int_1" +CAMPAIGN_TIME = 1_900_000_000 # unix seconds, UTC — a future time + +BASE = "/v2/notifications/sms" + +# name, verb, expected path, call +SmsCase = tuple[str, str, str, Callable[[Any], Any]] + +SMS_CASES: list[SmsCase] = [ + # module + ("enable_sms", "GET", f"{BASE}/enable", lambda m: m.enable_sms()), + ("disable_sms", "GET", f"{BASE}/disable", lambda m: m.disable_sms()), + ( + "get_sms_disable_dependencies", + "GET", + f"{BASE}/disable-dependencies", + lambda m: m.get_sms_disable_dependencies(), + ), + ("get_sms_settings", "GET", f"{BASE}/settings", lambda m: m.get_sms_settings()), + # integrations + ("get_sms_integrations", "GET", f"{BASE}/integrations", lambda m: m.get_sms_integrations()), + ("save_sms_integration", "POST", f"{BASE}/integrations", lambda m: m.save_sms_integration()), + ( + "get_sms_integration", + "GET", + f"{BASE}/integrations/{INTEGRATION_ID}", + lambda m: m.get_sms_integration(id=INTEGRATION_ID), + ), + ( + "enable_sms_integration", + "PUT", + f"{BASE}/integrations/{INTEGRATION_ID}/enable", + lambda m: m.enable_sms_integration(id=INTEGRATION_ID), + ), + ( + "disable_sms_integration", + "PUT", + f"{BASE}/integrations/{INTEGRATION_ID}/disable", + lambda m: m.disable_sms_integration(id=INTEGRATION_ID), + ), + ( + "set_sms_integration_as_default", + "PUT", + f"{BASE}/integrations/{INTEGRATION_ID}/default", + lambda m: m.set_sms_integration_as_default(id=INTEGRATION_ID), + ), + ( + "delete_sms_integration", + "DELETE", + f"{BASE}/integrations/{INTEGRATION_ID}", + lambda m: m.delete_sms_integration(id=INTEGRATION_ID), + ), + ( + "test_sms_integration", + "POST", + f"{BASE}/integrations/test", + lambda m: m.test_sms_integration(), + ), + ( + "confirm_sms_integration_human_delivery", + "POST", + f"{BASE}/integrations/confirm-human-delivery", + lambda m: m.confirm_sms_integration_human_delivery(), + ), + # templates + ("get_sms_templates", "GET", f"{BASE}/templates", lambda m: m.get_sms_templates()), + ("create_sms_template", "POST", f"{BASE}/templates", lambda m: m.create_sms_template()), + ("update_sms_template", "PUT", f"{BASE}/templates", lambda m: m.update_sms_template()), + ( + "get_sms_template", + "GET", + f"{BASE}/templates/{TEMPLATE_ID}", + lambda m: m.get_sms_template(id=TEMPLATE_ID), + ), + ( + "delete_sms_template", + "DELETE", + f"{BASE}/templates/{TEMPLATE_ID}", + lambda m: m.delete_sms_template(id=TEMPLATE_ID), + ), + ( + "archive_sms_template", + "PUT", + f"{BASE}/templates/{TEMPLATE_ID}/archive", + lambda m: m.archive_sms_template(id=TEMPLATE_ID), + ), + ( + "un_archive_sms_template", + "PUT", + f"{BASE}/templates/{TEMPLATE_ID}/unarchive", + lambda m: m.un_archive_sms_template(id=TEMPLATE_ID), + ), + ( + "clone_sms_template", + "POST", + f"{BASE}/templates/{TEMPLATE_ID}/clone", + lambda m: m.clone_sms_template(id=TEMPLATE_ID), + ), + ( + "get_sms_message_content_tokens", + "GET", + f"{BASE}/templates/{TEMPLATE_ID}/tokens", + lambda m: m.get_sms_message_content_tokens(id=TEMPLATE_ID), + ), + ("render_sms", "POST", f"{BASE}/templates/render", lambda m: m.render_sms()), + # campaigns + ("get_sms_campaigns", "GET", f"{BASE}/campaigns", lambda m: m.get_sms_campaigns()), + ("create_sms_campaign", "POST", f"{BASE}/campaigns", lambda m: m.create_sms_campaign()), + ( + "get_sms_campaign", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}", + lambda m: m.get_sms_campaign(id=CAMPAIGN_ID), + ), + ( + "delete_sms_campaign", + "DELETE", + f"{BASE}/campaigns/{CAMPAIGN_ID}", + lambda m: m.delete_sms_campaign(id=CAMPAIGN_ID), + ), + ( + "stop_sms_campaign", + "POST", + f"{BASE}/campaigns/{CAMPAIGN_ID}/stop", + lambda m: m.stop_sms_campaign(id=CAMPAIGN_ID), + ), + ( + "get_sms_campaign_batches", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}/batches", + lambda m: m.get_sms_campaign_batches(id=CAMPAIGN_ID), + ), + ( + "get_sms_campaign_batch_notifications", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}/batches/{BATCH_ID}", + lambda m: m.get_sms_campaign_batch_notifications(id=CAMPAIGN_ID, batch_id=BATCH_ID), + ), + ( + "get_sms_campaign_batch_notification", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}/batches/{BATCH_ID}/{NOTIFICATION_ID}", + lambda m: m.get_sms_campaign_batch_notification( + id=CAMPAIGN_ID, batch_id=BATCH_ID, notification_id=NOTIFICATION_ID + ), + ), + ( + "get_sms_campaign_statistics", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}/stats", + lambda m: m.get_sms_campaign_statistics(id=CAMPAIGN_ID), + ), + ( + "get_sms_campaign_messages", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}/messages", + lambda m: m.get_sms_campaign_messages(campaign_id=CAMPAIGN_ID), + ), + ( + "get_sms_campaign_message", + "GET", + f"{BASE}/campaigns/{CAMPAIGN_ID}/messages/{NOTIFICATION_ID}", + lambda m: m.get_sms_campaign_message(campaign_id=CAMPAIGN_ID, id=NOTIFICATION_ID), + ), + ( + "preview_sms_notification", + "GET", + f"{BASE}/preview", + lambda m: m.preview_sms_notification(), + ), +] + + +@pytest.mark.parametrize( + ("name", "verb", "path", "call"), + SMS_CASES, + ids=[case[0] for case in SMS_CASES], +) +def test_sms_endpoint_hits_the_expected_route( + name: str, verb: str, path: str, call: Callable[[Any], Any] +) -> None: + # The client carries an account id; project-scoped routes ignore it. + client, transport = make_client(account_id="acct_1") + call(client.hub.notifications) + + assert transport.last_request is not None + assert transport.last_request["method"] == verb + assert urlparse(transport.last_request["url"]).path == path, ( + f"{name}: got {transport.last_request['url']}, expected the path {path}" + ) + + +def test_sms_surface_size() -> None: + """35 live SMS routes in the gateway. A new one changes this count, so it cannot arrive untested.""" + assert len(SMS_CASES) == 35 + assert len({case[0] for case in SMS_CASES}) == 35 + + +def test_sms_call_sends_auth_and_project_headers() -> None: + client, transport = make_client() + client.hub.notifications.get_sms_templates() + + assert transport.last_request is not None + headers = transport.last_request["headers"] + assert headers["authorization"] == "Bearer test-token" + assert headers["x-cm-projectid"] == "test-project" + + +# The four campaign audiences the gateway accepts, each with the settings +# object it reads. Source of truth: gateway Hub.Sms/Campaigns/Create.cs — +# `deliveryType` names the audience and the `Settings` switch picks the one +# matching object (`allUsers`, `specifiedUsers`, `collection`, `phoneNumbers`). +# Unlike push, the request is flat (no `campaign` wrapper) and the audience +# object repeats its own name in `recipientsSourceType`. The gateway enum also +# lists `AccountUsers`, but the create request has no settings object for it, +# so it is not sent from here. +CAMPAIGN_AUDIENCES: list[tuple[str, str, dict[str, Any]]] = [ + ( + "AllUsers", + "allUsers", + { + "recipientsSourceType": "AllUsers", + "rolesNames": ["authenticated"], + "userTags": [], + "campaignTime": CAMPAIGN_TIME, + }, + ), + ( + "SpecifiedUsers", + "specifiedUsers", + { + "recipientsSourceType": "SpecifiedUsers", + "recipients": ["user_1"], + "campaignTime": CAMPAIGN_TIME, + }, + ), + ( + "Collection", + "collection", + { + "recipientsSourceType": "Collection", + "schemaName": "subscribers", + "fields": ["owner"], + "fieldType": "User", + "campaignTime": CAMPAIGN_TIME, + }, + ), + ( + "PhoneNumbers", + "phoneNumbers", + { + "recipientsSourceType": "PhoneNumbers", + "phoneNumbers": ["+37060000000"], + "campaignTime": CAMPAIGN_TIME, + }, + ), +] + +# The `Settings` switch arms in gateway Hub.Sms/Campaigns/Create.cs — exactly these four. +GATEWAY_AUDIENCES = {"allusers", "specifiedusers", "collection", "phonenumbers"} + + +@pytest.mark.parametrize( + ("delivery_type", "settings_key", "settings"), + CAMPAIGN_AUDIENCES, + ids=[case[0] for case in CAMPAIGN_AUDIENCES], +) +def test_create_sms_campaign_sends_the_audience_shape( + delivery_type: str, settings_key: str, settings: dict[str, Any] +) -> None: + client, transport = make_client() + client.hub.notifications.create_sms_campaign( + templateId=TEMPLATE_ID, + deliveryType=delivery_type, + **{settings_key: settings}, + ) + + assert transport.last_request is not None + assert transport.last_request["method"] == "POST" + body = json.loads(transport.last_request["body"]) + # The audience name has to reach the wire as the name, not a number, and + # the settings object has to sit under the key the gateway reads. + assert body == { + "templateId": TEMPLATE_ID, + "deliveryType": delivery_type, + settings_key: settings, + } + + +def test_campaign_audiences_match_the_gateway() -> None: + assert {delivery_type.lower() for delivery_type, _, _ in CAMPAIGN_AUDIENCES} == GATEWAY_AUDIENCES + assert len(CAMPAIGN_AUDIENCES) == len(GATEWAY_AUDIENCES) + + +# The eight providers the gateway's save converter accepts, with the fields +# each one validates. Source of truth: gateway Hub.Sms/Integrations/Save_.cs +# (the switch arms of the converter, read from `provider`) and +# Save..cs (the `required` fields of each record). +# +# Every value is a dummy; nothing here leaves the process. +SMS_PROVIDERS: list[tuple[str, dict[str, Any]]] = [ + ("Fake", {}), + ( + "Twilio", + {"accountSid": "AC000", "fromPhoneNumber": "+15550000001", "authToken": "token"}, + ), + ( + "Vonage", + {"apiKey": "key", "fromSender": "Norbix", "apiSecret": "secret"}, + ), + ( + "Plivo", + {"authId": "MA000", "fromPhoneNumber": "+15550000002", "authToken": "token"}, + ), + ( + "Telnyx", + {"messagingProfileId": "mp_1", "fromPhoneNumber": "+15550000003", "apiKey": "key"}, + ), + ( + "Bird", + {"originator": "Norbix", "region": "eu", "apiKey": "key"}, + ), + ( + "Telesign", + {"customerId": "cust_1", "fromSender": "Norbix", "apiKey": "key"}, + ), + ( + "Sinch", + { + "servicePlanId": "plan_1", + "fromPhoneNumber": "+15550000004", + "apiKey": "key", + "apiSecret": "secret", + }, + ), +] + +# SmsProvider in the gateway — exactly these eight. +GATEWAY_PROVIDERS = {"Fake", "Twilio", "Vonage", "Plivo", "Telnyx", "Bird", "Telesign", "Sinch"} + + +@pytest.mark.parametrize( + ("provider", "fields"), + SMS_PROVIDERS, + ids=[case[0] for case in SMS_PROVIDERS], +) +def test_save_sms_integration_sends_the_provider_shape( + provider: str, fields: dict[str, Any] +) -> None: + client, transport = make_client() + integration = { + "provider": provider, + "integrationName": f"sms-sdk-py {provider}", + "isEnabled": True, + **fields, + } + client.hub.notifications.save_sms_integration(integration=integration) + + assert transport.last_request is not None + assert transport.last_request["method"] == "POST" + assert json.loads(transport.last_request["body"])["integration"] == integration + + +def test_sms_providers_match_the_gateway() -> None: + assert {provider for provider, _ in SMS_PROVIDERS} == GATEWAY_PROVIDERS + assert len(SMS_PROVIDERS) == len(GATEWAY_PROVIDERS) + + +def test_fake_provider_needs_only_its_name() -> None: + """`integrationName` is optional on save; the Fake sandbox takes no field at all. + + Source of truth: gateway Hub.Sms/Integrations/Save.Fake.cs — a client posts + `{ "integration": { "provider": "Fake" } }` and the server builds the rest. + """ + client, transport = make_client() + client.hub.notifications.save_sms_integration(integration={"provider": "Fake"}) + + assert transport.last_request is not None + assert json.loads(transport.last_request["body"]) == {"integration": {"provider": "Fake"}} + + +def test_render_sms_sends_code_tokens_and_the_preview_flag() -> None: + """Gateway RenderSms reads `code`, `tokens` (key/value pairs) and `isForPreview`.""" + client, transport = make_client() + client.hub.notifications.render_sms( + code="Hi @Model.FirstName", + tokens=[{"key": "FirstName", "value": "Ada"}], + isForPreview=True, + ) + + assert transport.last_request is not None + assert transport.last_request["method"] == "POST" + assert urlparse(transport.last_request["url"]).path == f"{BASE}/templates/render" + assert json.loads(transport.last_request["body"]) == { + "code": "Hi @Model.FirstName", + "tokens": [{"key": "FirstName", "value": "Ada"}], + "isForPreview": True, + } + + +def test_stop_sms_campaign_fills_the_route_token_and_sends_no_body() -> None: + client, transport = make_client() + client.hub.notifications.stop_sms_campaign(id=CAMPAIGN_ID) + + assert transport.last_request is not None + assert transport.last_request["method"] == "POST" + assert urlparse(transport.last_request["url"]).path == f"{BASE}/campaigns/{CAMPAIGN_ID}/stop" + assert transport.last_request["body"] == "" + + +def test_get_sms_campaign_message_sends_the_batch_id_as_a_query_value() -> None: + """The gateway route carries the campaign and notification ids; the batch id + is required too but is not in the route, so it travels as a query value.""" + client, transport = make_client() + client.hub.notifications.get_sms_campaign_message( + campaign_id=CAMPAIGN_ID, id=NOTIFICATION_ID, campaignBatchId=BATCH_ID + ) + + assert transport.last_request is not None + url = urlparse(transport.last_request["url"]) + assert transport.last_request["method"] == "GET" + assert url.path == f"{BASE}/campaigns/{CAMPAIGN_ID}/messages/{NOTIFICATION_ID}" + assert parse_qs(url.query) == {"campaignBatchId": [BATCH_ID]} + + +def test_get_sms_campaigns_sends_its_filters_as_query_values() -> None: + client, transport = make_client() + client.hub.notifications.get_sms_campaigns(templateId=TEMPLATE_ID, pageSize=10, pageNumber=0) + + assert transport.last_request is not None + url = urlparse(transport.last_request["url"]) + assert url.path == f"{BASE}/campaigns" + assert parse_qs(url.query) == { + "templateId": [TEMPLATE_ID], + "pageSize": ["10"], + "pageNumber": ["0"], + } + + +def test_async_module_exposes_the_same_sms_surface() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json={}) + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + bearer_token="test-token", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + try: + module = client.hub.notifications + for name, _, _, _ in SMS_CASES: + assert inspect.iscoroutinefunction(getattr(module, name)), name + await module.stop_sms_campaign(id=CAMPAIGN_ID) + await module.get_sms_campaign_message( + campaign_id=CAMPAIGN_ID, id=NOTIFICATION_ID, campaignBatchId=BATCH_ID + ) + finally: + await client.aclose() + + asyncio.run(run()) + + assert [(r.method, urlparse(str(r.url)).path) for r in seen] == [ + ("POST", f"{BASE}/campaigns/{CAMPAIGN_ID}/stop"), + ("GET", f"{BASE}/campaigns/{CAMPAIGN_ID}/messages/{NOTIFICATION_ID}"), + ]