From abbf440f1a4b035a30896fd92d9588f837fdf96e Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 1 Jul 2026 10:01:49 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20add=20`createApnsBroadcast`=20(RSH1d)?= =?UTF-8?q?=20and=20`liveActivity`=20methods=20(RSH1e1=E2=80=93RSH1e3)=20i?= =?UTF-8?q?n=20PushAdmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include tests for `start`, `update`, and `end` methods with various scenarios and error propagation. - Update related specifications and documentation to reflect test coverage and implementation. --- specifications/api-docstrings.md | 17 + specifications/features.md | 40 ++ uts/docs/completion-status.md | 2 +- .../unit/push/push_admin_apns_broadcast.md | 204 ++++++++++ .../unit/push/push_admin_live_activity.md | 359 ++++++++++++++++++ 5 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 uts/rest/unit/push/push_admin_apns_broadcast.md create mode 100644 uts/rest/unit/push/push_admin_live_activity.md diff --git a/specifications/api-docstrings.md b/specifications/api-docstrings.md index f0d9594b1..187566acd 100644 --- a/specifications/api-docstrings.md +++ b/specifications/api-docstrings.md @@ -1084,6 +1084,23 @@ Enables the management of device registrations and push notification subscriptio || `data` ||| A JSON object containing the push notification payload. | | deviceRegistrations: PushDeviceRegistrations ||| RSH1b | A [`PushDeviceRegistrations`]{@link PushDeviceRegistrations} object. | | channelSubscriptions: PushChannelSubscriptions ||| RSH1c | A [`PushChannelSubscriptions`]{@link PushChannelSubscriptions} object. | +| createApnsBroadcast(options: PushApnsBroadcastOptions) => io PushApnsBroadcast ||| RSH1d | Creates an APNs broadcast channel for use with an iOS Live Activity. Call once before starting the Live Activity and persist the returned identifiers for the session. | +|| `options` ||| An object containing the `message-storage-policy` for the broadcast. | +|| `returns` ||| The created broadcast, containing its `id` and `apns-channel-id`. | +| liveActivity: PushLiveActivity ||| RSH1e | A [`PushLiveActivity`]{@link PushLiveActivity} object. | + +## class PushLiveActivity + +Controls the lifecycle of an iOS Live Activity over an APNs broadcast channel created with [`PushAdmin.createApnsBroadcast`]{@link PushAdmin#createApnsBroadcast}. + +| Method / Property | Parameter | Returns | Spec | Description | +|---|---|---|---|---| +| start(params: PushLiveActivityStartParams) => io ||| RSH1e1 | Sends a push-to-start notification to all devices subscribed to the given Ably channels, causing each device to start a new Live Activity. | +|| `params` ||| An object containing the recipient (either a list of `channels` or a single `device-id`), the broadcast `apns-broadcast` identifier, the `apns` Live Activity start payload, and optional APNs delivery `headers`. | +| update(params: PushLiveActivityUpdateParams) => io ||| RSH1e2 | Sends a content-state update to all devices with an active Live Activity on the broadcast channel. | +|| `params` ||| An object containing the broadcast `apns-broadcast` identifier, the `apns` Live Activity update payload, and optional APNs delivery `headers`. | +| end(params: PushLiveActivityEndParams) => io ||| RSH1e3 | Ends the Live Activity on all subscribed devices and cleans up the APNs channel. | +|| `params` ||| An object containing the broadcast `apns-broadcast` identifier, the `apns` Live Activity end payload, and optional APNs delivery `headers`. | ## class JsonObject diff --git a/specifications/features.md b/specifications/features.md index 2928b0d91..6930b25b1 100644 --- a/specifications/features.md +++ b/specifications/features.md @@ -1048,6 +1048,12 @@ The core SDK provides an API for wrapper SDKs to supply Ably with analytics info - `(RSH1c3)` `#save(pushChannelSubscription)` issues a `POST` request to [/push/channelSubscriptions](/api/rest-api#post-channel-subscription) using the `PushChannelSubscription` object (and optionally a JSON-encodable object) argument. If the client has been [activated as a push target device](#activation-state-machine), and the specified `PushChannelSubscription` contains a `deviceId` matching that of the present client, then this request must include [push device authentication](#push-device-authentication). A test should exist for a successful save, a successful subsequent save with an update, and a failed save operation - `(RSH1c4)` `#remove(push_channel_subscription)` issues a `DELETE` request to [/push/channelSubscriptions](/api/rest-api#delete-channel-subscription) and deletes the channel subscription using the attributes as params to the `DELETE` request. If the client has been [activated as a push target device](#activation-state-machine), and the specified `PushChannelSubscription` contains a `deviceId` matching that of the present client, then this request must include [push device authentication](#push-device-authentication). A test should exist that deletes a `clientId` and `deviceId` channel subscription separately and both succeed, and then also deletes a subscription that does not exist but still succeeds - `(RSH1c5)` `#removeWhere(params)` issues a `DELETE` request to [/push/channelSubscriptions](/api/rest-api#delete-channel-subscription) and deletes the matching channel subscriptions provided in `params`. A test should exist that deletes channel subscriptions by `clientId` and by `deviceId` separately, then additionally issues a delete for subscriptions with no matching params and checks the operation still succeeds. + - `(RSH1d)` `#createApnsBroadcast(options)` issues a `POST` request to `/push/apnsBroadcastChannels` with a body containing the `messageStoragePolicy` (either `0` or `1`), and returns the created broadcast as `{ id, apnsChannelId }`. This creates an APNs broadcast channel for use with an iOS Live Activity. + - `(RSH1e)` `#liveActivity` provides access to the admin `PushLiveActivity` object, which controls the lifecycle of an iOS Live Activity over an APNs broadcast channel created with `#createApnsBroadcast`. It provides the following methods: + - `(RSH1e1)` `#start(params)` issues a `POST` request to `/push/apnsBroadcastChannels/:apnsBroadcast/start` with a body carrying the `apns` payload together with the recipient, which is either a list of `channels` or a single `deviceId`. Optional APNs delivery `headers` (such as `apns-priority` and `apns-expiration`), when supplied, are included in the request body under a `headers` key alongside `apns`. + - `(RSH1e1a)` The `recipient` must specify exactly one of `channels` (a non-empty list) or `deviceId`. If neither is provided, or both are provided, the request is rejected before any HTTP request is made with an error with code `40000` and status code `400`. + - `(RSH1e2)` `#update(params)` issues a `POST` request to `/push/apnsBroadcastChannels/:apnsBroadcast/broadcast` with a body carrying the `apns` payload. Optional APNs delivery `headers` (such as `apns-priority` and `apns-expiration`), when supplied, are included in the request body under a `headers` key alongside `apns`. + - `(RSH1e3)` `#end(params)` issues a `POST` request to `/push/apnsBroadcastChannels/:apnsBroadcast/end` with a body carrying the `apns` payload. Optional APNs delivery `headers` (such as `apns-priority` and `apns-expiration`), when supplied, are included in the request body under a `headers` key alongside `apns`. - `(RSH2)` The following should only apply to platforms that support receiving push notifications: - `(RSH2a)` `Push#activate` sends a `CalledActivate` event to [the state machine](#RSH3). - `(RSH2b)` `Push#deactivate` sends a `CalledDeactivate` event to [the state machine](#RSH3). @@ -2781,6 +2787,8 @@ Each type, method, and attribute is labelled with the name of one or more clause publish(recipient: JsonObject, data: JsonObject) => io // RSH1a deviceRegistrations: PushDeviceRegistrations // RSH1b channelSubscriptions: PushChannelSubscriptions // RSH1c + createApnsBroadcast(options: PushApnsBroadcastOptions) => io PushApnsBroadcast // RSH1d + liveActivity: PushLiveActivity // RSH1e class PushDeviceRegistrations: // RSH1b get(deviceId: String) => io DeviceDetails // RSH1b1 @@ -2796,6 +2804,11 @@ Each type, method, and attribute is labelled with the name of one or more clause remove(PushChannelSubscription) => io // RSH1c4 removeWhere(params: Dict) => io // RSH1c5 + class PushLiveActivity: // RSH1e + start(params: PushLiveActivityStartParams) => io // RSH1e1 + update(params: PushLiveActivityUpdateParams) => io // RSH1e2 + end(params: PushLiveActivityEndParams) => io // RSH1e3 + enum DevicePlatform: // PCD6 "android" "ios" @@ -2818,6 +2831,33 @@ Each type, method, and attribute is labelled with the name of one or more clause clientId: String? // PCS3 channel: String // PCS4 + class PushApnsBroadcastOptions: // RSH1d + messageStoragePolicy: Int // 0 or 1 + + class PushApnsBroadcast: // RSH1d + id: String + apnsChannelId: String + + class PushLiveActivityStartParams: // RSH1e1 + recipient: PushLiveActivityRecipient + apnsBroadcast: String + apns: JsonObject + headers: Dict? + + class PushLiveActivityRecipient: // RSH1e1 + channels: List? // either channels or deviceId + deviceId: String? // either channels or deviceId + + class PushLiveActivityUpdateParams: // RSH1e2 + apnsBroadcast: String + apns: JsonObject + headers: Dict? + + class PushLiveActivityEndParams: // RSH1e3 + apnsBroadcast: String + apns: JsonObject + headers: Dict? + class ErrorInfo: // TI* code: Int // TI1 href: String? // TI1, TI4 diff --git a/uts/docs/completion-status.md b/uts/docs/completion-status.md index da149a36e..b0357f64f 100644 --- a/uts/docs/completion-status.md +++ b/uts/docs/completion-status.md @@ -297,7 +297,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSH1 | Push#admin object (RSH1a–RSH1c5) | Yes — `rest/unit/push/push_admin_publish.md` (RSH1, RSH1a), `rest/unit/push/push_device_registrations.md` (RSH1b1–RSH1b5), `rest/unit/push/push_channel_subscriptions.md` (RSH1c1–RSH1c5), `rest/integration/push_admin.md` (RSH1a–RSH1c5) | +| RSH1 | Push#admin object (RSH1a–RSH1e3) | Yes — `rest/unit/push/push_admin_publish.md` (RSH1, RSH1a), `rest/unit/push/push_device_registrations.md` (RSH1b1–RSH1b5), `rest/unit/push/push_channel_subscriptions.md` (RSH1c1–RSH1c5), `rest/unit/push/push_admin_apns_broadcast.md` (RSH1d), `rest/unit/push/push_admin_live_activity.md` (RSH1e1–RSH1e3), `rest/integration/push_admin.md` (RSH1a–RSH1c5) | | RSH2 | Platform-specific push operations (RSH2a–RSH2e) | | | RSH3 | Activation state machine (RSH3a–RSH3g3) | | | RSH4–RSH5 | Event queueing and sequential handling | | diff --git a/uts/rest/unit/push/push_admin_apns_broadcast.md b/uts/rest/unit/push/push_admin_apns_broadcast.md new file mode 100644 index 000000000..9a240d83e --- /dev/null +++ b/uts/rest/unit/push/push_admin_apns_broadcast.md @@ -0,0 +1,204 @@ +# PushAdmin createApnsBroadcast Tests + +Spec points: `RSH1`, `RSH1d` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSH1d — createApnsBroadcast sends POST to /push/apnsBroadcastChannels + +**Test ID**: `rest/unit/RSH1d/create-apns-broadcast-post-0` + +**Spec requirement:** RSH1d — `createApnsBroadcast(options)` issues a `POST` request to `/push/apnsBroadcastChannels`. + +Tests that `push.admin.createApnsBroadcast()` sends a POST to the correct path. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "id": "broadcast-1", "apnsChannelId": "apns-1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.createApnsBroadcast( + options: { "messageStoragePolicy": 1 } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/apnsBroadcastChannels" +``` + +--- + +## RSH1d — createApnsBroadcast body contains messageStoragePolicy + +**Test ID**: `rest/unit/RSH1d/message-storage-policy-1` + +**Spec requirement:** RSH1d — the request body contains the `messageStoragePolicy`. + +Tests that the supplied `messageStoragePolicy` is serialized into the request body. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "id": "broadcast-1", "apnsChannelId": "apns-1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.createApnsBroadcast( + options: { "messageStoragePolicy": 0 } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +body = parse_json(captured_requests[0].body) +ASSERT body["messageStoragePolicy"] == 0 +``` + +--- + +## RSH1d — createApnsBroadcast returns id and apnsChannelId + +**Test ID**: `rest/unit/RSH1d/returns-ids-2` + +**Spec requirement:** RSH1d — returns the created broadcast as `{ id, apnsChannelId }`. + +Tests that the `id` and `apnsChannelId` are parsed from the response body and returned. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(201, { + "id": "broadcast-xyz", + "apnsChannelId": "apple-channel-abc" + }) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.createApnsBroadcast( + options: { "messageStoragePolicy": 1 } +) +``` + +### Assertions +```pseudo +ASSERT result.id == "broadcast-xyz" +ASSERT result.apnsChannelId == "apple-channel-abc" +``` + +--- + +## RSH1d — createApnsBroadcast request includes an auth header + +**Test ID**: `rest/unit/RSH1d/auth-header-3` + +**Spec requirement:** RSH1d — the request is authenticated. + +Tests that the request carries a Basic authorization header derived from the API key. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "id": "broadcast-1", "apnsChannelId": "apns-1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.createApnsBroadcast( + options: { "messageStoragePolicy": 1 } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].headers["Authorization"] STARTS_WITH "Basic " +``` + +--- + +## RSH1d — createApnsBroadcast propagates server error + +**Test ID**: `rest/unit/RSH1d/server-error-4` + +**Spec requirement:** RSH1d — a server error response is propagated to the caller. + +Tests that an error response from the server is surfaced to the caller. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(400, { + "error": { + "code": 40000, + "statusCode": 400, + "message": "Invalid request" + } + }) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.createApnsBroadcast( + options: { "messageStoragePolicy": 1 } +) FAILS WITH error +ASSERT error.code == 40000 +``` diff --git a/uts/rest/unit/push/push_admin_live_activity.md b/uts/rest/unit/push/push_admin_live_activity.md new file mode 100644 index 000000000..85d459c66 --- /dev/null +++ b/uts/rest/unit/push/push_admin_live_activity.md @@ -0,0 +1,359 @@ +# PushAdmin Live Activity Tests + +Spec points: `RSH1`, `RSH1e`, `RSH1e1`, `RSH1e2`, `RSH1e3` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSH1e1 — start sends POST to /push/apnsBroadcastChannels/:id/start + +**Test ID**: `rest/unit/RSH1e1/start-post-0` + +**Spec requirement:** RSH1e1 — `start(params)` issues a `POST` to `/push/apnsBroadcastChannels/:apnsBroadcast/start` with a body carrying the `apns` payload and the recipient `channels`; optional APNs delivery `headers` are included in the request body under a `headers` key. + +Tests that `liveActivity.start()` POSTs the channels, apns payload and optional headers to the start endpoint, and does not include a `deviceId` when none is supplied. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.liveActivity.start( + params: { + "recipient": { "channels": ["nba:lakers", "nba:celtics"] }, + "apnsBroadcast": "broadcast-1", + "apns": { "aps": { "event": "start", "attributes-type": "GameAttributes" } }, + "headers": { "apns-priority": "10", "apns-expiration": "1782948701" } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/apnsBroadcastChannels/broadcast-1/start" + +body = parse_json(request.body) +ASSERT body["channels"] == ["nba:lakers", "nba:celtics"] +ASSERT body["apns"]["aps"]["event"] == "start" +ASSERT body DOES NOT CONTAIN KEY "deviceId" +ASSERT body["headers"] == { "apns-priority": "10", "apns-expiration": "1782948701" } +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " +``` + +--- + +## RSH1e1 — start includes deviceId and url-encodes the broadcast id + +**Test ID**: `rest/unit/RSH1e1/start-deviceid-encode-1` + +**Spec requirement:** RSH1e1 — the recipient may be a single `deviceId` instead of `channels`; when supplied, the `deviceId` is included in the body (and no `channels` key is sent), and the `apnsBroadcast` id is URL-encoded in the request path. + +Tests that a `deviceId`-only recipient is sent without a `channels` key, and that a broadcast id with reserved characters is URL-encoded in the path. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.liveActivity.start( + params: { + "recipient": { "deviceId": "device-7" }, + "apnsBroadcast": "broadcast/with space", + "apns": { "aps": { "event": "start" } } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.url.path == "/push/apnsBroadcastChannels/" + encode_uri_component("broadcast/with space") + "/start" + +body = parse_json(request.body) +ASSERT body["deviceId"] == "device-7" +ASSERT body DOES NOT CONTAIN KEY "channels" +``` + +--- + +## RSH1e2 — update sends POST to /push/apnsBroadcastChannels/:id/broadcast + +**Test ID**: `rest/unit/RSH1e2/update-post-0` + +**Spec requirement:** RSH1e2 — `update(params)` issues a `POST` to `/push/apnsBroadcastChannels/:apnsBroadcast/broadcast` with a body carrying the `apns` payload; optional APNs delivery `headers` are included in the request body under a `headers` key. + +Tests that `liveActivity.update()` POSTs the apns payload to the broadcast endpoint and includes the supplied APNs delivery headers in the request body. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.liveActivity.update( + params: { + "apnsBroadcast": "broadcast-1", + "apns": { "aps": { "event": "update", "content-state": { "homeScore": 14 } } }, + "headers": { "apns-priority": "10", "apns-expiration": "1782948701" } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/apnsBroadcastChannels/broadcast-1/broadcast" + +body = parse_json(request.body) +ASSERT body["apns"]["aps"]["event"] == "update" + +# The optional APNs delivery headers are sent in the request body under a "headers" key +ASSERT body["headers"] == { "apns-priority": "10", "apns-expiration": "1782948701" } +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " +``` + +--- + +## RSH1e2 — update omits APNs delivery headers when not supplied + +**Test ID**: `rest/unit/RSH1e2/update-no-headers-1` + +**Spec requirement:** RSH1e2 — the `headers` are optional. + +Tests that no `headers` key is included in the body when none are supplied, and the apns payload is still POSTed. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.liveActivity.update( + params: { + "apnsBroadcast": "broadcast-1", + "apns": { "aps": { "event": "update", "content-state": {} } } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +body = parse_json(request.body) +ASSERT body["apns"]["aps"]["event"] == "update" +ASSERT body DOES NOT CONTAIN KEY "headers" +``` + +--- + +## RSH1e3 — end sends POST to /push/apnsBroadcastChannels/:id/end + +**Test ID**: `rest/unit/RSH1e3/end-post-0` + +**Spec requirement:** RSH1e3 — `end(params)` issues a `POST` to `/push/apnsBroadcastChannels/:apnsBroadcast/end` with a body carrying the `apns` payload. + +Tests that `liveActivity.end()` POSTs the apns end payload to the end endpoint. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.liveActivity.end( + params: { + "apnsBroadcast": "broadcast-1", + "apns": { "aps": { "event": "end", "content-state": { "homeScore": 112 }, "dismissal-date": 1700000000 } } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/apnsBroadcastChannels/broadcast-1/end" + +body = parse_json(request.body) +ASSERT body["apns"]["aps"]["event"] == "end" +ASSERT body["apns"]["aps"]["dismissal-date"] == 1700000000 +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " +``` + +--- + +## RSH1e1 — start propagates server error + +**Test ID**: `rest/unit/RSH1e1/server-error-2` + +**Spec requirement:** RSH1e1 — a server error response is propagated to the caller. + +Tests that an error response from the server is surfaced to the caller of `start`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(400, { + "error": { "code": 40000, "statusCode": 400, "message": "Invalid request" } + }) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.liveActivity.start( + params: { + "recipient": { "channels": ["nba:lakers"] }, + "apnsBroadcast": "broadcast-1", + "apns": {} + } +) FAILS WITH error +ASSERT error.code == 40000 +``` + +--- + +## RSH1e2 — update propagates server error + +**Test ID**: `rest/unit/RSH1e2/server-error-2` + +**Spec requirement:** RSH1e2 — a server error response is propagated to the caller. + +Tests that an error response from the server is surfaced to the caller of `update`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(400, { + "error": { "code": 40000, "statusCode": 400, "message": "Invalid request" } + }) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.liveActivity.update( + params: { "apnsBroadcast": "broadcast-1", "apns": {} } +) FAILS WITH error +ASSERT error.code == 40000 +``` + +--- + +## RSH1e3 — end propagates server error + +**Test ID**: `rest/unit/RSH1e3/server-error-1` + +**Spec requirement:** RSH1e3 — a server error response is propagated to the caller. + +Tests that an error response from the server is surfaced to the caller of `end`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(400, { + "error": { "code": 40000, "statusCode": 400, "message": "Invalid request" } + }) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.liveActivity.end( + params: { "apnsBroadcast": "broadcast-1", "apns": {} } +) FAILS WITH error +ASSERT error.code == 40000 +```