From de7038aa89f6d30a2891b7eae4f2215d788ee41f Mon Sep 17 00:00:00 2001 From: CodeMash Date: Tue, 22 Sep 2026 11:47:44 +0300 Subject: [PATCH 1/2] test(push): pin the 5 campaign targets and 8 providers to the gateway field names Squash of audit/push-sdk-go-js-py (campaign item sdk-go-js-py). The body-variant tests only proved that a body reached the wire unchanged, so a wrong value still passed. They now send and assert every field the gateway actually reads for each variant, and a guard test fails if a target or provider is not one the gateway accepts. A device-register body test was added, and docs/hub/push.md lists the own fields per provider and per target. This caught a real defect: the Chrome-extension provider was tested and documented as CodeMashChromePlugin. That string parses as a PushProvider, but the gateway save converter (Hub.Push/Integrations/Save_.cs:94-121) has no arm for it and answers "Unsupported provider". The value is ChromePush. Anyone following the old docs could not save a Chrome-extension integration. Co-Authored-By: Claude Opus 5 --- docs/hub/push.md | 47 ++++++++---- tests/hub/test_push.py | 157 +++++++++++++++++++++++++++++++---------- 2 files changed, 156 insertions(+), 48 deletions(-) diff --git a/docs/hub/push.md b/docs/hub/push.md index 9fca66b..d220a5d 100644 --- a/docs/hub/push.md +++ b/docs/hub/push.md @@ -18,8 +18,13 @@ field plus the audience's own fields: | everyone in the project | `allUsers` | `rolesNames`, `userTags` (both optional filters) | | a named list of project users | `specifiedUsers` | `userRecipients` | | a named list of account users | `accountUsers` | `userRecipients` | -| rows of a database collection | `collection` | `schemaName`, `fields`, `fieldType` | -| raw device tokens | `devices` | `devices` | +| rows of a database collection | `collection` | `schemaName`, `fields` (the record fields that hold the recipient), `fieldType` (`User` or `Email`), optional `roleNames`, `languages` | +| raw device tokens | `devices` | `devices`: a list of `{ token, deliveryFamily }`, `deliveryFamily` one of `Ios`, `Android`, `Chrome`, `Safari`, `Expo` | + +Every target also takes `templateId` (required) and the optional `integrationId`, +`language`, `notes`, `campaignTime` (Unix seconds) and `mappedTokens`. Note the +spelling: `rolesNames` on `allUsers`, but `roleNames` on `collection` — the +gateway names them differently. ```python client.hub.notifications.create_push_campaign( @@ -37,16 +42,21 @@ Send `source` as the name, not a number — the server reads it as a string. `save_push_integration` works the same way, with `integration["provider"]`: -| provider | `provider` value | -|---|---| -| Fake (sandbox, never sends) | `Fake` | -| Android / Firebase | `AndroidFirebase` | -| Apple APNs | `AppleApns` | -| Chrome extension | `CodeMashChromePlugin` | -| Chrome web | `ChromeWeb` | -| Edge web | `EdgeWeb` | -| Firefox web | `FirefoxWeb` | -| Safari | `SafariPush` | +| provider | `provider` value | own fields | +|---|---|---| +| Fake (sandbox, never sends) | `Fake` | none | +| Android / Firebase | `AndroidFirebase` | `projectId`, `clientEmail`, `serviceAccountJson` | +| Apple APNs | `AppleApns` | `teamId`, `appBundleId`, `keyId`, `privateKey`, `isProduction` | +| Chrome extension | `ChromePush` | `extensionId` (a GUID), `vapidPublicKey`, `vapidPrivateKey`, optional `subject` | +| Chrome web | `ChromeWeb` | `vapidPublicKey`, `vapidPrivateKey`, optional `subject` | +| Edge web | `EdgeWeb` | `vapidPublicKey`, `vapidPrivateKey`, optional `subject` | +| Firefox web | `FirefoxWeb` | `vapidPublicKey`, `vapidPrivateKey`, optional `subject` | +| Safari | `SafariPush` | `websitePushId`, `certificateP12Base64`, `certificatePassword` | + +Every provider also takes `integrationName` and `isEnabled`; send `integrationId` +to update an existing one. The Chrome extension value is `ChromePush`. The +generated types also list `CodeMashChromePlugin` and other `CodeMash*` values — +the server rejects those here with "Unsupported provider". ```python client.hub.notifications.save_push_integration( @@ -57,6 +67,19 @@ client.hub.notifications.save_push_integration( Use `Fake` in tests and local development. It accepts a send and contacts no push service, so nothing reaches a real device. +## Registering a device + +A device is registered for one user. Send the device under `pushDeviceDto` +(`deviceOs` and `token` are required; `deviceId`, `brand`, `manufacturer`, +`modelName`, `deviceName`, `deviceType` are optional) plus `userId`: + +```python +client.hub.notifications.register_device( + userId="user_123", + pushDeviceDto={"deviceOs": "iOS", "token": ""}, +) +``` + ## Known gaps | what | why | diff --git a/tests/hub/test_push.py b/tests/hub/test_push.py index 55ae373..24a11c5 100644 --- a/tests/hub/test_push.py +++ b/tests/hub/test_push.py @@ -239,62 +239,147 @@ def test_push_call_sends_auth_and_project_headers() -> None: assert headers["x-cm-projectid"] == "test-project" -CAMPAIGN_AUDIENCES = [ - ("allUsers", {"templateId": TEMPLATE_ID, "userTags": ["beta"]}, "userTags"), - ("specifiedUsers", {"templateId": TEMPLATE_ID, "userRecipients": ["user_1"]}, "userRecipients"), - ("accountUsers", {"templateId": TEMPLATE_ID, "userRecipients": ["acct_1"]}, "userRecipients"), - ("collection", {"templateId": TEMPLATE_ID, "schemaName": "subscribers"}, "schemaName"), - ( - "devices", - {"templateId": TEMPLATE_ID, "devices": [{"token": "device_1", "deliveryFamily": "ios"}]}, - "devices", - ), +# The five campaign targets, with the fields the gateway reads for each one. +# Source of truth: gateway Hub.Push/Campaigns/Create.PushTo*.cs (field names) +# and Create_.cs (PushCampaignRequestDtoJsonConverter picks the record from +# `source`, case-insensitive). Note `rolesNames` on allUsers but `roleNames` on +# collection — the gateway spells them differently. +CAMPAIGN_TARGETS: list[tuple[str, dict[str, Any]]] = [ + ("allUsers", {"rolesNames": ["admin"], "userTags": ["beta"]}), + ("specifiedUsers", {"userRecipients": ["user_1"]}), + ("accountUsers", {"userRecipients": ["acct_user_1"]}), + ("collection", {"schemaName": "subscribers", "fields": ["owner"], "fieldType": "User"}), + ("devices", {"devices": [{"token": "device_1", "deliveryFamily": "Ios"}]}), ] +# PushCampaignRecipientsSourceTypes in the gateway — exactly these five. +GATEWAY_TARGETS = {"allusers", "specifiedusers", "accountusers", "collection", "devices"} + @pytest.mark.parametrize( - ("source", "fields", "own_field"), - CAMPAIGN_AUDIENCES, - ids=[case[0] for case in CAMPAIGN_AUDIENCES], + ("source", "fields"), + CAMPAIGN_TARGETS, + ids=[case[0] for case in CAMPAIGN_TARGETS], ) -def test_create_push_campaign_carries_the_audience_shape( - source: str, fields: dict[str, Any], own_field: str -) -> None: +def test_create_push_campaign_sends_the_target_shape(source: str, fields: dict[str, Any]) -> None: client, transport = make_client() - client.hub.notifications.create_push_campaign(campaign={"source": source, **fields}) + client.hub.notifications.create_push_campaign( + campaign={"source": source, "templateId": TEMPLATE_ID, **fields} + ) assert transport.last_request is not None campaign = json.loads(transport.last_request["body"])["campaign"] # The discriminator has to reach the wire as the name — the server reads it - # with a string parse and rejects a number. - assert campaign["source"] == source - assert campaign["templateId"] == TEMPLATE_ID - assert own_field in campaign + # with GetString() and rejects a number. + assert campaign == {"source": source, "templateId": TEMPLATE_ID, **fields} -PUSH_PROVIDERS = [ - "Fake", - "AndroidFirebase", +def test_campaign_targets_match_the_gateway() -> None: + assert {source.lower() for source, _ in CAMPAIGN_TARGETS} == GATEWAY_TARGETS + assert len(CAMPAIGN_TARGETS) == len(GATEWAY_TARGETS) + + +# The eight providers the gateway's save converter accepts, with the fields each +# one validates. Source of truth: gateway Hub.Push/Integrations/Save_.cs (the +# switch arms of PushIntegrationRequestDtoJsonConverter) and Save..cs +# (the fields). The Chrome extension is `ChromePush`: `CodeMashChromePlugin` +# also exists in the generated enum, but no switch arm takes it, so the gateway +# answers "Unsupported provider". +# +# Every value is a dummy; nothing here leaves the process. +_VAPID = {"vapidPublicKey": "vapid-public", "vapidPrivateKey": "vapid-private"} +PUSH_PROVIDERS: list[tuple[str, dict[str, Any]]] = [ + ("Fake", {}), + ( + "AndroidFirebase", + { + "projectId": "firebase-project", + "clientEmail": "push@firebase-project.iam.example.com", + "serviceAccountJson": '{"type":"service_account"}', + }, + ), + ( + "AppleApns", + { + "teamId": "TEAM123456", + "appBundleId": "com.example.app", + "keyId": "KEY1234567", + "privateKey": "-----BEGIN PRIVATE KEY-----dummy", + "isProduction": False, + }, + ), + ( + "ChromePush", + { + "extensionId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + **_VAPID, + "subject": "mailto:push@example.com", + }, + ), + ("ChromeWeb", {**_VAPID, "subject": "mailto:push@example.com"}), + ("EdgeWeb", {**_VAPID}), + ("FirefoxWeb", {**_VAPID}), + ( + "SafariPush", + { + "websitePushId": "web.com.example", + "certificateP12Base64": "ZHVtbXk=", + "certificatePassword": "dummy", + }, + ), +] + +# The switch arms of PushIntegrationRequestDtoJsonConverter.Read. +GATEWAY_PROVIDERS = { "AppleApns", - "CodeMashChromePlugin", + "AndroidFirebase", + "SafariPush", "ChromeWeb", - "EdgeWeb", "FirefoxWeb", - "SafariPush", -] + "EdgeWeb", + "ChromePush", + "Fake", +} -@pytest.mark.parametrize("provider", PUSH_PROVIDERS) -def test_save_push_integration_carries_the_provider_shape(provider: str) -> None: +@pytest.mark.parametrize( + ("provider", "fields"), + PUSH_PROVIDERS, + ids=[case[0] for case in PUSH_PROVIDERS], +) +def test_save_push_integration_sends_the_provider_shape( + provider: str, fields: dict[str, Any] +) -> None: client, transport = make_client() - client.hub.notifications.save_push_integration( - integration={"provider": provider, "integrationName": f"test-{provider}", "isEnabled": True} - ) + integration = { + "provider": provider, + "integrationName": f"test-{provider}", + "isEnabled": True, + **fields, + } + client.hub.notifications.save_push_integration(integration=integration) + + assert transport.last_request is not None + assert json.loads(transport.last_request["body"])["integration"] == integration + + +def test_push_providers_match_the_gateway() -> None: + assert {provider for provider, _ in PUSH_PROVIDERS} == GATEWAY_PROVIDERS + assert len(PUSH_PROVIDERS) == len(GATEWAY_PROVIDERS) + + +def test_register_device_sends_the_device_shape() -> None: + """Gateway Hub.Push/Devices/Create.cs + PushDeviceDto.cs: `deviceOs` and `token` are required.""" + client, transport = make_client() + device = {"deviceOs": "iOS", "token": "device_1", "modelName": "iPhone"} + client.hub.notifications.register_device(userId="user_1", pushDeviceDto=device) assert transport.last_request is not None - integration = json.loads(transport.last_request["body"])["integration"] - assert integration["provider"] == provider - assert integration["integrationName"] == f"test-{provider}" + assert transport.last_request["method"] == "POST" + assert transport.last_request["url"].endswith(f"{BASE}/devices") + body = json.loads(transport.last_request["body"]) + assert body["pushDeviceDto"] == device + assert body["userId"] == "user_1" def test_async_module_exposes_the_same_push_surface() -> None: From 634e4e4a2866e877e02425b95ca1f175a73f279e Mon Sep 17 00:00:00 2001 From: CodeMash Date: Tue, 22 Sep 2026 17:28:20 +0300 Subject: [PATCH 2/2] =?UTF-8?q?item:=20devices-read-side=20=E2=80=94=20get?= =?UTF-8?q?=5Fpush=5Fdevices=20and=20get=5Fpush=5Fdevice,=20sync=20and=20a?= =?UTF-8?q?sync,=2037=20->=2039=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/hub/push.md | 22 ++++++++++ src/norbix_python/hub/notifications.py | 60 ++++++++++++++++++++++++++ tests/hub/test_push.py | 45 +++++++++++++++++-- 3 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/hub/push.md b/docs/hub/push.md index d220a5d..ebdf6af 100644 --- a/docs/hub/push.md +++ b/docs/hub/push.md @@ -80,6 +80,28 @@ client.hub.notifications.register_device( ) ``` +## Listing registered devices + +`get_push_devices` returns the devices registered in the project, each with +the user it belongs to. Narrow it with `userId`, `deviceKey` (the provider +token) or `platform` (`ios`, `android`, `chrome`, `safari`, `expo`); a word +outside that list is refused rather than answered with an empty page. + +```python +devices = client.hub.notifications.get_push_devices(platform="ios") +``` + +Devices are stored inside their user, so a page is a page of **users** and +carries every matching device those users hold. Follow `hasMore` rather than +stopping at the first short page. + +`get_push_device` takes one device id and answers with the device and its +owner: + +```python +device = client.hub.notifications.get_push_device("pnd_123") +``` + ## Known gaps | what | why | diff --git a/src/norbix_python/hub/notifications.py b/src/norbix_python/hub/notifications.py index 5d2e583..2b62774 100644 --- a/src/norbix_python/hub/notifications.py +++ b/src/norbix_python/hub/notifications.py @@ -919,6 +919,36 @@ def register_device(self, *, timeout: float | None = None, bearer_token: str | N bearer_token=bearer_token, ) + def get_push_devices(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/push/devices + + Narrow the list with userId, deviceKey (the provider token) or + platform (ios, android, chrome, safari, expo). + """ + return self._transport.send( + target="hub", + path="/{version}/notifications/push/devices", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + def get_push_device(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/push/devices/{id}""" + return self._transport.send( + target="hub", + path="/{version}/notifications/push/devices/{id}", + method="GET", + path_params={"id": id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + def get_all_contacts(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/contacts""" return self._transport.send( @@ -2627,6 +2657,36 @@ async def register_device(self, *, timeout: float | None = None, bearer_token: s bearer_token=bearer_token, ) + async def get_push_devices(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/push/devices + + Narrow the list with userId, deviceKey (the provider token) or + platform (ios, android, chrome, safari, expo). + """ + return await self._transport.send( + target="hub", + path="/{version}/notifications/push/devices", + method="GET", + path_params={}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + + async def get_push_device(self, id: str, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: + """GET /{version}/notifications/push/devices/{id}""" + return await self._transport.send( + target="hub", + path="/{version}/notifications/push/devices/{id}", + method="GET", + path_params={"id": id}, + request=request, + scope="project", + timeout=timeout, + bearer_token=bearer_token, + ) + async def get_all_contacts(self, *, timeout: float | None = None, bearer_token: str | None = None, **request: Any) -> Any: """GET /{version}/notifications/contacts""" return await self._transport.send( diff --git a/tests/hub/test_push.py b/tests/hub/test_push.py index 24a11c5..0a9711f 100644 --- a/tests/hub/test_push.py +++ b/tests/hub/test_push.py @@ -3,6 +3,7 @@ import json from collections.abc import Callable from typing import Any +from urllib.parse import parse_qs, urlparse import pytest @@ -12,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 37 push routes, and the polymorphic bodies that the server +every one of the 39 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 @@ -25,6 +26,7 @@ NOTIFICATION_ID = "notif_1" TEMPLATE_ID = "tpl_1" INTEGRATION_ID = "int_1" +DEVICE_ID = "pnd_1" BASE = "/v2/notifications/push" @@ -201,6 +203,13 @@ ), # devices ("register_device", "POST", f"{BASE}/devices", lambda m: m.register_device()), + ("get_push_devices", "GET", f"{BASE}/devices", lambda m: m.get_push_devices()), + ( + "get_push_device", + "GET", + f"{BASE}/devices/{DEVICE_ID}", + lambda m: m.get_push_device(DEVICE_ID), + ), ] @@ -225,8 +234,8 @@ def test_push_endpoint_hits_the_expected_route( def test_push_surface_size() -> None: - """37 live push routes. A new one changes this count, so it cannot arrive untested.""" - assert len(PUSH_CASES) == 37 + """39 live push routes. A new one changes this count, so it cannot arrive untested.""" + assert len(PUSH_CASES) == 39 def test_push_call_sends_auth_and_project_headers() -> None: @@ -382,6 +391,36 @@ def test_register_device_sends_the_device_shape() -> None: assert body["userId"] == "user_1" +def test_get_push_devices_narrows_by_user_token_and_platform() -> None: + """Gateway Hub.Push/Devices/GetAll.cs: the three filters travel as query fields.""" + client, transport = make_client() + client.hub.notifications.get_push_devices( + userId="user_1", deviceKey="device_1", platform="ios" + ) + + assert transport.last_request is not None + assert transport.last_request["method"] == "GET" + + # A GET carries its filters in the query string, not a body. + url = urlparse(transport.last_request["url"]) + assert url.path == f"{BASE}/devices" + assert parse_qs(url.query) == { + "userId": ["user_1"], + "deviceKey": ["device_1"], + "platform": ["ios"], + } + + +def test_get_push_device_fills_the_route_token() -> None: + """The device id is a route token, not a body field.""" + client, transport = make_client() + client.hub.notifications.get_push_device(DEVICE_ID) + + assert transport.last_request is not None + assert transport.last_request["method"] == "GET" + assert urlparse(transport.last_request["url"]).path == f"{BASE}/devices/{DEVICE_ID}" + + def test_async_module_exposes_the_same_push_surface() -> None: """Every sync push method must have an async twin.""" from norbix_python.hub.notifications import AsyncNotificationsModule, NotificationsModule