diff --git a/docs/hub/email.md b/docs/hub/email.md index 684aa73..0795e00 100644 --- a/docs/hub/email.md +++ b/docs/hub/email.md @@ -4,4 +4,13 @@ Access with `norbix.hub.email`. | Method | Verb | Path | Scope | | --- | --- | --- | --- | -| `one_click_unsubscribe` | `POST` | `/{version}/email/one-click-unsubscribe` | `project` | +| `one_click_unsubscribe` | `POST` | `/{version}/email/one-click-unsubscribe` | `optional` | +| `get_email_preferences_by_link` | `GET` | `/{version}/email/preferences` | `optional` | + +`get_email_preferences_by_link(token=...)` reads the marketing e-mail preferences of the +person a signed unsubscribe link belongs to. The signed token is the key: it works on a +client with no API key or bearer token (scope `optional` — a token is sent only when the +client has one). + +Both methods are public links: `one_click_unsubscribe` is also sent without credentials +when the client has none. diff --git a/docs/hub/notifications.md b/docs/hub/notifications.md index c3a39e2..f7616b3 100644 --- a/docs/hub/notifications.md +++ b/docs/hub/notifications.md @@ -7,6 +7,7 @@ Access with `norbix.hub.notifications`. | `get_user_notification_preferences` | `GET` | `/{version}/notifications/user/preferences` | `project` | | `update_user_notifications_preferences` | `PUT` | `/{version}/notifications/user/preferences` | `project` | | `disable_email` | `GET` | `/{version}/notifications/email/disable` | `project` | +| `get_email_disable_dependencies` | `GET` | `/{version}/notifications/email/disable-dependencies` | `project` | | `enable_email` | `GET` | `/{version}/notifications/email/enable` | `project` | | `attach_file_to_template` | `POST` | `/{version}/notifications/email/templates/attachments` | `project` | | `create_email_template` | `POST` | `/{version}/notifications/email/templates` | `project` | @@ -24,6 +25,7 @@ Access with `norbix.hub.notifications`. | `save_email_signature` | `POST` | `/{version}/notifications/email/signatures` | `project` | | `get_email_settings` | `GET` | `/{version}/notifications/email/settings` | `project` | | `confirm_email_integration_human_delivery` | `POST` | `/{version}/notifications/email/integrations/confirm-human-delivery` | `project` | +| `check_email_integration_domain_health` | `POST` | `/{version}/notifications/email/integrations/domain-health` | `project` | | `delete_email_integration` | `DELETE` | `/{version}/notifications/email/integrations/{Id}` | `project` | | `disable_email_integration` | `PUT` | `/{version}/notifications/email/integrations/{Id}/disable` | `project` | | `enable_email_integration` | `PUT` | `/{version}/notifications/email/integrations/{Id}/enable` | `project` | @@ -41,6 +43,7 @@ Access with `norbix.hub.notifications`. | `save_email_footer` | `POST` | `/{version}/notifications/email/footers` | `project` | | `create_email_campaign` | `POST` | `/{version}/notifications/email/campaigns` | `project` | | `delete_email_campaign` | `DELETE` | `/{version}/notifications/email/campaigns/{Id}` | `project` | +| `stop_email_campaign` | `POST` | `/{version}/notifications/email/campaigns/{Id}/stop` | `project` | | `get_email_campaign` | `GET` | `/{version}/notifications/email/campaigns/{id}` | `project` | | `get_email_campaigns` | `GET` | `/{version}/notifications/email/campaigns` | `project` | | `get_email_campaign_batches` | `GET` | `/{version}/notifications/email/campaigns/{id}/batches` | `project` | @@ -48,7 +51,6 @@ Access with `norbix.hub.notifications`. | `get_email_campaign_batch_notifications` | `GET` | `/{version}/notifications/email/campaigns/{id}/batches/{batchId}` | `project` | | `get_email_campaign_statistics` | `GET` | `/{version}/notifications/email/campaigns/{id}/stats` | `project` | | `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` | @@ -83,7 +85,6 @@ Access with `norbix.hub.notifications`. | `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` | @@ -119,7 +120,6 @@ Access with `norbix.hub.notifications`. | `get_push_campaign_batch_notification` | `GET` | `/{version}/notifications/push/campaigns/{id}/batches/{batchId}/{notificationId}` | `project` | | `get_push_campaign_statistics` | `GET` | `/{version}/notifications/push/campaigns/{id}/stats` | `project` | | `get_push_campaign_messages` | `GET` | `/{version}/notifications/push/campaigns/{campaignId}/messages` | `project` | -| `get_push_campaign_message` | `GET` | `/{version}/notifications/push/campaigns/{campaignId}/messages/{id}` | `project` | | `preview_push_notification` | `GET` | `/{version}/notifications/push/preview` | `optional` | Every method above has an async twin on `client.hub.notifications` when the diff --git a/docs/hub/sms.md b/docs/hub/sms.md index 5927d09..11ac3fe 100644 --- a/docs/hub/sms.md +++ b/docs/hub/sms.md @@ -86,20 +86,21 @@ rendered = client.hub.notifications.render_sms( ## 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: +Use `get_sms_campaign_batch_notification` with the campaign id, the batch id +and the notification id (route `…/campaigns/{id}/batches/{batchId}/{notificationId}`): ```python -message = client.hub.notifications.get_sms_campaign_message( - campaign_id="camp_1", - id="notif_1", - campaignBatchId="batch_1", +message = client.hub.notifications.get_sms_campaign_batch_notification( + id="camp_1", + batch_id="batch_1", + notification_id="notif_1", ) ``` Get the ids from `get_sms_campaigns`, `get_sms_campaign_batches` and -`get_sms_campaign_messages`. +`get_sms_campaign_batch_notifications`. The older `get_sms_campaign_message` +(`…/campaigns/{campaignId}/messages/{notificationId}`) was removed together +with its gateway route. ## Preview with a signed link diff --git a/src/norbix_python/hub/email.py b/src/norbix_python/hub/email.py index 880b8f7..bacabd6 100644 --- a/src/norbix_python/hub/email.py +++ b/src/norbix_python/hub/email.py @@ -10,14 +10,36 @@ def __init__(self, transport: Transport) -> None: self._transport = transport def one_click_unsubscribe(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """POST /{version}/email/one-click-unsubscribe""" + """POST /{version}/email/one-click-unsubscribe + + Public: the signed link in the e-mail is the key, so no credentials are + needed. Auth is still sent when the client has a token. + """ return self._transport.send( target="hub", path="/{version}/email/one-click-unsubscribe", method="POST", path_params={}, request=request, - scope="project", + scope="optional", + timeout=timeout, + bearer_token=bearer_token, + ) + + def get_email_preferences_by_link(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/email/preferences + + Reads the marketing e-mail preferences of the person a signed unsubscribe link + belongs to. Opens with the link alone: pass ``token=...`` and no credentials are + needed. Auth is still sent when the client has a token. + """ + return self._transport.send( + target="hub", + path="/{version}/email/preferences", + method="GET", + path_params={}, + request=request, + scope="optional", timeout=timeout, bearer_token=bearer_token, ) @@ -28,14 +50,36 @@ def __init__(self, transport: AsyncTransport) -> None: self._transport = transport async def one_click_unsubscribe(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """POST /{version}/email/one-click-unsubscribe""" + """POST /{version}/email/one-click-unsubscribe + + Public: the signed link in the e-mail is the key, so no credentials are + needed. Auth is still sent when the client has a token. + """ return await self._transport.send( target="hub", path="/{version}/email/one-click-unsubscribe", method="POST", path_params={}, request=request, - scope="project", + scope="optional", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def get_email_preferences_by_link(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/email/preferences + + Reads the marketing e-mail preferences of the person a signed unsubscribe link + belongs to. Opens with the link alone: pass ``token=...`` and no credentials are + needed. Auth is still sent when the client has a token. + """ + return await self._transport.send( + target="hub", + path="/{version}/email/preferences", + method="GET", + path_params={}, + request=request, + scope="optional", timeout=timeout, bearer_token=bearer_token, ) diff --git a/src/norbix_python/hub/notifications.py b/src/norbix_python/hub/notifications.py index c621ee3..af1425e 100644 --- a/src/norbix_python/hub/notifications.py +++ b/src/norbix_python/hub/notifications.py @@ -48,6 +48,22 @@ def disable_email(self, *, timeout: float | None = None, bearer_token: str | Non bearer_token=bearer_token, ) + def get_email_disable_dependencies(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/email/disable-dependencies + + Lists what depends on the Email module (campaigns, triggers) before you turn it off. + """ + return self._transport.send( + target="hub", + path="/{version}/notifications/email/disable-dependencies", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def enable_email(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/email/enable""" return self._transport.send( @@ -269,6 +285,23 @@ def confirm_email_integration_human_delivery(self, *, timeout: float | None = No bearer_token=bearer_token, ) + def check_email_integration_domain_health(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/email/integrations/domain-health + + Informational only: returns the SPF / DMARC / DKIM records found in DNS for the + integration's sender domain (pass ``integrationId=...``). It never blocks the integration. + """ + return self._transport.send( + target="hub", + path="/{version}/notifications/email/integrations/domain-health", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def delete_email_integration(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """DELETE /{version}/notifications/email/integrations/{Id}""" return self._transport.send( @@ -490,6 +523,22 @@ def delete_email_campaign(self, id: str, *, timeout: float | None = None, bearer bearer_token=bearer_token, ) + def stop_email_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/email/campaigns/{Id}/stop + + The campaign stops at its next batch; batches already sent stay sent. + """ + return self._transport.send( + target="hub", + path="/{version}/notifications/email/campaigns/{Id}/stop", + method="POST", + path_params={"Id": id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def get_email_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/email/campaigns/{id}""" return self._transport.send( @@ -585,19 +634,6 @@ def preview_email_notification(self, *, timeout: float | None = None, bearer_tok bearer_token=bearer_token, ) - def get_email_campaign_message(self, campaign_id: str, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """GET /{version}/notifications/emails/campaigns/{campaignId}/messages/{id}""" - return self._transport.send( - target="hub", - path="/{version}/notifications/emails/campaigns/{campaignId}/messages/{id}", - method="GET", - path_params={"campaignId": campaign_id, "id": id}, - request=request, - scope="project", - timeout=timeout, - bearer_token=bearer_token, - ) - def get_email_campaign_messages(self, campaign_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/emails/campaigns/{campaignId}/messages""" return self._transport.send( @@ -1174,19 +1210,6 @@ def get_push_campaign_messages(self, campaign_id: str, *, timeout: float | None bearer_token=bearer_token, ) - def get_push_campaign_message(self, campaign_id: str, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """GET /{version}/notifications/push/campaigns/{campaignId}/messages/{id}""" - return self._transport.send( - target="hub", - path="/{version}/notifications/push/campaigns/{campaignId}/messages/{id}", - method="GET", - path_params={"campaignId": campaign_id, "id": id}, - request=request, - scope="project", - timeout=timeout, - bearer_token=bearer_token, - ) - def get_push_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/push/campaigns/{id}""" return self._transport.send( @@ -1334,19 +1357,6 @@ def get_sms_campaign_messages(self, campaign_id: str, *, timeout: float | None = bearer_token=bearer_token, ) - 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/{notificationId}""" - return self._transport.send( - target="hub", - path="/{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}", - method="GET", - path_params={"campaignId": campaign_id, "notificationId": id}, - request=request, - scope="project", - timeout=timeout, - bearer_token=bearer_token, - ) - def delete_sms_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """DELETE /{version}/notifications/sms/campaigns/{id}""" return self._transport.send( @@ -1785,6 +1795,22 @@ async def disable_email(self, *, timeout: float | None = None, bearer_token: str bearer_token=bearer_token, ) + async def get_email_disable_dependencies(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/email/disable-dependencies + + Lists what depends on the Email module (campaigns, triggers) before you turn it off. + """ + return await self._transport.send( + target="hub", + path="/{version}/notifications/email/disable-dependencies", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def enable_email(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/email/enable""" return await self._transport.send( @@ -2006,6 +2032,23 @@ async def confirm_email_integration_human_delivery(self, *, timeout: float | Non bearer_token=bearer_token, ) + async def check_email_integration_domain_health(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/email/integrations/domain-health + + Informational only: returns the SPF / DMARC / DKIM records found in DNS for the + integration's sender domain (pass ``integrationId=...``). It never blocks the integration. + """ + return await self._transport.send( + target="hub", + path="/{version}/notifications/email/integrations/domain-health", + method="POST", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def delete_email_integration(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """DELETE /{version}/notifications/email/integrations/{Id}""" return await self._transport.send( @@ -2227,6 +2270,22 @@ async def delete_email_campaign(self, id: str, *, timeout: float | None = None, bearer_token=bearer_token, ) + async def stop_email_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """POST /{version}/notifications/email/campaigns/{Id}/stop + + The campaign stops at its next batch; batches already sent stay sent. + """ + return await self._transport.send( + target="hub", + path="/{version}/notifications/email/campaigns/{Id}/stop", + method="POST", + path_params={"Id": id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def get_email_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/email/campaigns/{id}""" return await self._transport.send( @@ -2322,19 +2381,6 @@ async def preview_email_notification(self, *, timeout: float | None = None, bear bearer_token=bearer_token, ) - async def get_email_campaign_message(self, campaign_id: str, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """GET /{version}/notifications/emails/campaigns/{campaignId}/messages/{id}""" - return await self._transport.send( - target="hub", - path="/{version}/notifications/emails/campaigns/{campaignId}/messages/{id}", - method="GET", - path_params={"campaignId": campaign_id, "id": id}, - request=request, - scope="project", - timeout=timeout, - bearer_token=bearer_token, - ) - async def get_email_campaign_messages(self, campaign_id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/emails/campaigns/{campaignId}/messages""" return await self._transport.send( @@ -2911,19 +2957,6 @@ async def get_push_campaign_messages(self, campaign_id: str, *, timeout: float | bearer_token=bearer_token, ) - async def get_push_campaign_message(self, campaign_id: str, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: - """GET /{version}/notifications/push/campaigns/{campaignId}/messages/{id}""" - return await self._transport.send( - target="hub", - path="/{version}/notifications/push/campaigns/{campaignId}/messages/{id}", - method="GET", - path_params={"campaignId": campaign_id, "id": id}, - request=request, - scope="project", - timeout=timeout, - bearer_token=bearer_token, - ) - async def get_push_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/push/campaigns/{id}""" return await self._transport.send( @@ -3071,19 +3104,6 @@ async def get_sms_campaign_messages(self, campaign_id: str, *, timeout: float | bearer_token=bearer_token, ) - 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/{notificationId}""" - return await self._transport.send( - target="hub", - path="/{version}/notifications/sms/campaigns/{campaignId}/messages/{notificationId}", - method="GET", - path_params={"campaignId": campaign_id, "notificationId": id}, - request=request, - scope="project", - timeout=timeout, - bearer_token=bearer_token, - ) - async def delete_sms_campaign(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """DELETE /{version}/notifications/sms/campaigns/{id}""" return await self._transport.send( diff --git a/tests/hub/test_email_routes.py b/tests/hub/test_email_routes.py new file mode 100644 index 0000000..b86e7d8 --- /dev/null +++ b/tests/hub/test_email_routes.py @@ -0,0 +1,480 @@ +"""Email coverage for the notifications and email modules. + +The generated test files assert each method's verb and that the URL is https. +This file adds what they cannot see: the fully-resolved path of every one of +the 50 Email routes — the project routes under /notifications/email (and the +older /notifications/emails/campaigns/{campaignId}/messages) and the public +link routes under /email/ — with the routes and verbs taken from the gateway's +endpoint manifest, not from this SDK. Every call goes to a capture transport, +so nothing leaves the process and no email provider is contacted. +""" + +from __future__ import annotations + +import asyncio +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, Norbix + +from ..helpers import make_client + +CAMPAIGN_ID = "camp_1" +BATCH_ID = "batch_1" +NOTIFICATION_ID = "notif_1" +ITEM_ID = "id_1" +TOKEN = "signed-link-abc" + +# name, verb, expected path, call +EmailCase = tuple[str, str, str, Callable[[Any], Any]] + +EMAIL_CASES: list[EmailCase] = [ + ( + "one_click_unsubscribe", + "POST", + "/v2/email/one-click-unsubscribe", + lambda c: c.hub.email.one_click_unsubscribe(), + ), + ( + "get_email_preferences_by_link", + "GET", + "/v2/email/preferences", + lambda c: c.hub.email.get_email_preferences_by_link(), + ), + ( + "get_email_campaigns", + "GET", + "/v2/notifications/email/campaigns", + lambda c: c.hub.notifications.get_email_campaigns(), + ), + ( + "create_email_campaign", + "POST", + "/v2/notifications/email/campaigns", + lambda c: c.hub.notifications.create_email_campaign(), + ), + ( + "delete_email_campaign", + "DELETE", + f"/v2/notifications/email/campaigns/{ITEM_ID}", + lambda c: c.hub.notifications.delete_email_campaign(id=ITEM_ID), + ), + ( + "stop_email_campaign", + "POST", + f"/v2/notifications/email/campaigns/{ITEM_ID}/stop", + lambda c: c.hub.notifications.stop_email_campaign(id=ITEM_ID), + ), + ( + "get_email_campaign", + "GET", + f"/v2/notifications/email/campaigns/{ITEM_ID}", + lambda c: c.hub.notifications.get_email_campaign(id=ITEM_ID), + ), + ( + "get_email_campaign_batches", + "GET", + f"/v2/notifications/email/campaigns/{ITEM_ID}/batches", + lambda c: c.hub.notifications.get_email_campaign_batches(id=ITEM_ID), + ), + ( + "get_email_campaign_batch_notifications", + "GET", + f"/v2/notifications/email/campaigns/{ITEM_ID}/batches/{BATCH_ID}", + lambda c: c.hub.notifications.get_email_campaign_batch_notifications(id=ITEM_ID, batch_id=BATCH_ID), + ), + ( + "get_email_campaign_batch_notification", + "GET", + f"/v2/notifications/email/campaigns/{ITEM_ID}/batches/{BATCH_ID}/{NOTIFICATION_ID}", + lambda c: c.hub.notifications.get_email_campaign_batch_notification(id=ITEM_ID, batch_id=BATCH_ID, notification_id=NOTIFICATION_ID), + ), + ( + "get_email_campaign_statistics", + "GET", + f"/v2/notifications/email/campaigns/{ITEM_ID}/stats", + lambda c: c.hub.notifications.get_email_campaign_statistics(id=ITEM_ID), + ), + ( + "disable_email", + "GET", + "/v2/notifications/email/disable", + lambda c: c.hub.notifications.disable_email(), + ), + ( + "get_email_disable_dependencies", + "GET", + "/v2/notifications/email/disable-dependencies", + lambda c: c.hub.notifications.get_email_disable_dependencies(), + ), + ( + "enable_email", + "GET", + "/v2/notifications/email/enable", + lambda c: c.hub.notifications.enable_email(), + ), + ( + "get_email_footers", + "GET", + "/v2/notifications/email/footers", + lambda c: c.hub.notifications.get_email_footers(), + ), + ( + "save_email_footer", + "POST", + "/v2/notifications/email/footers", + lambda c: c.hub.notifications.save_email_footer(), + ), + ( + "delete_email_footer", + "DELETE", + f"/v2/notifications/email/footers/{ITEM_ID}", + lambda c: c.hub.notifications.delete_email_footer(id=ITEM_ID), + ), + ( + "get_email_footer", + "GET", + f"/v2/notifications/email/footers/{ITEM_ID}", + lambda c: c.hub.notifications.get_email_footer(id=ITEM_ID), + ), + ( + "get_email_integrations", + "GET", + "/v2/notifications/email/integrations", + lambda c: c.hub.notifications.get_email_integrations(), + ), + ( + "save_email_integration", + "POST", + "/v2/notifications/email/integrations", + lambda c: c.hub.notifications.save_email_integration(), + ), + ( + "confirm_email_integration_human_delivery", + "POST", + "/v2/notifications/email/integrations/confirm-human-delivery", + lambda c: c.hub.notifications.confirm_email_integration_human_delivery(), + ), + ( + "check_email_integration_domain_health", + "POST", + "/v2/notifications/email/integrations/domain-health", + lambda c: c.hub.notifications.check_email_integration_domain_health(), + ), + ( + "test_email_integration", + "POST", + "/v2/notifications/email/integrations/test", + lambda c: c.hub.notifications.test_email_integration(), + ), + ( + "delete_email_integration", + "DELETE", + f"/v2/notifications/email/integrations/{ITEM_ID}", + lambda c: c.hub.notifications.delete_email_integration(id=ITEM_ID), + ), + ( + "set_emails_integration_as_default", + "PUT", + f"/v2/notifications/email/integrations/{ITEM_ID}/default", + lambda c: c.hub.notifications.set_emails_integration_as_default(id=ITEM_ID), + ), + ( + "disable_email_integration", + "PUT", + f"/v2/notifications/email/integrations/{ITEM_ID}/disable", + lambda c: c.hub.notifications.disable_email_integration(id=ITEM_ID), + ), + ( + "enable_email_integration", + "PUT", + f"/v2/notifications/email/integrations/{ITEM_ID}/enable", + lambda c: c.hub.notifications.enable_email_integration(id=ITEM_ID), + ), + ( + "get_email_integration", + "GET", + f"/v2/notifications/email/integrations/{ITEM_ID}", + lambda c: c.hub.notifications.get_email_integration(id=ITEM_ID), + ), + ( + "preview_email_notification", + "GET", + "/v2/notifications/email/preview", + lambda c: c.hub.notifications.preview_email_notification(), + ), + ( + "get_email_settings", + "GET", + "/v2/notifications/email/settings", + lambda c: c.hub.notifications.get_email_settings(), + ), + ( + "get_email_signatures", + "GET", + "/v2/notifications/email/signatures", + lambda c: c.hub.notifications.get_email_signatures(), + ), + ( + "save_email_signature", + "POST", + "/v2/notifications/email/signatures", + lambda c: c.hub.notifications.save_email_signature(), + ), + ( + "delete_email_signature", + "DELETE", + f"/v2/notifications/email/signatures/{ITEM_ID}", + lambda c: c.hub.notifications.delete_email_signature(id=ITEM_ID), + ), + ( + "get_email_signature", + "GET", + f"/v2/notifications/email/signatures/{ITEM_ID}", + lambda c: c.hub.notifications.get_email_signature(id=ITEM_ID), + ), + ( + "get_system_email_templates", + "GET", + "/v2/notifications/email/system-templates", + lambda c: c.hub.notifications.get_system_email_templates(), + ), + ( + "get_system_email_template", + "GET", + f"/v2/notifications/email/system-templates/{ITEM_ID}", + lambda c: c.hub.notifications.get_system_email_template(id=ITEM_ID), + ), + ( + "get_email_templates", + "GET", + "/v2/notifications/email/templates", + lambda c: c.hub.notifications.get_email_templates(), + ), + ( + "create_email_template", + "POST", + "/v2/notifications/email/templates", + lambda c: c.hub.notifications.create_email_template(), + ), + ( + "update_email_template", + "PUT", + "/v2/notifications/email/templates", + lambda c: c.hub.notifications.update_email_template(), + ), + ( + "attach_file_to_template", + "POST", + "/v2/notifications/email/templates/attachments", + lambda c: c.hub.notifications.attach_file_to_template(), + ), + ( + "get_mjml", + "POST", + "/v2/notifications/email/templates/mjml", + lambda c: c.hub.notifications.get_mjml(), + ), + ( + "delete_email_template", + "DELETE", + f"/v2/notifications/email/templates/{ITEM_ID}", + lambda c: c.hub.notifications.delete_email_template(id=ITEM_ID), + ), + ( + "archive_email_template", + "PUT", + f"/v2/notifications/email/templates/{ITEM_ID}/archive", + lambda c: c.hub.notifications.archive_email_template(id=ITEM_ID), + ), + ( + "clone_email_template", + "POST", + f"/v2/notifications/email/templates/{ITEM_ID}/clone", + lambda c: c.hub.notifications.clone_email_template(id=ITEM_ID), + ), + ( + "un_archive_email_template", + "PUT", + f"/v2/notifications/email/templates/{ITEM_ID}/unarchive", + lambda c: c.hub.notifications.un_archive_email_template(id=ITEM_ID), + ), + ( + "get_email_template", + "GET", + f"/v2/notifications/email/templates/{ITEM_ID}", + lambda c: c.hub.notifications.get_email_template(id=ITEM_ID), + ), + ( + "get_email_template_available_tokens", + "GET", + f"/v2/notifications/email/templates/{ITEM_ID}/tokens", + lambda c: c.hub.notifications.get_email_template_available_tokens(id=ITEM_ID), + ), + ( + "save_email_validation_integration", + "POST", + "/v2/notifications/email/validation/integrations", + lambda c: c.hub.notifications.save_email_validation_integration(), + ), + ( + "test_email_validation_integration", + "POST", + "/v2/notifications/email/validation/integrations/test", + lambda c: c.hub.notifications.test_email_validation_integration(), + ), + ( + "get_email_campaign_messages", + "GET", + f"/v2/notifications/emails/campaigns/{CAMPAIGN_ID}/messages", + lambda c: c.hub.notifications.get_email_campaign_messages(campaign_id=CAMPAIGN_ID), + ), +] + + +@pytest.mark.parametrize( + ("name", "verb", "path", "call"), + EMAIL_CASES, + ids=[case[0] for case in EMAIL_CASES], +) +def test_email_endpoint_hits_the_expected_route( + name: str, verb: str, path: str, call: Callable[[Any], Any] +) -> None: + client, transport = make_client(account_id="acct_1") + call(client) + + 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 path {path}" + ) + + +def test_email_surface_size() -> None: + """50 live Email routes. A new or removed one changes this count, so it cannot pass untested.""" + assert len(EMAIL_CASES) == 50 + + +def test_email_call_sends_auth_and_project_headers() -> None: + client, transport = make_client() + client.hub.notifications.stop_email_campaign(CAMPAIGN_ID) + + 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" + + +@pytest.fixture +def _no_env_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NORBIX_API_KEY", raising=False) + monkeypatch.delenv("NORBIX_BEARER_TOKEN", raising=False) + + +def _capture(seen: list[httpx.Request]) -> Callable[[httpx.Request], httpx.Response]: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json={"item": {}}) + + return handler + + +@pytest.mark.usefixtures("_no_env_credentials") +@pytest.mark.parametrize( + ("api_key", "want_auth"), + [(None, None), ("key_1", "Bearer key_1")], + ids=["no_credentials", "api_key"], +) +def test_email_preferences_by_link_needs_no_sign_in(api_key: str | None, want_auth: str | None) -> None: + seen: list[httpx.Request] = [] + client = Norbix( + project_id="test-project", + api_key=api_key, + http_client=httpx.Client(transport=httpx.MockTransport(_capture(seen))), + ) + + client.hub.email.get_email_preferences_by_link(token=TOKEN) + + assert len(seen) == 1 + url = urlparse(str(seen[0].url)) + assert seen[0].method == "GET" + assert url.path == "/v2/email/preferences" + assert parse_qs(url.query) == {"token": [TOKEN]} + assert seen[0].headers.get("Authorization") == want_auth + + +@pytest.mark.usefixtures("_no_env_credentials") +def test_async_email_preferences_by_link_needs_no_sign_in() -> None: + seen: list[httpx.Request] = [] + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(_capture(seen))), + ) + try: + await client.hub.email.get_email_preferences_by_link(token=TOKEN) + finally: + await client.aclose() + + asyncio.run(run()) + + assert len(seen) == 1 + assert urlparse(str(seen[0].url)).path == "/v2/email/preferences" + assert seen[0].headers.get("Authorization") is None + + +def test_async_modules_expose_the_same_email_surface() -> None: + """Every sync Email method must have an async twin.""" + from norbix_python.hub.email import AsyncEmailModule, EmailModule + from norbix_python.hub.notifications import AsyncNotificationsModule, NotificationsModule + + pairs = [(NotificationsModule, AsyncNotificationsModule), (EmailModule, AsyncEmailModule)] + missing = sorted( + name + for sync_cls, async_cls in pairs + for name in dir(sync_cls) + if not name.startswith("_") and "email" in name and not hasattr(async_cls, name) + ) + assert missing == [] + + +@pytest.mark.usefixtures("_no_env_credentials") +def test_one_click_unsubscribe_needs_no_sign_in() -> None: + """The gateway does not authenticate it; with no credentials it still goes out, no Authorization.""" + seen: list[httpx.Request] = [] + client = Norbix( + project_id="test-project", + http_client=httpx.Client(transport=httpx.MockTransport(_capture(seen))), + ) + + client.hub.email.one_click_unsubscribe(token=TOKEN) + + assert len(seen) == 1 + assert seen[0].method == "POST" + assert urlparse(str(seen[0].url)).path == "/v2/email/one-click-unsubscribe" + assert seen[0].headers.get("Authorization") is None + + +@pytest.mark.usefixtures("_no_env_credentials") +def test_async_one_click_unsubscribe_needs_no_sign_in() -> None: + seen: list[httpx.Request] = [] + + async def run() -> None: + client = AsyncNorbix( + project_id="test-project", + http_client=httpx.AsyncClient(transport=httpx.MockTransport(_capture(seen))), + ) + try: + await client.hub.email.one_click_unsubscribe(token=TOKEN) + finally: + await client.aclose() + + asyncio.run(run()) + + assert len(seen) == 1 + assert seen[0].headers.get("Authorization") is None diff --git a/tests/hub/test_notifications.py b/tests/hub/test_notifications.py index d6046a0..2d8f092 100644 --- a/tests/hub/test_notifications.py +++ b/tests/hub/test_notifications.py @@ -51,7 +51,6 @@ def test_hub_notifications_module_surface() -> None: assert callable(module.get_email_campaign_batch_notifications) assert callable(module.get_email_campaign_statistics) assert callable(module.preview_email_notification) - assert callable(module.get_email_campaign_message) assert callable(module.get_email_campaign_messages) assert callable(module.disable_push) assert callable(module.enable_push) @@ -384,13 +383,6 @@ def test_hub_notifications_preview_email_notification_request_shape() -> None: assert transport.last_request is not None assert transport.last_request['url'].startswith('https://') -def test_hub_notifications_get_email_campaign_message_request_shape() -> None: - client, transport = make_client(account_id=None) - client.hub.notifications.get_email_campaign_message(campaign_id="stub-campaignId", id="stub-id") - assert transport.last_request['method'] == 'GET' - assert transport.last_request is not None - assert transport.last_request['url'].startswith('https://') - def test_hub_notifications_get_email_campaign_messages_request_shape() -> None: client, transport = make_client(account_id=None) client.hub.notifications.get_email_campaign_messages(campaign_id="stub-campaignId") @@ -566,7 +558,6 @@ def test_hub_notifications_new_endpoints_surface() -> None: assert callable(module.create_push_campaign) assert callable(module.delete_push_campaign) assert callable(module.get_push_campaign_messages) - assert callable(module.get_push_campaign_message) assert callable(module.get_push_campaign) assert callable(module.get_push_campaign_batches) assert callable(module.get_push_campaign_batch_notifications) @@ -577,7 +568,6 @@ def test_hub_notifications_new_endpoints_surface() -> None: 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) assert callable(module.get_sms_campaign) assert callable(module.get_sms_campaign_batches) @@ -746,13 +736,6 @@ def test_hub_notifications_get_push_campaign_messages_request_shape() -> None: assert transport.last_request['url'].startswith('https://') -def test_hub_notifications_get_push_campaign_message_request_shape() -> None: - client, transport = make_client(account_id=None) - client.hub.notifications.get_push_campaign_message(campaign_id="stub-campaign_id", id="stub-id") - assert transport.last_request['method'] == 'GET' - assert transport.last_request is not None - assert transport.last_request['url'].startswith('https://') - def test_hub_notifications_get_push_campaign_request_shape() -> None: client, transport = make_client(account_id=None) @@ -834,13 +817,6 @@ def test_hub_notifications_get_sms_campaign_messages_request_shape() -> None: assert transport.last_request['url'].startswith('https://') -def test_hub_notifications_get_sms_campaign_message_request_shape() -> None: - client, transport = make_client(account_id=None) - client.hub.notifications.get_sms_campaign_message(campaign_id="stub-campaign_id", id="stub-id") - assert transport.last_request['method'] == 'GET' - assert transport.last_request is not None - assert transport.last_request['url'].startswith('https://') - def test_hub_notifications_delete_sms_campaign_request_shape() -> None: client, transport = make_client(account_id=None) diff --git a/tests/hub/test_push.py b/tests/hub/test_push.py index f84ba1b..a1ec4af 100644 --- a/tests/hub/test_push.py +++ b/tests/hub/test_push.py @@ -13,7 +13,7 @@ The generated test file asserts each push method's verb and that the URL is https. This file adds the two things it cannot see: the fully-resolved path of -every one of the 38 push routes, and the polymorphic bodies that the server +every one of the 37 push routes, and the polymorphic bodies that the server routes on. Only the Fake provider is used for a send path — it is the sandbox that accepts @@ -183,12 +183,6 @@ f"{BASE}/campaigns/{CAMPAIGN_ID}/messages", lambda m: m.get_push_campaign_messages(campaign_id=CAMPAIGN_ID), ), - ( - "get_push_campaign_message", - "GET", - f"{BASE}/campaigns/{CAMPAIGN_ID}/messages/{NOTIFICATION_ID}", - lambda m: m.get_push_campaign_message(campaign_id=CAMPAIGN_ID, id=NOTIFICATION_ID), - ), ( "preview_push_notification", "GET", @@ -227,8 +221,8 @@ def test_push_endpoint_hits_the_expected_route( def test_push_surface_size() -> None: - """38 live push routes. A new one changes this count, so it cannot arrive untested.""" - assert len(PUSH_CASES) == 38 + """37 live push routes. A new one changes this count, so it cannot arrive untested.""" + assert len(PUSH_CASES) == 37 def test_push_call_sends_auth_and_project_headers() -> None: diff --git a/tests/hub/test_sms.py b/tests/hub/test_sms.py index ad0eb67..f1f9c60 100644 --- a/tests/hub/test_sms.py +++ b/tests/hub/test_sms.py @@ -18,7 +18,7 @@ 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 +of the 34 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 @@ -188,12 +188,6 @@ 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", @@ -223,9 +217,9 @@ def test_sms_endpoint_hits_the_expected_route( 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 + """34 live SMS routes in the gateway. A new one changes this count, so it cannot arrive untested.""" + assert len(SMS_CASES) == 34 + assert len({case[0] for case in SMS_CASES}) == 34 def test_sms_call_sends_auth_and_project_headers() -> None: @@ -440,21 +434,6 @@ def test_stop_sms_campaign_fills_the_route_token_and_sends_no_body() -> None: 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) @@ -487,9 +466,6 @@ async def run() -> None: 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() @@ -497,5 +473,4 @@ async def run() -> None: 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}"), ]