diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2eff8e310 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "submodules/ably-common"] + path = submodules/ably-common + url = https://github.com/ably/ably-common.git diff --git a/submodules/ably-common b/submodules/ably-common new file mode 160000 index 000000000..bf29083e8 --- /dev/null +++ b/submodules/ably-common @@ -0,0 +1 @@ +Subproject commit bf29083e89aa2b8125830371af6e2eac46c15e10 diff --git a/uts/README.md b/uts/README.md new file mode 100644 index 000000000..b3799a954 --- /dev/null +++ b/uts/README.md @@ -0,0 +1,309 @@ +# Test Specifications + +Portable test specifications for Ably REST SDK implementation. + +## Directory Structure + +``` +specs/ +├── unit/ # Unit tests (mocked HTTP) +│ ├── auth/ +│ │ ├── auth_callback.md # RSA8c, RSA8d - authCallback/authUrl invocation +│ │ ├── auth_scheme.md # RSA1-4, RSA4b, RSC18 - auth method selection +│ │ ├── token_renewal.md # RSA4b4, RSA14 - token expiry and renewal +│ │ └── client_id.md # RSA7, RSA12 - clientId handling +│ ├── channel/ +│ │ ├── history.md # RSL2 - channel history +│ │ ├── idempotency.md # RSL1k - idempotent publishing +│ │ └── publish.md # RSL1 - channel publish +│ ├── client/ +│ │ ├── client_options.md # RSC1 - ClientOptions parsing +│ │ ├── fallback.md # RSC15, REC - host fallback +│ │ ├── realtime_client.md # RTC1, RTC2, RTC12-17 - Realtime client +│ │ ├── rest_client.md # RSC7, RSC8, RSC13, RSC18 - client configuration +│ │ ├── time.md # RSC16 - server time +│ │ └── stats.md # RSC6 - application statistics +│ ├── encoding/ +│ │ └── message_encoding.md # RSL4, RSL6 - data encoding/decoding +│ ├── presence/ +│ │ └── rest_presence.md # RSP1-5 - REST presence operations +│ └── types/ +│ ├── error_types.md # TI - ErrorInfo +│ ├── message_types.md # TM - Message +│ ├── options_types.md # TO, AO - ClientOptions, AuthOptions +│ ├── paginated_result.md # TG - PaginatedResult +│ └── token_types.md # TD, TK, TE - TokenDetails, TokenParams, TokenRequest +├── integration/ # Integration tests (Ably sandbox) +│ ├── auth.md # Authentication against real server +│ ├── history.md # History retrieval +│ ├── pagination.md # TG - pagination navigation +│ ├── presence.md # RSP1-5 - REST presence operations +│ ├── publish.md # RSL1 - channel publish +│ └── time_stats.md # RSC16, RSC6 - time and stats APIs +└── README.md # This file +``` + +## Test Types + +### Unit Tests + +Unit tests use a mocked HTTP client to: +- Verify correct request formation (headers, body, query params) +- Test response parsing +- Test error handling +- Test client-side validation + +The mock HTTP client should: +- Capture outgoing requests for inspection +- Return configurable responses +- Support per-host response configuration (for fallback tests) +- Simulate failure conditions (timeout, connection errors) + +### Integration Tests + +Integration tests run against the Ably sandbox environment: +- `POST https://sandbox.realtime.ably-nonprod.net/apps` to provision app +- Use `endpoint: "sandbox"` in ClientOptions +- Test real server behavior and validation + +#### Sandbox App Management + +Test apps created using this endpoint should be created **once** in the setup for a test run, and **explicitly deleted** when complete. Multiple tests can run against a single app so long as there is no conflict between the state created between those tests. + +```pseudo +BEFORE ALL TESTS: + app_config = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + api_key = app_config.keys[0].key_str + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +#### Unique Channel Names + +Any channels created by tests within sandbox apps should be unique for each test. The preferred approach to ensuring uniqueness is to construct channel names as a combination of: +1. A **descriptive part** that refers to the test (e.g., including the name of the test, or the ID of the spec item) +2. A **random part** that's sufficiently large to ensure the risk of collision is negligible (e.g., a base64-encoded 48-bit number) + +Example: `test-RSL1-publish-${base64(random_bytes(6))}` + +#### Authenticated Endpoints + +Do **not** use `time()` for testing authentication because it does not require authentication. Use the **channel status endpoint** instead: + +```pseudo +GET /channels/{channel_name} +``` + +This endpoint requires authentication and returns channel metadata. + +## Token Testing + +### JWT vs Native Tokens + +All relevant token functionality should be integration-tested with **both**: +1. **JWTs** (primary format) - Use a third-party JWT library to generate valid JWTs for integration tests +2. **Ably native tokens** - Obtained using `requestToken()` + +JWT should be the primary token format used. Native tokens, and the correct handling of token requests, should be tested in a way that's as independent as possible from testing the mechanisms relating to handling tokens in requests and the token renewal process via `authCallback` and `authUrl`. + +### Unit Tests with Tokens + +For unit tests, since the token string is opaque to the library, any arbitrary string can be used as a token value. + +## Avoiding Flaky Tests + +### Polling Instead of Fixed Waits + +Do not use fixed `WAIT` durations that may cause flakiness due to timing variations. Instead, use polling: + +```pseudo +# Bad - flaky +WAIT 5 seconds +ASSERT condition + +# Good - reliable +poll_until( + condition, + interval: 500ms, + timeout: 10s +) +``` + +### Token Expiry Testing + +For tests that need to wait for token expiry: +1. Use a short TTL (e.g., 2 seconds) +2. Wait the TTL duration +3. Poll an endpoint at intervals (e.g., 500ms) until rejection +4. Set a reasonable timeout (e.g., 5 seconds after TTL) + +This approach avoids flakes from minor clock skew while minimizing test duration. + +## Spec Point Coverage + +### REST Client (RSC) +| Spec | Test File | Description | +|------|-----------|-------------| +| RSC1 | uts/test/realtime/unit/client/client_options.md | String argument detection | +| RSC6 | unit/client/stats.md | Application statistics | +| RSC7 | uts/test/rest/unit/rest_client.md | Request headers | +| RSC8 | uts/test/rest/unit/rest_client.md | Protocol selection | +| RSC13 | uts/test/rest/unit/rest_client.md | Request timeouts | +| RSC15 | unit/client/fallback.md | Host fallback | +| RSC16 | unit/client/time.md | Server time | +| RSC18 | uts/test/rest/unit/rest_client.md | TLS configuration | + +### REST Authentication (RSA) +| Spec | Test File | Description | +|------|-----------|-------------| +| RSA1-4 | unit/auth/auth_scheme.md | Auth method selection | +| RSA4b4, RSA14 | unit/auth/token_renewal.md | Token expiry and renewal | +| RSA7 | unit/auth/client_id.md | clientId from options | +| RSA8c | unit/auth/auth_callback.md | authUrl queries | +| RSA8d | unit/auth/auth_callback.md | authCallback invocation | +| RSA12 | unit/auth/client_id.md | clientId in TokenParams | + +### REST Channel (RSL) +| Spec | Test File | Description | +|------|-----------|-------------| +| RSL1 | unit/channel/publish.md | Channel publish | +| RSL1k | unit/channel/idempotency.md | Idempotent publishing | +| RSL2 | unit/channel/history.md | Channel history | +| RSL4, RSL6 | unit/encoding/message_encoding.md | Message encoding | + +### REST Presence (RSP) +| Spec | Test File | Description | +|------|-----------|-------------| +| RSP1 | unit/presence/rest_presence.md | RestPresence accessible via channel | +| RSP3 | unit/presence/rest_presence.md | RestPresence#get | +| RSP3a1 | unit/presence/rest_presence.md | get() limit parameter | +| RSP3a2 | unit/presence/rest_presence.md | get() clientId filter | +| RSP3a3 | unit/presence/rest_presence.md | get() connectionId filter | +| RSP4 | unit/presence/rest_presence.md | RestPresence#history | +| RSP4b1 | unit/presence/rest_presence.md | history() start/end params | +| RSP4b2 | unit/presence/rest_presence.md | history() direction param | +| RSP4b3 | unit/presence/rest_presence.md | history() limit param | +| RSP5 | unit/presence/rest_presence.md | Presence message decoding | + +### Realtime Client (RTC) +| Spec | Test File | Description | +|------|-----------|-------------| +| RTC1a | uts/test/realtime/unit/client/realtime_client.md | echoMessages option | +| RTC1b | uts/test/realtime/unit/client/realtime_client.md | autoConnect option | +| RTC1c | uts/test/realtime/unit/client/realtime_client.md | recover option | +| RTC1f | uts/test/realtime/unit/client/realtime_client.md | transportParams option | +| RTC2 | uts/test/realtime/unit/client/realtime_client.md | connection attribute | +| RTC3 | uts/test/realtime/unit/client/realtime_client.md | channels attribute | +| RTC4 | uts/test/realtime/unit/client/realtime_client.md | auth attribute | +| RTC12 | uts/test/realtime/unit/client/realtime_client.md | Constructor (same as REST) | +| RTC15 | uts/test/realtime/unit/client/realtime_client.md | connect() method | +| RTC16 | uts/test/realtime/unit/client/realtime_client.md | close() method | +| RTC17 | uts/test/realtime/unit/client/realtime_client.md | clientId attribute | + +### Types (T*) +| Spec | Test File | Description | +|------|-----------|-------------| +| TD | unit/types/token_types.md | TokenDetails | +| TK | unit/types/token_types.md | TokenParams | +| TE | unit/types/token_types.md | TokenRequest | +| TM | unit/types/message_types.md | Message | +| TO | unit/types/options_types.md | ClientOptions | +| AO | unit/types/options_types.md | AuthOptions | +| TI | unit/types/error_types.md | ErrorInfo | +| TG | unit/types/paginated_result.md | PaginatedResult | + +### Environment Configuration (REC) +| Spec | Test File | Description | +|------|-----------|-------------| +| REC1, REC2 | unit/client/fallback.md | Custom endpoints | + +## Pseudo-code Conventions + +### Setup Blocks +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(status, body) +mock_http.queue_response_for_host(host, status, body) + +client = Rest(options: ClientOptions(...)) +``` + +### Test Steps +```pseudo +result = AWAIT client.operation() +``` + +### Assertions +```pseudo +ASSERT condition +ASSERT value == expected +ASSERT value IN list +ASSERT value matches pattern "regex" +ASSERT value IS Type +ASSERT "key" IN object +ASSERT "key" NOT IN object +``` + +### Error Testing +```pseudo +AWAIT operation_that_fails() FAILS WITH error +ASSERT error.code == expected_code +``` + +### URI Path Component Encoding +```pseudo +encode_uri_component(value) +``` + +Encodes a string for use as a single URI path segment or query parameter value, +per [RFC 3986 Section 2.1](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1). +All characters except unreserved characters (`A-Z a-z 0-9 - _ . ~`) are +percent-encoded. In particular, `/`, `:`, and space are encoded as `%2F`, +`%3A`, and `%20` respectively. + +Language equivalents: +- Dart: `Uri.encodeComponent()` +- JavaScript: `encodeURIComponent()` +- Python: `urllib.parse.quote(, safe="")` +- Go: `url.PathEscape()` +- Java: `URLEncoder.encode(, "UTF-8")` (then replace `+` with `%20`) + +### Loops +```pseudo +FOR EACH item IN collection: + # test each item + +FOR i IN 1..10: + # test numbered items +``` + +### Polling +```pseudo +poll_until(condition, interval, timeout): + start = now() + WHILE now() - start < timeout: + IF condition(): + RETURN success + WAIT interval + FAIL("Timeout waiting for condition") +``` + +## Fixtures + +Where applicable, tests reference fixtures from `ably-common`: +- Encoding/decoding test vectors +- Standard test data +- App setup configuration: `test-resources/test-app-setup.json` + +## Implementation Notes + +When implementing these tests: +1. Use the language's idiomatic testing framework +2. Implement mock HTTP client via appropriate mechanism (dependency injection, HttpOverrides, etc.) +3. Group related tests in the same test file/class +4. Use descriptive test names that reference spec points +5. Consider parameterized/table-driven tests for test cases +6. For JWT generation in integration tests, use a well-established third-party JWT library diff --git a/uts/rest/unit/auth/auth_callback.md b/uts/rest/unit/auth/auth_callback.md new file mode 100644 index 000000000..ac6464463 --- /dev/null +++ b/uts/rest/unit/auth/auth_callback.md @@ -0,0 +1,561 @@ +# Auth Callback Tests + +Spec points: `RSA8c`, `RSA8d` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +These tests verify that the library correctly invokes `authCallback` and `authUrl` to obtain tokens for authentication. The authCallback/authUrl can return: +- A `TokenDetails` object (containing token, expires, etc.) +- A `TokenRequest` object (which the library exchanges for a token) +- A JWT string (raw token string) + +--- + +## RSA8d - authCallback invoked for authentication + +**Spec requirement:** When `authCallback` is configured, it is invoked to obtain a token for authentication. + +Tests that when `authCallback` is configured, it is invoked to obtain a token. + +### Setup +```pseudo +callback_invoked = false +callback_params = null +captured_requests = [] + +auth_callback = FUNCTION(params): + callback_invoked = true + callback_params = params + RETURN TokenDetails( + token: "callback-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +# Make a request that requires authentication +result = AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# authCallback was invoked +ASSERT callback_invoked == true + +# Request used the token from authCallback +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].headers["Authorization"] == "Bearer callback-token" +``` + +--- + +## RSA8d - authCallback returning JWT string + +**Spec requirement:** authCallback can return a raw JWT string (not wrapped in TokenDetails). + +Tests that authCallback can return a raw JWT string (not wrapped in TokenDetails). + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + # Return raw JWT string instead of TokenDetails + RETURN "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# Request used the JWT from authCallback +ASSERT captured_requests[0].headers["Authorization"] == "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-jwt-payload" +``` + +--- + +## RSA8d - authCallback returning TokenRequest + +**Spec requirement:** When authCallback returns a TokenRequest, the library must exchange it for a token via the requestToken endpoint. + +Tests that when authCallback returns a TokenRequest, the library exchanges it for a token. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + # Return a TokenRequest (to be exchanged for token) + RETURN TokenRequest( + keyName: "app.key", + ttl: 3600000, + timestamp: now(), + nonce: "unique-nonce", + mac: "computed-mac" + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # First request exchanges TokenRequest for TokenDetails + req.respond_with(200, { + "token": "exchanged-token", + "expires": now() + 3600000 + }) + ELSE: + # Second request is the actual API call + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# Two HTTP requests: token exchange + API call +ASSERT captured_requests.length == 2 + +# First request was POST to /keys/{keyName}/requestToken +first_request = captured_requests[0] +ASSERT first_request.method == "POST" +ASSERT first_request.path matches "/keys/.*/requestToken" + +# Second request used the exchanged token +second_request = captured_requests[1] +ASSERT second_request.headers["Authorization"] == "Bearer exchanged-token" +``` + +--- + +## RSA8d - authCallback receives TokenParams + +**Spec requirement:** authCallback receives TokenParams when provided to authorize(). + +Tests that authCallback receives TokenParams when provided to authorize(). + +### Setup +```pseudo +received_params = null + +auth_callback = FUNCTION(params): + received_params = params + RETURN TokenDetails( + token: "test-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + tokenParams: TokenParams( + clientId: "requested-client-id", + ttl: 7200000, + capability: {"channel1": ["publish"]} + ) +) +``` + +### Assertions +```pseudo +# authCallback received the TokenParams +ASSERT received_params.clientId == "requested-client-id" +ASSERT received_params.ttl == 7200000 +ASSERT received_params.capability == {"channel1": ["publish"]} +``` + +--- + +## RSA8c - authUrl invoked for authentication + +**Spec requirement:** When `authUrl` is configured, the library must fetch a token from it before making API requests. + +Tests that when `authUrl` is configured, the library fetches a token from it. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl returns TokenDetails + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + # Actual API request + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# First request was to authUrl +auth_request = captured_requests[0] +ASSERT auth_request.url.host == "auth.example.com" +ASSERT auth_request.url.path == "/token" +ASSERT auth_request.method == "GET" + +# Second request used the token from authUrl +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer authurl-token" +``` + +--- + +## RSA8c - authUrl with POST method + +**Spec requirement:** authMethod can be set to POST for authUrl requests. + +Tests that authMethod can be set to POST for authUrl. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token", + authMethod: "POST" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# authUrl request used POST method +auth_request = captured_requests[0] +ASSERT auth_request.method == "POST" +``` + +--- + +## RSA8c - authUrl with custom headers + +**Spec requirement:** authHeaders are sent with authUrl requests. + +Tests that authHeaders are sent with authUrl requests. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token", + authHeaders: { + "X-Custom-Header": "custom-value", + "X-API-Key": "my-api-key" + } + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +auth_request = captured_requests[0] +ASSERT auth_request.headers["X-Custom-Header"] == "custom-value" +ASSERT auth_request.headers["X-API-Key"] == "my-api-key" +``` + +--- + +## RSA8c - authUrl with query params + +**Spec requirement:** authParams are sent as query parameters with authUrl GET requests. + +Tests that authParams are sent as query parameters with authUrl GET requests. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token", + authParams: { + "client_id": "my-client", + "scope": "publish:*" + } + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +auth_request = captured_requests[0] +ASSERT auth_request.url.query_params["client_id"] == "my-client" +ASSERT auth_request.url.query_params["scope"] == "publish:*" +``` + +--- + +## RSA8c - authUrl returning JWT string + +**Spec requirement:** authUrl can return a raw JWT string (not JSON). + +Tests that authUrl can return a raw JWT string. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl returns plain text JWT (not JSON) + req.respond_with(200, + body: "eyJhbGciOiJIUzI1NiJ9.jwt-body.signature", + headers: {"Content-Type": "text/plain"} + ) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/jwt" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer eyJhbGciOiJIUzI1NiJ9.jwt-body.signature" +``` + +--- + +## RSA8d - authCallback error propagated + +**Spec requirement:** Errors from authCallback are properly propagated to the caller. + +Tests that errors from authCallback are properly propagated. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + THROW Error("Authentication server unavailable") + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +# Error should indicate auth failure +ASSERT error.message CONTAINS "Authentication server unavailable" +``` + +### Assertions +```pseudo +# No HTTP requests should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA8c - authUrl error propagated + +**Spec requirement:** HTTP errors from authUrl are properly propagated to the caller. + +Tests that HTTP errors from authUrl are properly propagated. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl returns error + req.respond_with(500, { + "error": "Internal server error" + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +ASSERT error.statusCode == 500 OR error.message CONTAINS "auth" +``` + +### Assertions +```pseudo +# Only authUrl request was made, not the API request +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].url.host == "auth.example.com" +``` diff --git a/uts/rest/unit/auth/auth_scheme.md b/uts/rest/unit/auth/auth_scheme.md new file mode 100644 index 000000000..7250e3bcd --- /dev/null +++ b/uts/rest/unit/auth/auth_scheme.md @@ -0,0 +1,494 @@ +# Auth Scheme Selection Tests + +Spec points: `RSA1`, `RSA2`, `RSA3`, `RSA4`, `RSA4a2`, `RSA11`, `RSC1b` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +These tests verify that the library correctly selects between Basic authentication (API key) and Token authentication based on ClientOptions configuration. + +### Key Rules + +- **Basic auth**: Uses `Authorization: Basic {base64(key)}` header +- **Token auth**: Uses `Authorization: Bearer {token}` header + +--- + +## RSA4 - Basic auth with API key only + +**Spec requirement:** When only an API key is provided (no clientId), Basic auth is used. + +Tests that when only an API key is provided (no clientId), Basic auth is used. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(key: "appId.keyId:keySecret") +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Basic auth header uses base64-encoded key +expected_auth = "Basic " + base64("appId.keyId:keySecret") +ASSERT request.headers["Authorization"] == expected_auth +``` + +--- + +## RSA3 - Token auth with explicit token + +**Spec requirement:** When an explicit token is provided, it is used for Bearer auth. + +Tests that when an explicit token is provided, it is used for Bearer auth. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(token: "explicit-token-string") +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer explicit-token-string" +``` + +--- + +## RSA3 - Token auth with TokenDetails + +**Spec requirement:** When TokenDetails is provided, the token string is extracted and used for Bearer auth. + +Tests that when TokenDetails is provided, the token string is extracted and used. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + tokenDetails: TokenDetails( + token: "token-from-details", + expires: now() + 3600000 + ) + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer token-from-details" +``` + +--- + +## RSA4 - useTokenAuth forces token auth + +**Spec requirement:** `useTokenAuth: true` forces token auth even with just an API key. + +Tests that `useTokenAuth: true` forces token auth even with just an API key. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # Token request + req.respond_with(200, { + "token": "obtained-token", + "expires": now() + 3600000 + }) + ELSE: + # API request + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + key: "appId.keyId:keySecret", + useTokenAuth: true + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# Should obtain token rather than use Basic auth +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer obtained-token" +``` + +--- + +## RSA4 - authCallback triggers token auth + +**Spec requirement:** Presence of authCallback triggers token auth. + +Tests that presence of authCallback triggers token auth. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + RETURN TokenDetails( + token: "callback-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer callback-token" +``` + +--- + +## RSA4 - authUrl triggers token auth + +**Spec requirement:** Presence of authUrl triggers token auth. + +Tests that presence of authUrl triggers token auth. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + # authUrl response + req.respond_with(200, { + "token": "authurl-token", + "expires": now() + 3600000 + }) + ELSE: + # API request + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://auth.example.com/token" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +api_request = captured_requests[1] +ASSERT api_request.headers["Authorization"] == "Bearer authurl-token" +``` + +--- + +## RSC1b - Error when no auth method available + +**Spec requirement:** An error is raised when no authentication method is configured (code 40106). + +Tests that an error is raised when no authentication method is configured. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions() # No key, token, or auth callback +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +ASSERT error.code == 40106 # No authentication method +``` + +### Assertions +```pseudo +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA4a2 - Error when token expired and no renewal method + +**Spec requirement:** An error is raised when a static token has expired and there's no way to renew it (code 40171). + +Tests that an appropriate error is raised when a static token has expired and there's no way to renew it. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + tokenDetails: TokenDetails( + token: "expired-token", + expires: now() - 1000 # Already expired + ) + # No key, authCallback, or authUrl for renewal + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") FAILS WITH error +ASSERT error.code == 40171 # Token expired with no means of renewal +``` + +### Assertions +```pseudo +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA1 - Auth method priority + +**Spec requirement:** When multiple auth options are provided, token-based auth takes precedence over basic auth. + +Tests the priority order when multiple auth options are provided. + +### Setup +```pseudo +captured_requests = [] + +auth_callback = FUNCTION(params): + RETURN TokenDetails( + token: "callback-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +# Both key and authCallback provided +client = Rest( + options: ClientOptions( + key: "appId.keyId:keySecret", + authCallback: auth_callback + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +# authCallback takes precedence, so Bearer auth is used +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer callback-token" +``` + +--- + +## RSA2, RSA11 - Basic auth header format + +**Spec requirement:** Basic auth uses the format `Authorization: Basic {base64(key)}` (RSA2). The API key is Base64-encoded per RFC 7235, with the key name as username and key secret as password (RSA11). + +Tests the exact format of Basic auth header. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(key: "app123.key456:secretXYZ") +) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/test") +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Verify exact Base64 encoding +# "app123.key456:secretXYZ" base64 encoded +expected = "Basic " + base64("app123.key456:secretXYZ") +ASSERT request.headers["Authorization"] == expected + +# The Base64 should NOT have URL-safe encoding (+ and / are valid) +ASSERT request.headers["Authorization"] CONTAINS "Basic " +``` + +--- + +## RSC18 - Token auth allowed over non-TLS + +**Spec requirement:** Token auth is allowed over non-TLS connections. + +Tests that token auth is allowed over non-TLS connections. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + token: "explicit-token", + tls: false # Non-TLS allowed for token auth + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Authorization"] == "Bearer explicit-token" + +# Request should use http:// (non-TLS) +ASSERT request.url.scheme == "http" +``` diff --git a/uts/rest/unit/auth/authorize.md b/uts/rest/unit/auth/authorize.md new file mode 100644 index 000000000..ff300d8dd --- /dev/null +++ b/uts/rest/unit/auth/authorize.md @@ -0,0 +1,422 @@ +# Auth.authorize() Tests + +Spec points: `RSA10`, `RSA10a`, `RSA10b`, `RSA10e`, `RSA10g`, `RSA10h`, `RSA10i`, `RSA10j`, `RSA10k`, `RSA10l` + +## Test Type +Unit test with mocked HTTP client and/or mocked authCallback + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +--- + +## RSA10a - authorize() with default tokenParams + +**Spec requirement:** `authorize()` obtains a token using configured defaults. + +Tests that `authorize()` obtains a token using configured defaults. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # Token request + req.respond_with(200, { + "token": "obtained-token", + "expires": now() + 3600000, + "keyName": "appId.keyId" + }) + ELSE: + # Subsequent request to verify token is used + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +ASSERT token_details IS TokenDetails +ASSERT token_details.token == "obtained-token" + +# Verify token is now used for requests +AWAIT client.channels.get("test").status() +ASSERT captured_requests.last.headers["Authorization"] == "Bearer obtained-token" +``` + +--- + +## RSA10b - authorize() with explicit tokenParams + +**Spec requirement:** Provided `tokenParams` override defaults in authorize(). + +Tests that provided `tokenParams` override defaults. + +### Setup +```pseudo +callback_params = [] + +mock_auth_callback = (params) => { + callback_params.append(params) + RETURN TokenDetails(token: "callback-token", expires: now() + 3600000) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: mock_auth_callback, + clientId: "default-client" # Default TokenParams +)) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + tokenParams: TokenParams( + clientId: "override-client", + ttl: 7200000 + ) +) +``` + +### Assertions +```pseudo +params = callback_params[0] +ASSERT params.clientId == "override-client" # Overridden +ASSERT params.ttl == 7200000 +``` + +--- + +## RSA10e - authorize() saves tokenParams for reuse + +**Spec requirement:** `tokenParams` provided to `authorize()` are saved and reused on subsequent token requests. + +Tests that `tokenParams` provided to `authorize()` are saved and reused. + +### Setup +```pseudo +callback_invocations = [] + +mock_auth_callback = (params) => { + callback_invocations.append(params) + RETURN TokenDetails( + token: "token-" + str(callback_invocations.length), + expires: now() + 1000 # Very short expiry for testing + ) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: mock_auth_callback)) +``` + +### Test Steps +```pseudo +# First authorize with custom params +AWAIT client.auth.authorize( + tokenParams: TokenParams(clientId: "saved-client", ttl: 3600000) +) + +# Wait for token to expire +WAIT 1500 milliseconds + +# Force re-auth via request - should reuse saved params +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +# Second callback should have received the saved params +ASSERT callback_invocations[1].clientId == "saved-client" +ASSERT callback_invocations[1].ttl == 3600000 +``` + +--- + +## RSA10g - authorize() updates Auth.tokenDetails + +**Spec requirement:** After `authorize()`, `auth.tokenDetails` reflects the new token. + +Tests that after `authorize()`, `auth.tokenDetails` reflects the new token. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "token": "new-token", + "expires": now() + 3600000, + "keyName": "appId.keyId", + "clientId": "token-client" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +ASSERT client.auth.tokenDetails IS null # Before authorize + +result = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "new-token" +ASSERT client.auth.tokenDetails.clientId == "token-client" +ASSERT client.auth.tokenDetails == result # Same object +``` + +--- + +## RSA10h - authorize() with authOptions replaces defaults + +**Spec requirement:** `authOptions` in `authorize()` replaces stored auth options. + +Tests that `authOptions` in `authorize()` replaces stored auth options. + +### Setup +```pseudo +original_callback_called = false +new_callback_called = false + +original_callback = (params) => { + original_callback_called = true + RETURN TokenDetails(token: "original", expires: now() + 3600000) +} + +new_callback = (params) => { + new_callback_called = true + RETURN TokenDetails(token: "new", expires: now() + 3600000) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: original_callback)) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + authOptions: AuthOptions(authCallback: new_callback) +) +``` + +### Assertions +```pseudo +ASSERT original_callback_called == false +ASSERT new_callback_called == true +``` + +--- + +## RSA10i - authorize() preserves key from constructor + +**Spec requirement:** The API key from `ClientOptions` is preserved even when `authOptions` are provided. + +Tests that the API key from `ClientOptions` is preserved even when `authOptions` are provided. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.path matches "/keys/.*/requestToken": + # Initial token request using key + req.respond_with(200, { + "token": "token-via-key", + "expires": now() + 3600000, + "keyName": "appId.keyId" + }) + ELSE: + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Call authorize with new authUrl but no key +AWAIT client.auth.authorize( + authOptions: AuthOptions( + authUrl: "https://new-auth.example.com/token" + ) +) + +# The key should still be available for signing +# Implementation can still use key for requestToken +``` + +### Assertions +```pseudo +# Key from constructor should be preserved (not cleared) +# Exact assertion depends on whether auth.key is exposed +# Verify by checking that key-based operations still work +``` + +--- + +## RSA10j - authorize() when already authorized + +**Spec requirement:** Calling `authorize()` when a valid token exists obtains a new token. + +Tests that calling `authorize()` when a valid token exists obtains a new token. + +### Setup +```pseudo +token_count = 0 + +mock_auth_callback = (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-" + str(token_count), + expires: now() + 3600000 + ) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {"channelId": "test"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: mock_auth_callback)) +``` + +### Test Steps +```pseudo +result1 = AWAIT client.auth.authorize() +result2 = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +ASSERT result1.token == "token-1" +ASSERT result2.token == "token-2" +ASSERT client.auth.tokenDetails.token == "token-2" +``` + +--- + +## RSA10k - authorize() with queryTime option + +**Spec requirement:** `queryTime: true` causes time to be queried from server before requesting token. + +Tests that `queryTime: true` causes time to be queried from server. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.path == "/time": + # Time query + req.respond_with(200, { "time": 1234567890000 }) + ELSE: + # Token request + req.respond_with(200, { + "token": "time-synced-token", + "expires": 1234567890000 + 3600000 + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize( + authOptions: AuthOptions(queryTime: true) +) +``` + +### Assertions +```pseudo +# Should have made two requests: time query + token request +time_request = captured_requests.find(r => r.url.path == "/time") +ASSERT time_request IS NOT null +``` + +--- + +## RSA10l - authorize() error handling + +**Spec requirement:** Errors during authorization are properly propagated to the caller. + +Tests that errors during authorization are properly propagated. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + "error": { + "code": 40100, + "statusCode": 401, + "message": "Unauthorized" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "invalid.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.authorize() FAILS WITH error +ASSERT error.code == 40100 +ASSERT error.statusCode == 401 +``` diff --git a/uts/rest/unit/auth/client_id.md b/uts/rest/unit/auth/client_id.md new file mode 100644 index 000000000..7feb90b6a --- /dev/null +++ b/uts/rest/unit/auth/client_id.md @@ -0,0 +1,466 @@ +# Client ID Tests + +Spec points: `RSA7`, `RSA7a`, `RSA7b`, `RSA7c`, `RSA12`, `RSA12a`, `RSA12b`, `RSA15`, `RSA15a`, `RSA15b`, `RSA15c` + +## Test Type +Unit test with mocked HTTP client and/or authCallback + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +--- + +## RSA7a - clientId from ClientOptions + +**Spec requirement:** `clientId` from `ClientOptions` is accessible via `auth.clientId`. + +Tests that `clientId` from `ClientOptions` is accessible via `auth.clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "my-client-id" +)) +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId == "my-client-id" +``` + +--- + +## RSA7b - clientId from TokenDetails + +**Spec requirement:** `clientId` is derived from `TokenDetails` when token auth is used. + +Tests that `clientId` is derived from `TokenDetails` when token auth is used. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + tokenDetails: TokenDetails( + token: "token-with-clientId", + expires: now() + 3600000, + clientId: "token-client-id" + ) +)) +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId == "token-client-id" +``` + +--- + +## RSA7b - clientId from authCallback TokenDetails + +**Spec requirement:** `clientId` is extracted from `TokenDetails` returned by `authCallback`. + +Tests that `clientId` is extracted from `TokenDetails` returned by `authCallback`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "callback-token", + expires: now() + 3600000, + clientId: "callback-client-id" + ) +)) +``` + +### Test Steps +```pseudo +# Trigger auth by making a request +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId == "callback-client-id" +``` + +--- + +## RSA7c - clientId null when unidentified + +**Spec requirement:** `auth.clientId` is null when no client identity is established. + +Tests that `auth.clientId` is null when no client identity is established. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +# No clientId specified +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId IS null +``` + +--- + +## RSA7c - clientId null with unidentified token + +**Spec requirement:** `auth.clientId` is null when token has no `clientId`. + +Tests that `auth.clientId` is null when token has no `clientId`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + tokenDetails: TokenDetails( + token: "token-without-clientId", + expires: now() + 3600000 + # No clientId in token + ) +)) +``` + +### Assertions +```pseudo +ASSERT client.auth.clientId IS null +``` + +--- + +## RSA12a - clientId passed to authCallback in TokenParams + +**Spec requirement:** `clientId` is passed to `authCallback` via `TokenParams`. + +Tests that `clientId` is passed to `authCallback` via `TokenParams`. + +### Setup +```pseudo +received_params = [] + +mock_auth_callback = (params) => { + received_params.append(params) + RETURN TokenDetails(token: "tok", expires: now() + 3600000) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: mock_auth_callback, + clientId: "library-client-id" +)) +``` + +### Test Steps +```pseudo +# Trigger auth +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +ASSERT received_params.length >= 1 +ASSERT received_params[0].clientId == "library-client-id" +``` + +--- + +## RSA12b - clientId sent to authUrl + +**Spec requirement:** `clientId` is sent as a parameter when using `authUrl`. + +Tests that `clientId` is sent as a parameter when using `authUrl`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.url.host == "auth.example.com": + req.respond_with(200, + body: { "token": "url-token", "expires": now() + 3600000 }, + headers: { "Content-Type": "application/json" } + ) + ELSE: + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authUrl: "https://auth.example.com/token", + clientId: "url-client-id" +)) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +auth_request = captured_requests[0] +ASSERT auth_request.url.host == "auth.example.com" + +# clientId should be in query params (GET) or body (POST) +IF auth_request.method == "GET": + ASSERT auth_request.url.query_params["clientId"] == "url-client-id" +ELSE: + body_params = parse_form_urlencoded(auth_request.body) + ASSERT body_params["clientId"] == "url-client-id" +``` + +--- + +## RSA7 - clientId updated after authorize() + +**Spec requirement:** `auth.clientId` is updated when `authorize()` returns a new token with different `clientId`. + +Tests that `auth.clientId` is updated when `authorize()` returns a new token with different `clientId`. + +### Setup +```pseudo +token_count = 0 + +mock_auth_callback = (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-" + str(token_count), + expires: now() + 3600000, + clientId: "client-" + str(token_count) + ) +} + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(authCallback: mock_auth_callback)) +``` + +### Test Steps +```pseudo +# First auth +AWAIT client.channels.get("test").status() + +ASSERT client.auth.clientId == "client-1" + +# Second auth with explicit authorize +AWAIT client.auth.authorize() + +ASSERT client.auth.clientId == "client-2" +``` + +--- + +## RSA12 - Wildcard clientId + +**Spec requirement:** Wildcard `*` clientId allows the token to be used with any client identity. + +Tests handling of wildcard `*` clientId. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + tokenDetails: TokenDetails( + token: "wildcard-token", + expires: now() + 3600000, + clientId: "*" # Wildcard + ) +)) +``` + +### Assertions +```pseudo +# Wildcard clientId should be preserved +ASSERT client.auth.clientId == "*" +``` + +### Note +The wildcard `*` clientId allows the token to be used with any client identity. This is a special case where `clientId` on individual operations can vary. + +--- + +## RSA7 - clientId consistency between ClientOptions and token + +**Spec requirement:** `clientId` in `ClientOptions` must be consistent with token's `clientId` (mismatch is an error). + +Tests that `clientId` in `ClientOptions` is consistent with token's `clientId`. + +### Test Cases + +| ID | ClientOptions clientId | Token clientId | Expected | +|----|----------------------|----------------|----------| +| 1 | `"client-a"` | `"client-a"` | Success | +| 2 | `"client-a"` | `"client-b"` | Error | +| 3 | `"client-a"` | `null` | Success (client keeps explicit) | +| 4 | `"client-a"` | `"*"` | Success (wildcard allows any) | +| 5 | `null` | `"client-b"` | Success (inherit from token) | + +### Setup (Case 2 - Mismatch should error) +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + clientId: "client-a", + tokenDetails: TokenDetails( + token: "mismatched-token", + expires: now() + 3600000, + clientId: "client-b" # Different from ClientOptions + ) +)) +``` + +### Test Steps (Case 2) +```pseudo +AWAIT client.channels.get("test").status() FAILS WITH error # Or any operation requiring auth +ASSERT error.message CONTAINS "clientId" OR error.message CONTAINS "mismatch" +``` + +### Note +The exact timing of mismatch detection (constructor vs first use) may vary by implementation. The key requirement is that the mismatch is detected and reported as an error. + +--- + +## RSA15a - Token clientId must match ClientOptions clientId + +**Spec requirement:** Any `clientId` provided in `ClientOptions` must match any non-wildcard `clientId` value in `TokenDetails`. + +This is tested by the RSA7 consistency test above (cases 1 and 2). When Token Auth is used and both `ClientOptions.clientId` and `TokenDetails.clientId` are set to non-wildcard values, they must match. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +# Matching case — should succeed +client_match = Rest(options: ClientOptions( + clientId: "my-client", + tokenDetails: TokenDetails( + token: "matching-token", + expires: now() + 3600000, + clientId: "my-client" + ) +)) + +# Mismatching case — should error +ASSERT Rest(options: ClientOptions( + clientId: "my-client", + tokenDetails: TokenDetails( + token: "mismatched-token", + expires: now() + 3600000, + clientId: "other-client" + ) +)) THROWS error +``` + +### Assertions +```pseudo +ASSERT client_match.auth.clientId == "my-client" +ASSERT error.code == 40102 +``` + +--- + +## RSA15b - Wildcard token clientId permits any ClientOptions clientId + +**Spec requirement:** If the `clientId` from `TokenDetails` is a wildcard string `'*'`, then the client is permitted to be either unidentified or identified by providing a `clientId`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "channelId": "test", "status": { "isActive": true } }) + } +) +install_mock(mock_http) + +# Wildcard token with explicit clientId — should succeed +client = Rest(options: ClientOptions( + clientId: "any-client", + tokenDetails: TokenDetails( + token: "wildcard-token", + expires: now() + 3600000, + clientId: "*" + ) +)) +``` + +### Assertions +```pseudo +# No error thrown — wildcard allows any clientId +ASSERT client.auth.clientId == "any-client" +``` + +--- + +## RSA15c - Incompatible clientId results in error (REST) or FAILED (Realtime) + +**Spec requirement:** Following an auth request which uses a `TokenDetails` that contains an incompatible `clientId`, the library should in the case of REST result in an appropriate error response, and in the case of Realtime transition the connection state to `FAILED`. + +### REST case + +See RSA15a mismatch case above — the REST client raises an error with code 40102. + +### Realtime case + +See `realtime/integration/auth.md` RSA7 test — the Realtime client transitions to FAILED state when a token with a mismatched clientId is used. diff --git a/uts/rest/unit/auth/revoke_tokens.md b/uts/rest/unit/auth/revoke_tokens.md new file mode 100644 index 000000000..8d63b66e1 --- /dev/null +++ b/uts/rest/unit/auth/revoke_tokens.md @@ -0,0 +1,705 @@ +# Revoke Tokens Tests + +Spec points: `RSA17`, `RSA17b`, `RSA17c`, `RSA17d`, `RSA17e`, `RSA17f`, `RSA17g`, `BAR2`, `TRS2`, `TRF2` + +## 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. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Server Response Format + +The server returns per-target results as an array. Each element is either a success +(with `target`, `issuedBefore`, `appliesAt`) or a failure (with `target`, `error`). + +On success (HTTP 2xx), the response body is a plain array: +```json +[ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "clientId:bob", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } +] +``` + +On mixed success/failure (HTTP 400), the response wraps the array: +```json +{ + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "..." } } + ] +} +``` + +The client computes `successCount` and `failureCount` from the per-target results. + +These unit tests mock responses as plain arrays (the success format). The client +must handle both formats, but unit tests focus on parsing and request formation. + +--- + +## RSA17g - revokeTokens sends POST to /keys/{keyName}/revokeTokens + +**Spec requirement:** `Auth#revokeTokens` takes a `TokenRevocationTargetSpecifier` or +an array of `TokenRevocationTargetSpecifier`s and sends them in a POST request to +`/keys/{API_KEY_NAME}/revokeTokens`, where `API_KEY_NAME` is the API key name +obtained by reading `AuthOptions#key` up until the first `:` character. + +### RSA17g_1 - Sends POST request to correct path + +**Spec requirement:** revokeTokens sends a POST request to `/keys/{keyName}/revokeTokens`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "POST" +ASSERT captured_requests[0].url.path == "/keys/appId.keyName/revokeTokens" +``` + +--- + +## RSA17b - Target specifiers mapped to type:value strings + +**Spec requirement:** The `TokenRevocationTargetSpecifier`s should be mapped to +strings by joining the `type` and `value` with a `:` character and sent in the +`targets` field of the request body. + +### RSA17b_1 - Single specifier sent as targets array + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["targets"] == ["clientId:alice"] +``` + +### RSA17b_2 - Multiple specifiers with different types + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "revocationKey:group-1", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "channel:secret", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice"), + TokenRevocationTargetSpecifier(type: "revocationKey", value: "group-1"), + TokenRevocationTargetSpecifier(type: "channel", value: "secret") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["targets"] == ["clientId:alice", "revocationKey:group-1", "channel:secret"] +``` + +--- + +## RSA17c - Returns BatchResult + +| Spec | Requirement | +|------|-------------| +| RSA17c | Returns a `BatchResult` | +| BAR2a | `successCount` - the number of successful operations | +| BAR2b | `failureCount` - the number of unsuccessful operations | +| BAR2c | `results` - an array of results | + +### RSA17c_1 - All success result + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "clientId:bob", "issuedBefore": 1700000000000, "appliesAt": 1700000002000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice"), + TokenRevocationTargetSpecifier(type: "clientId", value: "bob") +]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 +``` + +### RSA17c_2 - Mixed success and failure result + +**Spec requirement:** When the server returns a mix of successes and failures, +the response is HTTP 400 with a `batchResponse` array. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, + { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } + ] + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice"), + TokenRevocationTargetSpecifier(type: "invalidType", value: "abc") +]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 1 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 2 +``` + +### RSA17c_3 - All failure result + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { "target": "invalidType:foo", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } }, + { "target": "invalidType:bar", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } + ] + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "invalidType", value: "foo"), + TokenRevocationTargetSpecifier(type: "invalidType", value: "bar") +]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 0 +ASSERT result.failureCount == 2 +ASSERT result.results.length == 2 +``` + +--- + +## TRS2 - TokenRevocationSuccessResult attributes + +| Spec | Requirement | +|------|-------------| +| TRS2a | `target` string - the target specifier | +| TRS2b | `appliesAt` Time - timestamp at which the revocation takes effect | +| TRS2c | `issuedBefore` Time - timestamp for which previously issued tokens are revoked | + +### TRS2_1 - Success result contains target, appliesAt, and issuedBefore + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +success = result.results[0] +ASSERT success IS TokenRevocationSuccessResult +ASSERT success.target == "clientId:alice" +ASSERT success.issuedBefore == 1700000000000 +ASSERT success.appliesAt == 1700000001000 +``` + +--- + +## TRF2 - TokenRevocationFailureResult attributes + +| Spec | Requirement | +|------|-------------| +| TRF2a | `target` string - the target specifier | +| TRF2b | `error` ErrorInfo - reason the revocation failed | + +### TRF2_1 - Failure result contains target and error + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } + ] + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "invalidType", value: "abc") +]) +``` + +### Assertions +```pseudo +failure = result.results[0] +ASSERT failure IS TokenRevocationFailureResult +ASSERT failure.target == "invalidType:abc" +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.code == 40000 +ASSERT failure.error.statusCode == 400 +ASSERT failure.error.message CONTAINS "Invalid target type" +``` + +--- + +## RSA17d - Token auth clients cannot revoke tokens + +**Spec requirement:** If called from a client using token authentication, should +raise an `ErrorInfo` with a `40162` error code and `401` status code. This is a +client-side check — no HTTP request is made. + +### RSA17d_1 - Token auth client fails with 40162 + +### 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(token: "a.token.string")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40162 +ASSERT error.statusCode == 401 + +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +### RSA17d_2 - Token auth via useTokenAuth flag fails with 40162 + +### 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.keyName:keySecret", useTokenAuth: true)) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40162 +ASSERT error.statusCode == 401 +ASSERT captured_requests.length == 0 +``` + +--- + +## RSA17e - Optional issuedBefore parameter + +**Spec requirement:** Accepts an optional `issuedBefore` timestamp, represented as +milliseconds since the epoch, which is included in the request body. + +### RSA17e_1 - issuedBefore included in 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, [ + { "target": "clientId:alice", "issuedBefore": 1699999000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: "alice")], + options: { issuedBefore: 1699999000000 } +) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["issuedBefore"] == 1699999000000 +``` + +### RSA17e_2 - issuedBefore omitted when not provided + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT "issuedBefore" NOT IN request_body +``` + +--- + +## RSA17f - Optional allowReauthMargin parameter + +**Spec requirement:** If an `allowReauthMargin` boolean is supplied, it should be +included in the `allowReauthMargin` field of the request body. + +### RSA17f_1 - allowReauthMargin included when true + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000030000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: "alice")], + options: { allowReauthMargin: true } +) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["allowReauthMargin"] == true +``` + +### RSA17f_2 - allowReauthMargin omitted when not provided + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT "allowReauthMargin" NOT IN request_body +``` + +### RSA17f_3 - Both issuedBefore and allowReauthMargin together + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1699999000000, "appliesAt": 1700000030000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: "alice")], + options: { issuedBefore: 1699999000000, allowReauthMargin: true } +) +``` + +### Assertions +```pseudo +request_body = JSON_PARSE(captured_requests[0].body) +ASSERT request_body["targets"] == ["clientId:alice"] +ASSERT request_body["issuedBefore"] == 1699999000000 +ASSERT request_body["allowReauthMargin"] == true +``` + +--- + +## Error handling + +### RSA17_Error_1 - Server error is propagated as an error + +**Spec requirement:** A server-level error (e.g. 500) for the entire request +is propagated as an error, not a per-target failure. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + "error": { "code": 50000, "statusCode": 500, "message": "Internal error" } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 50000 +ASSERT error.statusCode == 500 +``` + +--- + +## Request authentication + +### RSA17_Auth_1 - Request uses Basic authentication + +**Spec requirement:** revokeTokens requires key-based auth (RSA17d rejects token +auth). The POST request uses the client's configured Basic authentication. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyName:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "alice") +]) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].headers["Authorization"] STARTS WITH "Basic " +``` diff --git a/uts/rest/unit/auth/token_details.md b/uts/rest/unit/auth/token_details.md new file mode 100644 index 000000000..9d01dba99 --- /dev/null +++ b/uts/rest/unit/auth/token_details.md @@ -0,0 +1,593 @@ +# Auth.tokenDetails Tests + +Spec points: `RSA16`, `RSA16a`, `RSA16b`, `RSA16c`, `RSA16d` + +## Test Type +Unit test with mocked HTTP client and/or mocked authCallback + +## Overview + +`Auth#tokenDetails` is a property that holds the `TokenDetails` representing the token currently in use by the library. These tests verify: +- It holds the current token when using token auth +- It handles tokens provided as strings (without full TokenDetails) +- It is updated on authorize() and library-initiated renewals +- It is null when using basic auth or when no valid token exists + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSA16a - tokenDetails holds current token + +**Spec requirement:** `Auth#tokenDetails` holds a `TokenDetails` representing the token currently in use by the library, if any. + +### Test: tokenDetails reflects token from authCallback + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "callback-token-abc", + expires: now() + 3600000, + issued: now(), + clientId: "my-client" + ) +)) +``` + +#### Test Steps +```pseudo +# Force token acquisition by making a request +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "callback-token-abc" +ASSERT client.auth.tokenDetails.clientId == "my-client" +ASSERT client.auth.tokenDetails.expires IS NOT null +ASSERT client.auth.tokenDetails.issued IS NOT null +``` + +--- + +### Test: tokenDetails reflects token from requestToken + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.path matches "/keys/.*/requestToken": + req.respond_with(200, { + "token": "requested-token-xyz", + "expires": now() + 3600000, + "issued": now(), + "keyName": "appId.keyId", + "clientId": "token-client" + }) + ELSE: + req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +# Explicitly authorize to get a token +AWAIT client.auth.authorize() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "requested-token-xyz" +ASSERT client.auth.tokenDetails.clientId == "token-client" +``` + +--- + +## RSA16b - tokenDetails with token string only + +**Spec requirement:** If the library is provided with a token without the corresponding `TokenDetails`, then `tokenDetails` holds a `TokenDetails` instance in which only the `token` attribute is populated with that token string. + +### Test: tokenDetails created from token string in ClientOptions + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +# Provide only a token string, not full TokenDetails +client = Rest(options: ClientOptions(token: "standalone-token-string")) +``` + +#### Test Steps +```pseudo +# Access tokenDetails immediately after construction +token_details = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT token_details IS NOT null +ASSERT token_details.token == "standalone-token-string" +# Other fields should be null since we only had the token string +ASSERT token_details.expires IS null +ASSERT token_details.issued IS null +ASSERT token_details.clientId IS null +ASSERT token_details.capability IS null +``` + +--- + +### Test: tokenDetails created from token string in authCallback + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +# authCallback returns just a token string, not TokenDetails +client = Rest(options: ClientOptions( + authCallback: (params) => "just-a-token-string" +)) +``` + +#### Test Steps +```pseudo +# Force token acquisition +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "just-a-token-string" +# Other fields should be null +ASSERT client.auth.tokenDetails.expires IS null +ASSERT client.auth.tokenDetails.issued IS null +``` + +--- + +## RSA16c - tokenDetails updated on token changes + +**Spec requirement:** `tokenDetails` is set with the current token (if applicable) on instantiation and each time it is replaced, whether the result of an explicit `Auth#authorize` operation, or a library-initiated renewal resulting from expiry or a token error response. + +### Test: tokenDetails set on instantiation with tokenDetails option + +#### Setup +```pseudo +initial_token = TokenDetails( + token: "initial-token", + expires: now() + 3600000, + issued: now(), + clientId: "initial-client" +) + +client = Rest(options: ClientOptions(tokenDetails: initial_token)) +``` + +#### Test Steps +```pseudo +# Access tokenDetails immediately after construction +token_details = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT token_details IS NOT null +ASSERT token_details.token == "initial-token" +ASSERT token_details.clientId == "initial-client" +``` + +--- + +### Test: tokenDetails updated after explicit authorize() + +#### Setup +```pseudo +token_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-v" + str(token_count), + expires: now() + 3600000, + clientId: "client-v" + str(token_count) + ) + } +)) +``` + +#### Test Steps +```pseudo +# First authorize +AWAIT client.auth.authorize() +first_token = client.auth.tokenDetails + +# Second authorize +AWAIT client.auth.authorize() +second_token = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT first_token.token == "token-v1" +ASSERT first_token.clientId == "client-v1" + +ASSERT second_token.token == "token-v2" +ASSERT second_token.clientId == "client-v2" + +# Verify it's actually updated, not the same object +ASSERT first_token.token != second_token.token +``` + +--- + +### Test: tokenDetails updated after library-initiated renewal on expiry + +#### Setup +```pseudo +test_clock = TestClock() +token_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +WITH_CLOCK(test_clock): + client = Rest(options: ClientOptions( + authCallback: (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-v" + str(token_count), + expires: test_clock.now() + 1000, # Expires in 1 second + clientId: "client-v" + str(token_count) + ) + } + )) +``` + +#### Test Steps +```pseudo +WITH_CLOCK(test_clock): + # First request - gets initial token + AWAIT client.channels.get("test").status() + first_token = client.auth.tokenDetails + + # Advance time past token expiry + test_clock.advance(2000 milliseconds) + + # Second request - should trigger renewal + AWAIT client.channels.get("test").status() + second_token = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT first_token.token == "token-v1" +ASSERT second_token.token == "token-v2" +``` + +--- + +### Test: tokenDetails updated after library-initiated renewal on 40142 error + +#### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count = request_count + 1 + IF request_count == 1: + # First request fails with token expired error + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + ELSE: + # Subsequent requests succeed + req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) + } +) +install_mock(mock_http) + +token_count = 0 + +client = Rest(options: ClientOptions( + authCallback: (params) => { + token_count = token_count + 1 + RETURN TokenDetails( + token: "token-v" + str(token_count), + expires: now() + 3600000, + clientId: "client-v" + str(token_count) + ) + } +)) +``` + +#### Test Steps +```pseudo +# First get a token +AWAIT client.auth.authorize() +first_token = client.auth.tokenDetails + +# Make a request that will fail with 40142, triggering renewal +AWAIT client.channels.get("test").status() +second_token = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +ASSERT first_token.token == "token-v1" +ASSERT second_token.token == "token-v2" +``` + +--- + +## RSA16d - tokenDetails is null when appropriate + +**Spec requirement:** `tokenDetails` is `null` if there is no current token, including after a previous token has been determined to be invalid or expired, or if the library is using basic auth. + +### Test: tokenDetails is null when using basic auth + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +# Client with only API key - uses basic auth +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +# Make a request using basic auth (no token) +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +# Should be null because we're using basic auth, not token auth +ASSERT client.auth.tokenDetails IS null +``` + +--- + +### Test: tokenDetails is null before any token is obtained + +#### Setup +```pseudo +# Client configured for token auth but no request made yet +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "my-token", + expires: now() + 3600000 + ) +)) +``` + +#### Test Steps +```pseudo +# Don't make any requests - just check tokenDetails +token_details = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +# Should be null because no token has been obtained yet +ASSERT token_details IS null +``` + +--- + +### Test: tokenDetails is null after token invalidation + +**Note:** This test verifies behavior when a token error occurs and cannot be renewed (e.g., authCallback fails). + +#### Setup +```pseudo +callback_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + # Always fail with token error + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => { + callback_count = callback_count + 1 + IF callback_count == 1: + RETURN TokenDetails(token: "first-token", expires: now() + 3600000) + ELSE: + # Second callback fails - cannot renew + THROW AblyException("Cannot obtain new token") + } +)) +``` + +#### Test Steps +```pseudo +# First authorize succeeds +AWAIT client.auth.authorize() +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.token == "first-token" + +# Make a request that fails with 40142 +# Renewal will be attempted but will fail +AWAIT client.channels.get("test").status() FAILS WITH error +# Expected to fail - error is expected +``` + +#### Assertions +```pseudo +# After failed renewal, tokenDetails should be null +# (the old token is invalid and we couldn't get a new one) +ASSERT client.auth.tokenDetails IS null +``` + +--- + +### Test: tokenDetails is null after switching from token to basic auth + +**Note:** This tests the case where a client is reconfigured to use basic auth after having used token auth. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "my-token", + expires: now() + 3600000 + ) +)) +``` + +#### Test Steps +```pseudo +# First use token auth +AWAIT client.auth.authorize() +ASSERT client.auth.tokenDetails IS NOT null + +# Now authorize with basic auth (providing key in authOptions) +AWAIT client.auth.authorize( + authOptions: AuthOptions( + key: "appId.keyId:keySecret", + useTokenAuth: false + ) +) +``` + +#### Assertions +```pseudo +# After switching to basic auth, tokenDetails should be null +ASSERT client.auth.tokenDetails IS null +``` + +--- + +## Edge Cases + +### Test: tokenDetails preserved across multiple successful requests + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "stable-token", + expires: now() + 3600000, + clientId: "stable-client" + ) +)) +``` + +#### Test Steps +```pseudo +# Make multiple requests +AWAIT client.channels.get("test").status() +first_check = client.auth.tokenDetails + +AWAIT client.channels.get("test").status() +second_check = client.auth.tokenDetails + +AWAIT client.channels.get("test").status() +third_check = client.auth.tokenDetails +``` + +#### Assertions +```pseudo +# Token should remain the same across requests (not re-fetched) +ASSERT first_check.token == "stable-token" +ASSERT second_check.token == "stable-token" +ASSERT third_check.token == "stable-token" +``` + +--- + +### Test: tokenDetails reflects capability from token + +#### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"channelId": "test", "status": {"isActive": true}}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: (params) => TokenDetails( + token: "capable-token", + expires: now() + 3600000, + capability: '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}' + ) +)) +``` + +#### Test Steps +```pseudo +AWAIT client.channels.get("test").status() +``` + +#### Assertions +```pseudo +ASSERT client.auth.tokenDetails IS NOT null +ASSERT client.auth.tokenDetails.capability == '{"channel1":["publish","subscribe"],"channel2":["subscribe"]}' +``` diff --git a/uts/rest/unit/auth/token_renewal.md b/uts/rest/unit/auth/token_renewal.md new file mode 100644 index 000000000..609a54e7e --- /dev/null +++ b/uts/rest/unit/auth/token_renewal.md @@ -0,0 +1,579 @@ +# Token Renewal Tests + +Spec points: `RSA4a2`, `RSA4b`, `RSA4b1`, `RSC10` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +These tests verify that the library correctly handles token expiry and triggers renewal when: +1. A token is known to be expired before a request +2. A request is rejected by the server due to token expiry + +--- + +## RSA4b - Token renewal on expiry rejection + +**Spec requirement:** When a request is rejected with error code 40142 (token expired), the library must obtain a new token via the auth callback and retry the request automatically. + +Tests that when a request is rejected with a token expiry error, the library obtains a new token and retries. + +### Setup +```pseudo +callback_count = 0 +tokens = ["first-token", "second-token"] +captured_requests = [] +request_count = 0 + +auth_callback = FUNCTION(params): + token = tokens[callback_count] + callback_count = callback_count + 1 + RETURN TokenDetails( + token: token, + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + IF request_count == 1: + # First request fails with token expired + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + ELSE: + # Second request (after renewal) succeeds + req.respond_with(200, [{"channel": "test"}]) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +# authCallback was called twice (initial + renewal) +ASSERT callback_count == 2 + +# Two HTTP requests were made +ASSERT request_count == 2 + +# First request used first token +ASSERT captured_requests[0].headers["Authorization"] == "Bearer first-token" + +# Second request used renewed token +ASSERT captured_requests[1].headers["Authorization"] == "Bearer second-token" + +# Final result is successful +ASSERT result.items IS List +``` + +--- + +## RSA4b - Token renewal on 40140 error + +**Spec requirement:** Token renewal must also be triggered for error code 40140 (token error), not just 40142 (token expired). + +Tests renewal is triggered for error code 40140 (token error). + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + callback_count, + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + # First attempt fails with 40140 + req.respond_with(401, { + "error": { + "code": 40140, + "statusCode": 401, + "message": "Token error" + } + }) + ELSE: + # Retry succeeds + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +ASSERT callback_count == 2 +ASSERT request_count == 2 +``` + +--- + +## RSA4b1 - Pre-emptive token renewal + +**Spec requirement:** If a token is known to be expired before making a request, renewal must happen pre-emptively without first making a failing request. + +Tests that if a token is known to be expired before making a request, renewal happens without first making a failing request. + +### Setup +```pseudo +callback_count = 0 +captured_requests = [] + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + IF callback_count == 1: + # First token is already expired + RETURN TokenDetails( + token: "expired-token", + expires: now() - 1000 # Already expired + ) + ELSE: + RETURN TokenDetails( + token: "fresh-token", + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + # Only success response (no 401 expected) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +# Force initial token acquisition +AWAIT client.auth.authorize() + +# This should detect expired token and renew before request +AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +# Callback was called twice (initial + pre-emptive renewal) +ASSERT callback_count == 2 + +# Only ONE HTTP request to the API (history) +# No failed request with expired token +requests_to_history = captured_requests.filter( + r => r.path == "/channels/test/messages" +) +ASSERT requests_to_history.length == 1 +ASSERT requests_to_history[0].headers["Authorization"] == "Bearer fresh-token" +``` + +--- + +## RSA4a2 - No renewal without authCallback + +**Spec requirement:** Token renewal is not attempted if no renewal mechanism (authCallback/authUrl/key) is available. + +Tests that token renewal is not attempted if no renewal mechanism is available. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + } +) +install_mock(mock_http) + +# Client with explicit token but no authCallback +client = Rest( + options: ClientOptions(token: "static-token") +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() FAILS WITH error +ASSERT error.code == 40171 +``` + +### Assertions +```pseudo +# Only one request was made (no retry) +ASSERT request_count == 1 +``` + +--- + +## RSA4b - Renewal with authUrl + +**Spec requirement:** Token renewal must work via authUrl when a request is rejected with error code 40142. + +Tests that token renewal works via authUrl. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + + IF req.url.host == "example.com": + # authUrl requests - return tokens + IF request_count == 1: + req.respond_with(200, { + "token": "first-token", + "expires": now() + 3600000 + }) + ELSE: + # Second token request (renewal) + req.respond_with(200, { + "token": "second-token", + "expires": now() + 3600000 + }) + ELSE: + # API requests + IF request_count == 2: + # First API request fails + req.respond_with(401, { + "error": {"code": 40142, "message": "Token expired"} + }) + ELSE: + # Retry succeeds + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authUrl: "https://example.com/auth" + ) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() +``` + +### Assertions +```pseudo +# Two requests to authUrl +auth_requests = captured_requests.filter( + r => r.url.host == "example.com" +) +ASSERT auth_requests.length == 2 + +# Two requests to Ably API +api_requests = captured_requests.filter( + r => r.url.host != "example.com" +) +ASSERT api_requests.length == 2 + +# Second API request used renewed token +ASSERT api_requests[1].headers["Authorization"] == "Bearer second-token" +``` + +--- + +## RSA4b - Renewal limit + +**Spec requirement:** Token renewal must not loop infinitely if server keeps rejecting tokens. + +Tests that token renewal doesn't loop infinitely if server keeps rejecting. + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + callback_count, + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + # Always return token expired + req.respond_with(401, { + "error": {"code": 40142, "message": "Token expired"} + }) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").history() FAILS WITH error +# Should eventually give up +ASSERT error.code == 40142 +``` + +### Assertions +```pseudo +# The library MUST retry at most once per original request (one renewal +# attempt). After the renewed token is also rejected, the error is +# propagated to the caller. +ASSERT callback_count == 2 # Initial token + one renewal +ASSERT request_count == 2 # Original request + one retry +``` + +--- + +## RSC10 - REST request retried after token renewal + +**Spec requirement:** If a REST request responds with a token error (401 HTTP status code and an Ably error value 40140 <= code < 40150), then the Auth class is responsible for reissuing a token and the request should be reattempted. + +This test verifies the end-to-end flow at the HTTP client level: the original REST API call is transparently retried after the token is renewed, and the caller receives the successful result without knowing a renewal occurred. + +Note: The RSA4b tests above verify the auth renewal mechanism in isolation. This RSC10 test verifies the HTTP client's retry behaviour wrapping that mechanism. + +### Setup +```pseudo +callback_count = 0 +captured_requests = [] + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(callback_count), + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + IF req.headers["Authorization"] == "Bearer token-1": + # First token is rejected + req.respond_with(401, { + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }) + ELSE: + # Renewed token succeeds — return channel status + req.respond_with(200, { + "channelId": "test", + "status": {"isActive": true, "occupancy": {"metrics": {"connections": 0}}} + }) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +# Call channel.status() — the caller should not see the 401/renewal +result = AWAIT client.channels.get("test").status() +``` + +### Assertions +```pseudo +# The call succeeded transparently +ASSERT result IS ChannelDetails + +# Two HTTP requests were made to /channels/test (original + retry) +channel_requests = captured_requests.filter(r => r.path == "/channels/test") +ASSERT channel_requests.length == 2 + +# Auth callback was called twice (initial token + renewal) +ASSERT callback_count == 2 + +# First request used first token, second used renewed token +ASSERT channel_requests[0].headers["Authorization"] == "Bearer token-1" +ASSERT channel_requests[1].headers["Authorization"] == "Bearer token-2" +``` + +--- + +## RSC10 - Non-token 401 errors MUST NOT trigger token renewal + +**Spec requirement:** Only errors with codes in the range 40140–40149 trigger token renewal. Other 401 errors (e.g. 40100 Unauthorized) MUST be propagated immediately without any renewal or retry attempt. + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(callback_count), + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count = request_count + 1 + # Return a 401 with a non-token error code + req.respond_with(401, { + "error": { + "code": 40100, + "statusCode": 401, + "message": "Unauthorized" + } + }) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions(authCallback: auth_callback) +) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").status() FAILS WITH error +ASSERT error.code == 40100 +``` + +### Assertions +```pseudo +# Only one HTTP request — no retry +ASSERT request_count == 1 + +# Auth callback was called once (initial token only, no renewal) +ASSERT callback_count == 1 +``` + +--- + +## RSA4b - Token renewal with MessagePack error response + +**Spec requirement:** Token renewal must work correctly when the server returns the 401 token-error response in MessagePack format (which is the default when `useBinaryProtocol: true`). The SDK must decode the msgpack error body to extract the token-error code (40140–40149) and trigger renewal. + +### Setup +```pseudo +callback_count = 0 +request_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(callback_count), + expires: now() + 3600000 + ) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count = request_count + 1 + IF request_count == 1: + # First request fails with token expired — returned as msgpack + req.respond_with(401, + body: msgpack_encode({ + "error": { + "code": 40142, + "statusCode": 401, + "message": "Token expired" + } + }), + headers: { "Content-Type": "application/x-msgpack" } + ) + ELSE: + # Retry succeeds — also returned as msgpack + req.respond_with(200, + body: msgpack_encode([1234567890000]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest( + options: ClientOptions( + authCallback: auth_callback, + useBinaryProtocol: true # Default — msgpack + ) +) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Auth callback was called twice (initial + renewal) +ASSERT callback_count == 2 + +# Two HTTP requests were made (original + retry) +ASSERT request_count == 2 + +# Result is successful +ASSERT result == 1234567890000 +``` diff --git a/uts/rest/unit/auth/token_request_params.md b/uts/rest/unit/auth/token_request_params.md new file mode 100644 index 000000000..ea9f89db6 --- /dev/null +++ b/uts/rest/unit/auth/token_request_params.md @@ -0,0 +1,218 @@ +# Token Request Parameter Defaults + +Spec points: `RSA5`, `RSA6`, `RSA9` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +## Purpose + +Tests the handling of `ttl` and `capability` parameters in `createTokenRequest()`. +The spec requires that when these values are not provided by the user, they must be +**null** in the token request rather than defaulted client-side. This allows Ably to +apply its own server-side defaults (60 minute TTL, key capabilities). + +**Portability note:** The `ttl` and `capability` fields on `TokenRequest` must be +nullable types (e.g. `int?` / `String?` in Dart, `Integer` / `String` in Java, +`*int` / `*string` in Go). This allows implementations to distinguish "not specified" +(null) from an explicit value, and to omit null fields during serialization. + +--- + +## RSA5 - TTL is null when not specified + +**Spec requirement:** TTL for new tokens is specified in milliseconds. If the user-provided `tokenParams` does not specify a TTL, the TTL field MUST be null (or the equivalent absent/unset value) in the `tokenRequest`, and Ably will supply a token with a TTL of 60 minutes. Implementations MUST NOT default this to 3600000 client-side. + +Tests that `createTokenRequest()` without explicit TTL produces a token request +with a null `ttl`, rather than a client-side default like 3600000. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +# TTL should be null (not zero, not a default like 3600000) +ASSERT token_request.ttl IS null +``` + +--- + +## RSA5b - Explicit TTL is preserved + +**Spec requirement:** When `tokenParams` specifies a TTL, it must be included in the token request. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(ttl: 7200000) # 2 hours +) +``` + +### Assertions +```pseudo +ASSERT token_request.ttl == 7200000 +``` + +--- + +## RSA5c - TTL from defaultTokenParams is used + +**Spec requirement:** TTL from `ClientOptions.defaultTokenParams` should be used when no explicit TTL is provided to `createTokenRequest()`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(ttl: 1800000) # 30 minutes +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +ASSERT token_request.ttl == 1800000 +``` + +--- + +## RSA5d - Explicit TTL overrides defaultTokenParams + +**Spec requirement:** An explicit TTL in `tokenParams` takes precedence over `defaultTokenParams`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(ttl: 1800000) # 30 minutes +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(ttl: 600000) # 10 minutes +) +``` + +### Assertions +```pseudo +ASSERT token_request.ttl == 600000 +``` + +--- + +## RSA6 - Capability is null when not specified + +**Spec requirement:** The `capability` for new tokens is JSON stringified. If the user-provided `tokenParams` does not specify capabilities, the `capability` field MUST be null (or the equivalent absent/unset value) in the `tokenRequest`, and Ably will supply a token with the capabilities of the underlying key. Implementations MUST NOT default this to '{"*":["*"]}' client-side. + +Tests that `createTokenRequest()` without explicit capability produces a token +request with a null `capability`, rather than a client-side default like `{"*":["*"]}`. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +# Capability should be null (not a default like '{"*":["*"]}') +ASSERT token_request.capability IS null +``` + +--- + +## RSA6b - Explicit capability is preserved + +**Spec requirement:** When `tokenParams` specifies a capability, it must be included in the token request as a JSON string. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(capability: '{"channel-a":["publish","subscribe"]}') +) +``` + +### Assertions +```pseudo +ASSERT token_request.capability == '{"channel-a":["publish","subscribe"]}' +``` + +--- + +## RSA6c - Capability from defaultTokenParams is used + +**Spec requirement:** Capability from `ClientOptions.defaultTokenParams` should be used when no explicit capability is provided to `createTokenRequest()`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(capability: '{"*":["subscribe"]}') +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest() +``` + +### Assertions +```pseudo +ASSERT token_request.capability == '{"*":["subscribe"]}' +``` + +--- + +## RSA6d - Explicit capability overrides defaultTokenParams + +**Spec requirement:** An explicit capability in `tokenParams` takes precedence over `defaultTokenParams`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams(capability: '{"*":["subscribe"]}') +)) +``` + +### Test Steps +```pseudo +token_request = AWAIT client.auth.createTokenRequest( + tokenParams: TokenParams(capability: '{"channel-x":["publish"]}') +) +``` + +### Assertions +```pseudo +ASSERT token_request.capability == '{"channel-x":["publish"]}' +``` diff --git a/uts/rest/unit/batch_presence.md b/uts/rest/unit/batch_presence.md new file mode 100644 index 000000000..523df822f --- /dev/null +++ b/uts/rest/unit/batch_presence.md @@ -0,0 +1,449 @@ +# Batch Presence Tests + +Tests for `RestClient#batchPresence` (RSC24) and related types (BAR*, BGR*, BGF*). + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## Server Response Format + +The server returns different formats depending on the outcome: +- **All success (HTTP 200):** Plain array of per-channel results: `[{channel, presence}, ...]` +- **Mixed/all failure (HTTP 400):** Wrapper with error and batch results: + `{error: {code: 40020, ...}, batchResponse: [{channel, presence/error}, ...]}` +- **Server error (HTTP 500, 401, etc.):** Error object only: `{error: {code, ...}}` + +The SDK normalises both success and mixed/failure formats into a +`BatchPresenceResponse` with computed `successCount`, `failureCount`, and `results`. + +--- + +## RSC24 - batchPresence sends GET to /presence + +**Spec requirement:** `RestClient#batchPresence` takes an array of channel name strings +and sends them as a comma separated string in the `channels` query parameter in a GET +request to `/presence`, returning a `BatchPresenceResponse` containing per-channel results. + +### RSC24_1 - Sends GET request to /presence with channels query param + +**Spec requirement:** batchPresence sends a GET request to `/presence` with channel +names joined as a comma-separated `channels` query parameter. + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: [ + { "channel": "channel-a", "presence": [] }, + { "channel": "channel-b", "presence": [] } + ]) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["channel-a", "channel-b"]) + +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].url.path == "/presence" +ASSERT captured_requests[0].url.queryParameters["channels"] == "channel-a,channel-b" +``` + +### RSC24_2 - Single channel sends GET with single channel name + +**Spec requirement:** batchPresence with a single channel sends the channel name in +the `channels` query parameter (no trailing comma). + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: [ + { "channel": "my-channel", "presence": [] } + ]) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["my-channel"]) + +ASSERT captured_requests[0].url.queryParameters["channels"] == "my-channel" +``` + +### RSC24_3 - Channel names with special characters are comma-joined + +**Spec requirement:** Channel names containing special characters are joined with +commas as-is (the server handles parsing). + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: [ + { "channel": "foo:bar", "presence": [] }, + { "channel": "baz/qux", "presence": [] } + ]) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["foo:bar", "baz/qux"]) + +ASSERT captured_requests[0].url.queryParameters["channels"] == "foo:bar,baz/qux" +``` + +--- + +## BAR2 - BatchPresenceResponse structure + +**Spec requirement:** The response is normalised into a `BatchPresenceResponse` with +computed `successCount`, `failureCount`, and `results` attributes (BAR2). + +### BAR2_1 - successCount and failureCount computed from mixed response + +The server returns HTTP 400 with `batchResponse` for mixed results. The SDK +computes `successCount` and `failureCount` from the per-channel results. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 400, body: { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { "channel": "ch-1", "presence": [] }, + { "channel": "ch-2", "presence": [] }, + { "channel": "ch-3", "presence": [] }, + { "channel": "ch-4", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["ch-1", "ch-2", "ch-3", "ch-4"]) + +ASSERT result.successCount == 3 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 4 +``` + +### BAR2_2 - All success + +The server returns HTTP 200 with a plain array when all channels succeed. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: [ + { "channel": "ch-a", "presence": [] }, + { "channel": "ch-b", "presence": [] } + ]) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["ch-a", "ch-b"]) + +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 +``` + +### BAR2_3 - All failure + +The server returns HTTP 400 with `batchResponse` when all channels fail. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 400, body: { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { "channel": "ch-a", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } }, + { "channel": "ch-b", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["ch-a", "ch-b"]) + +ASSERT result.successCount == 0 +ASSERT result.failureCount == 2 +ASSERT result.results.length == 2 +``` + +--- + +## BGR2 - BatchPresenceSuccessResult structure + +**Spec requirement:** A successful per-channel result contains `channel` (string) and +`presence` (array of PresenceMessage). + +### BGR2_1 - Success result with members present + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: [ + { + "channel": "my-channel", + "presence": [ + { + "clientId": "client-1", + "action": 1, + "connectionId": "conn-abc", + "id": "conn-abc:0:0", + "timestamp": 1700000000000, + "data": "hello" + }, + { + "clientId": "client-2", + "action": 1, + "connectionId": "conn-def", + "id": "conn-def:0:0", + "timestamp": 1700000000000, + "data": { "key": "value" } + } + ] + } + ]) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["my-channel"]) + +ASSERT result.results.length == 1 + +success = result.results[0] +ASSERT success IS BatchPresenceSuccessResult +ASSERT success.channel == "my-channel" +ASSERT success.presence.length == 2 + +ASSERT success.presence[0].clientId == "client-1" +ASSERT success.presence[0].action == PRESENT +ASSERT success.presence[0].connectionId == "conn-abc" +ASSERT success.presence[0].data == "hello" + +ASSERT success.presence[1].clientId == "client-2" +ASSERT success.presence[1].data IS Object/Map +ASSERT success.presence[1].data["key"] == "value" +``` + +### BGR2_2 - Success result with empty presence (no members) + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 200, body: [ + { "channel": "empty-channel", "presence": [] } + ]) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["empty-channel"]) + +success = result.results[0] +ASSERT success IS BatchPresenceSuccessResult +ASSERT success.channel == "empty-channel" +ASSERT success.presence.length == 0 +``` + +--- + +## BGF2 - BatchPresenceFailureResult structure + +**Spec requirement:** A failed per-channel result contains `channel` (string) and +`error` (ErrorInfo). + +### BGF2_1 - Failure result with error details + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 400, body: { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { + "channel": "restricted-channel", + "error": { + "code": 40160, + "statusCode": 401, + "message": "Channel operation not permitted" + } + } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["restricted-channel"]) + +ASSERT result.results.length == 1 + +failure = result.results[0] +ASSERT failure IS BatchPresenceFailureResult +ASSERT failure.channel == "restricted-channel" +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.code == 40160 +ASSERT failure.error.statusCode == 401 +ASSERT failure.error.message CONTAINS "not permitted" +``` + +--- + +## Mixed results + +### RSC24_Mixed_1 - Mixed success and failure results + +**Spec requirement:** A batch presence request can succeed for some channels and fail +for others. The server returns HTTP 400 with a `batchResponse` containing both +success and failure per-channel results. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 400, body: { + "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, + "batchResponse": [ + { + "channel": "allowed-channel", + "presence": [ + { + "clientId": "user-1", + "action": 1, + "connectionId": "conn-1", + "id": "conn-1:0:0", + "timestamp": 1700000000000 + } + ] + }, + { + "channel": "restricted-channel", + "error": { + "code": 40160, + "statusCode": 401, + "message": "Not permitted" + } + } + ] + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +result = AWAIT client.batchPresence(["allowed-channel", "restricted-channel"]) + +ASSERT result.successCount == 1 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 2 + +ASSERT result.results[0] IS BatchPresenceSuccessResult +ASSERT result.results[0].channel == "allowed-channel" +ASSERT result.results[0].presence.length == 1 +ASSERT result.results[0].presence[0].clientId == "user-1" + +ASSERT result.results[1] IS BatchPresenceFailureResult +ASSERT result.results[1].channel == "restricted-channel" +ASSERT result.results[1].error.code == 40160 +``` + +--- + +## Error handling + +### RSC24_Error_1 - Server error is propagated as an error + +**Spec requirement:** A server-level error (e.g. 500) for the entire batch request +is propagated as an error, not a per-channel failure. The response contains only an +`error` field with no `batchResponse`. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 500, body: { + "error": { "code": 50000, "statusCode": 500, "message": "Internal error" } + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +AWAIT client.batchPresence(["any-channel"]) FAILS WITH error +ASSERT error.code == 50000 +ASSERT error.statusCode == 500 +``` + +### RSC24_Error_2 - Authentication error is propagated as an error + +**Spec requirement:** An authentication error (401) for the entire request is +propagated as an error. + +```pseudo +mock_http = MockHTTP( + onRequest: (request) => { + RETURN HttpResponse(status: 401, body: { + "error": { "code": 40101, "statusCode": 401, "message": "Invalid credentials" } + }) + } +) + +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) + +AWAIT client.batchPresence(["any-channel"]) FAILS WITH error +ASSERT error.code == 40101 +ASSERT error.statusCode == 401 +``` + +--- + +## Request authentication + +### RSC24_Auth_1 - Request uses configured authentication + +**Spec requirement:** batchPresence requests use the client's configured authentication +mechanism (Basic or Token auth). + +```pseudo +captured_requests = [] +mock_http = MockHTTP( + onRequest: (request) => { + captured_requests.append(request) + RETURN HttpResponse(status: 200, body: [ + { "channel": "ch", "presence": [] } + ]) + } +) + +# Basic auth +client = Rest(options: ClientOptions(key: "fake.key:secret"), httpClient: mock_http) +AWAIT client.batchPresence(["ch"]) + +ASSERT captured_requests[0].headers["Authorization"] STARTS_WITH "Basic " +``` diff --git a/uts/rest/unit/batch_publish.md b/uts/rest/unit/batch_publish.md new file mode 100644 index 000000000..2dda737e5 --- /dev/null +++ b/uts/rest/unit/batch_publish.md @@ -0,0 +1,504 @@ +# Batch Publish Tests + +Tests for `RestClient#batchPublish` (RSC22) and related types (BSP*, BPR*, BPF*). + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## RSC22c - batchPublish sends POST to /messages + +**Spec requirement:** The `batchPublish` method must send a POST request to the `/messages` endpoint with the batch specifications in the request body. + +### RSC22c1 - Single BatchPublishSpec sends POST to /messages + +**Spec requirement:** A single BatchPublishSpec is sent as a POST to `/messages` with the spec in the request body. + +```pseudo +channel_name_1 = "test-RSC22c1-a-${random_id()}" +channel_name_2 = "test-RSC22c1-b-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests and respond with success +When batchPublish is called with a single BatchPublishSpec: + - channels: [channel_name_1, channel_name_2] + - messages: [Message(name: "event", data: "hello")] +Then a POST request is sent to "/messages" +And the captured request body contains: + - channels: [channel_name_1, channel_name_2] + - messages: [{ name: "event", data: "hello" }] +``` + +### RSC22c2 - Array of BatchPublishSpecs sends POST to /messages + +**Spec requirement:** An array of BatchPublishSpecs is sent as a POST to `/messages` with an array of specs in the request body. + +```pseudo +channel_name_1 = "test-RSC22c2-a-${random_id()}" +channel_name_2 = "test-RSC22c2-b-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests and respond with success +When batchPublish is called with an array of BatchPublishSpecs: + - BatchPublishSpec(channels: [channel_name_1], messages: [Message(name: "e1", data: "d1")]) + - BatchPublishSpec(channels: [channel_name_2], messages: [Message(name: "e2", data: "d2")]) +Then a POST request is sent to "/messages" +And the captured request body is an array containing both specs +``` + +### RSC22c3 - Single spec returns single BatchResult + +**Spec requirement:** When a single BatchPublishSpec is sent, the response is a single BatchResult (not an array). + +```pseudo +channel_name = "test-RSC22c3-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to respond with: + { + "channel": channel_name, + "messageId": "msg123", + "serials": ["serial1"] + } +When batchPublish is called with a single BatchPublishSpec +Then a single BatchResult is returned (not an array) +And the result contains the success result for channel_name +``` + +### RSC22c4 - Array of specs returns array of BatchResults + +**Spec requirement:** When an array of BatchPublishSpecs is sent, the response is an array of BatchResults. + +```pseudo +channel_name_1 = "test-RSC22c4-a-${random_id()}" +channel_name_2 = "test-RSC22c4-b-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to respond with an array of results: + [ + { "channel": channel_name_1, "messageId": "msg1", "serials": ["s1"] }, + { "channel": channel_name_2, "messageId": "msg2", "serials": ["s2"] } + ] +When batchPublish is called with an array of BatchPublishSpecs +Then an array of BatchResults is returned +And each result corresponds to the respective spec +``` + +### RSC22c5 - Multiple channels in spec produces multiple results + +**Spec requirement:** A BatchPublishSpec with multiple channels produces multiple results in the response, one per channel. + +```pseudo +channel_name_1 = "test-RSC22c5-a-${random_id()}" +channel_name_2 = "test-RSC22c5-b-${random_id()}" +channel_name_3 = "test-RSC22c5-c-${random_id()}" + +Given a REST client with mock HTTP +And a BatchPublishSpec with channels: [channel_name_1, channel_name_2, channel_name_3] +And the mock is configured to respond with results for each channel +When batchPublish is called +Then the BatchResult contains results for all three channels +``` + +### RSC22c6 - Messages are encoded according to RSL4 + +**Spec requirement:** Messages must be encoded according to RSL4 (String, Binary base64, JSON stringified). + +```pseudo +channel_name = "test-RSC22c6-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages containing: + - String data + - Binary data (Uint8List/[]byte) + - JSON object data +Then the captured request shows each message is encoded per RSL4: + - String: data as-is, no encoding + - Binary: base64 encoded, encoding: "base64" + - JSON: JSON stringified, encoding: "json" +``` + +### RSC22c7 - Request uses correct authentication + +**Spec requirement:** Batch publish requests must use the configured authentication mechanism. + +```pseudo +channel_name = "test-RSC22c7-${random_id()}" + +Given a REST client with token auth and mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured POST request includes Authorization: Bearer +``` + +```pseudo +channel_name = "test-RSC22c7-basic-${random_id()}" + +Given a REST client with basic auth and mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured POST request includes Authorization: Basic +``` + +## RSC22d - Idempotent publishing applies RSL1k1 + +**Spec requirement (RSC22d):** "If `idempotentRestPublishing` is enabled, then RSL1k1 should be applied (to each `BatchPublishSpec` separately)." + +### RSC22d - Idempotent IDs generated when enabled + +**Spec requirement:** With idempotentRestPublishing enabled, messages without IDs get unique IDs generated in baseId:serial format per RSL1k1, applied to each BatchPublishSpec separately. + +```pseudo +Given a REST client with idempotentRestPublishing: true and mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages that have no id +Then the captured request shows each message in each BatchPublishSpec has a unique id generated +And the id format follows RSL1k1 (baseId:serial) +And each BatchPublishSpec gets a separate base ID +``` + +### RSC22d - Explicit message IDs preserved + +**Spec requirement:** Per RSL1k3, messages with explicit IDs must have those IDs preserved as-is, even when idempotent publishing is enabled. + +```pseudo +Given a REST client with idempotentRestPublishing: true and mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages that have explicit ids +Then the captured request shows the explicit ids are preserved (not overwritten) +``` + +### RSC22d - Idempotent IDs not generated when disabled + +**Spec requirement:** When idempotent REST publishing is disabled, no IDs are generated for messages without IDs. + +```pseudo +Given a REST client with idempotentRestPublishing: false and mock HTTP +And the mock is configured to capture requests +When batchPublish is called with messages that have no id +Then the captured request shows messages are sent without id fields +``` + +## BSP - BatchPublishSpec Structure + +**Spec requirement:** BatchPublishSpec defines the structure for specifying channels and messages in a batch publish request (BSP2a, BSP2b). + +### BSP2a - channels is array of strings + +**Spec requirement:** The channels field must be an array of channel name strings. + +```pseudo +channel_name_1 = "test-BSP2a-a-${random_id()}" +channel_name_2 = "test-BSP2a-b-${random_id()}" +channel_name_3 = "test-BSP2a-c-${random_id()}" + +Given a BatchPublishSpec with mock HTTP +When channels is set to [channel_name_1, channel_name_2, channel_name_3] +Then the serialized spec in the captured request contains channels as a string array +``` + +### BSP2b - messages is array of Message objects + +**Spec requirement:** The messages field must be an array of Message objects, each serialized according to TM* rules. + +```pseudo +channel_name = "test-BSP2b-${random_id()}" + +Given a BatchPublishSpec with mock HTTP +And the mock is configured to capture requests +When messages contains multiple Message objects with: + - Message(name: "event1", data: "data1") + - Message(name: "event2", data: { "key": "value" }) +Then the serialized spec in the captured request contains messages as an array of message objects +And each message is serialized according to TM* rules +``` + +## BPR - BatchPublishSuccessResult Structure + +**Spec requirement:** BatchPublishSuccessResult defines the structure of successful batch publish responses (BPR2a, BPR2b, BPR2c). + +### BPR2a - channel field contains channel name + +**Spec requirement:** The channel field contains the name of the channel where messages were published. + +```pseudo +channel_name = "test-BPR2a-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with: + { "channel": channel_name, "messageId": "msg123", "serials": ["s1"] } +When the response is parsed into BatchPublishSuccessResult +Then result.channel equals channel_name +``` + +### BPR2b - messageId contains the message ID prefix + +**Spec requirement:** The messageId field contains the unique ID prefix for the published messages. + +```pseudo +channel_name = "test-BPR2b-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with: + { "channel": channel_name, "messageId": "unique-id-prefix", "serials": ["s1", "s2"] } +When the response is parsed into BatchPublishSuccessResult +Then result.messageId equals "unique-id-prefix" +``` + +### BPR2c - serials contains array of message serials + +**Spec requirement:** The serials field contains an array of serial numbers, one per published message. + +```pseudo +channel_name = "test-BPR2c-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with: + { "channel": channel_name, "messageId": "msg", "serials": ["serial1", "serial2", "serial3"] } +When the response is parsed into BatchPublishSuccessResult +Then result.serials equals ["serial1", "serial2", "serial3"] +And serials.length matches the number of messages published +``` + +### BPR2c1 - serials may contain null for conflated messages + +**Spec requirement:** The serials array may contain null values for messages that were conflated (deduplicated). + +```pseudo +channel_name = "test-BPR2c1-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with a response where some messages were conflated: + { "channel": channel_name, "messageId": "msg", "serials": ["serial1", null, "serial3"] } +When the response is parsed into BatchPublishSuccessResult +Then result.serials equals ["serial1", null, "serial3"] +And the null indicates the second message was discarded due to conflation +``` + +## BPF - BatchPublishFailureResult Structure + +**Spec requirement:** BatchPublishFailureResult defines the structure of failed batch publish responses (BPF2a, BPF2b). + +### BPF2a - channel field contains failed channel name + +**Spec requirement:** The channel field contains the name of the channel that failed. + +```pseudo +channel_name = "test-BPF2a-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with a failure: + { + "channel": channel_name, + "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } + } +When the response is parsed into BatchPublishFailureResult +Then result.channel equals channel_name +``` + +### BPF2b - error contains ErrorInfo for failure reason + +**Spec requirement:** The error field contains an ErrorInfo object with code, statusCode, and message. + +```pseudo +channel_name = "test-BPF2b-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with a detailed error: + { + "channel": channel_name, + "error": { + "code": 40160, + "statusCode": 401, + "message": "Channel operation not permitted", + "href": "https://help.ably.io/error/40160" + } + } +When the response is parsed into BatchPublishFailureResult +Then result.error is an ErrorInfo +And result.error.code equals 40160 +And result.error.statusCode equals 401 +And result.error.message contains "not permitted" +``` + +## BatchResult - Mixed Success and Failure + +**Spec requirement:** Batch publish responses can contain a mix of success and failure results, one per channel. + +### BatchResult1 - Partial success with mixed results + +**Spec requirement:** A batch publish can succeed for some channels and fail for others. + +```pseudo +channel_name_allowed = "test-BatchResult1-allowed-${random_id()}" +channel_name_restricted = "test-BatchResult1-restricted-${random_id()}" + +Given a REST client with mock HTTP +And a BatchPublishSpec with channels: [channel_name_allowed, channel_name_restricted] +And the mock responds with mixed results: + [ + { "channel": channel_name_allowed, "messageId": "msg1", "serials": ["s1"] }, + { "channel": channel_name_restricted, "error": { "code": 40160, ... } } + ] +When batchPublish is called +Then the BatchResult contains both results +And result[0] is a BatchPublishSuccessResult +And result[1] is a BatchPublishFailureResult +``` + +### BatchResult2 - Distinguishing success from failure results + +**Spec requirement:** Success and failure results can be distinguished by the presence of messageId/serials vs error fields. + +```pseudo +channel_name = "test-BatchResult2-${random_id()}" + +Given a BatchResult from batchPublish with mock HTTP +When iterating through results +Then each result can be identified as success or failure: + - Success results have messageId and serials fields + - Failure results have error field +``` + +## Error Handling + +**Spec requirement:** Batch publish must validate inputs and properly propagate errors from the server. + +### RSC22_Error1 - Invalid BatchPublishSpec rejected + +**Spec requirement:** Empty channels array must be rejected with a validation error. + +```pseudo +Given a REST client with mock HTTP +When batchPublish is called with an empty channels array +Then an error is returned +And the error indicates invalid request +``` + +### RSC22_Error2 - Empty messages array rejected + +**Spec requirement:** Empty messages array must be rejected with a validation error. + +```pseudo +channel_name = "test-RSC22-Error2-${random_id()}" + +Given a REST client with mock HTTP +When batchPublish is called with an empty messages array +Then an error is returned +And the error indicates invalid request +``` + +### RSC22_Error3 - Server error returns AblyException + +**Spec requirement:** Server errors (5xx) must be propagated as AblyException with the error code and status. + +```pseudo +channel_name = "test-RSC22-Error3-${random_id()}" + +Given a REST client with mock HTTP +And the mock responds with HTTP 500: + { "error": { "code": 50000, "statusCode": 500, "message": "Internal error" } } +When batchPublish is called +Then an AblyException is thrown +And exception.code equals 50000 +And exception.statusCode equals 500 +``` + +### RSC22_Error4 - Authentication error returns AblyException + +**Spec requirement:** Authentication errors (401) must be propagated as AblyException with the error code and status. + +```pseudo +channel_name = "test-RSC22-Error4-${random_id()}" + +Given a REST client with invalid credentials and mock HTTP +And the mock responds with HTTP 401: + { "error": { "code": 40101, "statusCode": 401, "message": "Invalid credentials" } } +When batchPublish is called +Then an AblyException is thrown +And exception.code equals 40101 +And exception.statusCode equals 401 +``` + +## Request Headers + +**Spec requirement:** Batch publish requests must include standard Ably headers (X-Ably-Version, Ably-Agent, Content-Type). + +### RSC22_Headers1 - Standard headers included + +**Spec requirement:** All batch publish requests must include standard Ably protocol headers. + +```pseudo +channel_name = "test-RSC22-Headers1-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured request includes: + - X-Ably-Version: 2 + - Ably-Agent: + - Content-Type: application/json +``` + +### RSC22_Headers2 - Request ID included when enabled + +**Spec requirement:** When addRequestIds is enabled, a unique request_id query parameter must be included. + +```pseudo +channel_name = "test-RSC22-Headers2-${random_id()}" + +Given a REST client with addRequestIds: true and mock HTTP +And the mock is configured to capture requests +When batchPublish is called +Then the captured request includes a request_id query parameter +And the request_id is a unique identifier +``` + +## Large Batch Handling + +**Spec requirement:** Batch publish must handle large batches with multiple messages and channels efficiently. + +### RSC22_Batch1 - Multiple messages per channel + +**Spec requirement:** A batch can include many messages to be published to a single channel. + +```pseudo +channel_name = "test-RSC22-Batch1-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to capture requests +And a BatchPublishSpec with: + - channels: [channel_name] + - messages: [100 Message objects] +When batchPublish is called +Then all 100 messages are included in the captured request body +And the mock response confirms all messages were processed +``` + +### RSC22_Batch2 - Multiple channels with multiple messages + +**Spec requirement:** A batch can publish multiple messages to multiple channels (cartesian product). + +```pseudo +channel_name_1 = "test-RSC22-Batch2-a-${random_id()}" +channel_name_2 = "test-RSC22-Batch2-b-${random_id()}" +channel_name_3 = "test-RSC22-Batch2-c-${random_id()}" + +Given a REST client with mock HTTP +And the mock is configured to respond with results for each channel +And a BatchPublishSpec with: + - channels: [channel_name_1, channel_name_2, channel_name_3] + - messages: [msg1, msg2, msg3] +When batchPublish is called +Then the batch publishes all 3 messages to all 3 channels (9 total publications) +And the result contains 3 BatchPublishSuccessResult entries (one per channel) +``` diff --git a/uts/rest/unit/channel/annotations.md b/uts/rest/unit/channel/annotations.md new file mode 100644 index 000000000..4189bb4c9 --- /dev/null +++ b/uts/rest/unit/channel/annotations.md @@ -0,0 +1,480 @@ +# REST Channel Annotations Tests + +Spec points: `RSL10`, `RSAN1`, `RSAN1a`, `RSAN1a2`, `RSAN1a3`, `RSAN1c`, `RSAN1c1`, `RSAN1c2`, `RSAN1c3`, `RSAN1c4`, `RSAN1c5`, `RSAN1c6`, `RSAN2`, `RSAN2a`, `RSAN3`, `RSAN3a`, `RSAN3b`, `RSAN3c` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL10 — channel.annotations returns RestAnnotations + +**Spec requirement:** RSL10 — `RestChannel#annotations` attribute contains the `RestAnnotations` object for this channel. + +Tests that the channel exposes an `annotations` attribute of type `RestAnnotations`. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test-RSL10") +``` + +### Assertions +```pseudo +ASSERT channel.annotations IS RestAnnotations +``` + +--- + +## RSAN1c6, RSAN1c1, RSAN1c2 — publish sends POST with ANNOTATION_CREATE to correct endpoint + +| Spec | Requirement | +|------|-------------| +| RSAN1c6 | Body sent as POST to `/channels/{channelName}/messages/{messageSerial}/annotations` | +| RSAN1c1 | `Annotation.action` must be set to `ANNOTATION_CREATE` | +| RSAN1c2 | `Annotation.messageSerial` must be set to the identifier from the first argument | + +Tests that `annotations.publish()` sends a correctly formatted POST request. + +### Setup +```pseudo +channel_name = "test-RSAN1-publish-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/annotations" + +body = parse_json(request.body) +ASSERT body IS List +ASSERT body.length == 1 + +annotation = body[0] +ASSERT annotation["action"] == 0 # ANNOTATION_CREATE numeric value +ASSERT annotation["messageSerial"] == "msg-serial-1" +ASSERT annotation["type"] == "com.example.reaction" +ASSERT annotation["name"] == "like" +``` + +--- + +## RSAN1a3 — publish validates type is required + +**Spec requirement:** RSAN1a3 — The SDK must validate that the user supplied a `type`. All other fields are optional. + +Tests that publishing an annotation without a `type` field throws an error. + +### Setup +```pseudo +channel_name = "test-RSAN1a3-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(201, {}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# Annotation without type +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + name: "like" +)) FAILS WITH error +ASSERT error.code == 40003 +``` + +--- + +## RSAN1c3 — annotation data encoded per RSL4 + +**Spec requirement:** RSAN1c3 — If the user has supplied an `Annotation.data`, that must be encoded (and the `encoding` set) just as it would be for a `Message`, per `RSL4`. + +Tests that JSON data in an annotation is encoded following message encoding rules. + +### Setup +```pseudo +channel_name = "test-RSAN1c3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.data", + data: { "key": "value", "nested": { "a": 1 } } +)) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +annotation = body[0] + +# JSON data should be encoded as a string with encoding field +ASSERT annotation["data"] IS String +ASSERT annotation["encoding"] == "json" +ASSERT parse_json(annotation["data"]) == { "key": "value", "nested": { "a": 1 } } +``` + +--- + +## RSAN1c4 — idempotent ID generated when enabled + +**Spec requirement:** RSAN1c4 — If `idempotentRestPublishing` is enabled and the annotation has an empty `id`, the SDK should generate a base64-encoded random string, append `:0`, and set it as the `Annotation.id`. + +Tests that an idempotent ID is auto-generated when the option is enabled. + +### Setup +```pseudo +channel_name = "test-RSAN1c4-enabled-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction" +)) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +annotation = body[0] + +ASSERT "id" IN annotation +annotation_id = annotation["id"] + +# Format: :0 +parts = annotation_id.split(":") +ASSERT parts.length == 2 +ASSERT parts[0] matches pattern "[A-Za-z0-9_-]+" +ASSERT parts[0].length >= 12 # At least 9 bytes base64 encoded +ASSERT parts[1] == "0" +``` + +--- + +## RSAN1c4 — idempotent ID not generated when disabled + +**Spec requirement:** RSAN1c4 — The SDK should only generate idempotent IDs when `idempotentRestPublishing` is enabled. + +Tests that no ID is auto-generated when idempotent publishing is disabled. + +### Setup +```pseudo +channel_name = "test-RSAN1c4-disabled-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction" +)) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +annotation = body[0] + +ASSERT "id" NOT IN annotation +``` + +--- + +## RSAN2a — delete sends POST with ANNOTATION_DELETE + +**Spec requirement:** RSAN2a — Must be identical to RSAN1 `publish()` except that the `Annotation.action` is set to `ANNOTATION_DELETE`, not `ANNOTATION_CREATE`. + +Tests that `annotations.delete()` sends a POST with the delete action. + +### Setup +```pseudo +channel_name = "test-RSAN2-delete-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.delete("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/annotations" + +body = parse_json(request.body) +ASSERT body IS List +ASSERT body.length == 1 + +annotation = body[0] +ASSERT annotation["action"] == 1 # ANNOTATION_DELETE numeric value +ASSERT annotation["messageSerial"] == "msg-serial-1" +ASSERT annotation["type"] == "com.example.reaction" +ASSERT annotation["name"] == "like" +``` + +--- + +## RSAN3b — get sends GET to correct endpoint + +| Spec | Requirement | +|------|-------------| +| RSAN3b | Sends a GET request to `/channels/{channelName}/messages/{messageSerial}/annotations` | + +Tests that `annotations.get()` sends a GET request to the correct URL. + +### Setup +```pseudo +channel_name = "test-RSAN3-get-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "ann-1", + "action": 0, + "type": "com.example.reaction", + "name": "like", + "clientId": "user-1", + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "timestamp": 1700000000000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.annotations.get("msg-serial-1") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/annotations" +``` + +--- + +## RSAN3c — get returns PaginatedResult of Annotations + +**Spec requirement:** RSAN3c — Returns a `PaginatedResult` page containing the first page of decoded `Annotation` objects. + +Tests that the response is parsed into a paginated result of annotations with all fields. + +### Setup +```pseudo +channel_name = "test-RSAN3c-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + "id": "ann-1", + "action": 0, + "type": "com.example.reaction", + "name": "like", + "clientId": "user-1", + "count": 1, + "data": "thumbs-up", + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "timestamp": 1700000000000, + "extras": { "custom": "metadata" } + }, + { + "id": "ann-2", + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "clientId": "user-2", + "serial": "ann-serial-2", + "messageSerial": "msg-serial-1", + "timestamp": 1700000001000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.annotations.get("msg-serial-1") +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 + +ann1 = result.items[0] +ASSERT ann1 IS Annotation +ASSERT ann1.id == "ann-1" +ASSERT ann1.action == AnnotationAction.ANNOTATION_CREATE +ASSERT ann1.type == "com.example.reaction" +ASSERT ann1.name == "like" +ASSERT ann1.clientId == "user-1" +ASSERT ann1.count == 1 +ASSERT ann1.data == "thumbs-up" +ASSERT ann1.serial == "ann-serial-1" +ASSERT ann1.messageSerial == "msg-serial-1" +ASSERT ann1.timestamp == 1700000000000 +ASSERT ann1.extras["custom"] == "metadata" + +ann2 = result.items[1] +ASSERT ann2.name == "heart" +ASSERT ann2.clientId == "user-2" +``` + +--- + +## RSAN3b — get passes params as querystring + +**Spec requirement:** RSAN3b — Any `params` are sent in the querystring. + +Tests that optional params are sent as query parameters. + +### Setup +```pseudo +channel_name = "test-RSAN3b-params-${random_id()}" +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")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.annotations.get("msg-serial-1", params: { "limit": "50" }) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["limit"] == "50" +``` diff --git a/uts/rest/unit/channel/get_message.md b/uts/rest/unit/channel/get_message.md new file mode 100644 index 000000000..29b716a5b --- /dev/null +++ b/uts/rest/unit/channel/get_message.md @@ -0,0 +1,183 @@ +# REST Channel GetMessage Tests + +Spec points: `RSL11`, `RSL11a`, `RSL11a1`, `RSL11b`, `RSL11c` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL11b — getMessage sends GET to correct endpoint + +**Spec requirement:** RSL11b — The SDK must send a GET request to the endpoint `/channels/{channelName}/messages/{serial}`. + +Tests that `getMessage()` sends a GET request to the correct URL. + +### Setup +```pseudo +channel_name = "test-RSL11b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "name": "evt", + "data": "hello", + "serial": "msg-serial-123", + "timestamp": 1700000000000 + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessage("msg-serial-123") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-123" +ASSERT request.body IS null OR request.body IS empty +``` + +--- + +## RSL11c — getMessage returns decoded Message + +**Spec requirement:** RSL11c — Returns the decoded `Message` object for the specified message serial. + +Tests that `getMessage()` returns a fully decoded `Message` with all fields populated. + +### Setup +```pseudo +channel_name = "test-RSL11c-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "id": "msg-id-1", + "name": "test-event", + "data": "hello world", + "serial": "serial-xyz", + "clientId": "client-1", + "timestamp": 1700000000000, + "extras": { "push": { "notification": { "title": "Test" } } }, + "version": { + "serial": "version-serial-1", + "timestamp": 1700000000000, + "clientId": "client-1" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +msg = AWAIT channel.getMessage("serial-xyz") +``` + +### Assertions +```pseudo +ASSERT msg IS Message +ASSERT msg.id == "msg-id-1" +ASSERT msg.name == "test-event" +ASSERT msg.data == "hello world" +ASSERT msg.serial == "serial-xyz" +ASSERT msg.clientId == "client-1" +ASSERT msg.timestamp == 1700000000000 +ASSERT msg.version.serial == "version-serial-1" +``` + +--- + +## RSL11b — getMessage URL-encodes serial in path + +**Spec requirement:** RSL11b — The serial must be URL-encoded when used in the request path. + +Tests that special characters in the serial are properly URL-encoded. + +### Setup +```pseudo +channel_name = "test-RSL11b-encode-${random_id()}" +captured_requests = [] +serial_with_special_chars = "serial/with:special+chars" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "name": "evt", + "data": "hello", + "serial": serial_with_special_chars + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.getMessage(serial_with_special_chars) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/" + encode_uri_component(serial_with_special_chars) +``` + +--- + +## RSL11a — getMessage with missing serial throws error + +**Spec requirement:** RSL11a — Takes a first argument of a `serial` string of the message to be retrieved. The serial must be present. + +Tests that calling `getMessage()` with an empty or missing serial throws an error. + +### Setup +```pseudo +channel_name = "test-RSL11a-error-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# Empty string serial +AWAIT channel.getMessage("") FAILS WITH error +ASSERT error.code == 40003 +``` diff --git a/uts/rest/unit/channel/history.md b/uts/rest/unit/channel/history.md new file mode 100644 index 000000000..ac07f675f --- /dev/null +++ b/uts/rest/unit/channel/history.md @@ -0,0 +1,327 @@ +# REST Channel History Tests + +Spec points: `RSL2`, `RSL2a`, `RSL2b`, `RSL2b1`, `RSL2b2`, `RSL2b3` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock must support: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` callbacks +- Request capture via `captured_requests` arrays +- Request counting via `request_count` variables +- Response configuration with status, headers, and body + +See rest_client.md for the complete `MockHttpClient` interface specification. + +--- + +## RSL2a - History returns PaginatedResult + +**Spec requirement:** The `history()` method must return a `PaginatedResult` object containing an array of `Message` objects. + +Tests that `history()` returns a `PaginatedResult` containing messages. + +### Setup +```pseudo +channel_name = "test-RSL2a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "id": "msg1", "name": "event1", "data": "data1", "timestamp": 1000 }, + { "id": "msg2", "name": "event2", "data": "data2", "timestamp": 2000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items IS List +ASSERT result.items.length == 2 + +ASSERT result.items[0] IS Message +ASSERT result.items[0].id == "msg1" +ASSERT result.items[0].name == "event1" +ASSERT result.items[0].data == "data1" +``` + +--- + +## RSL2b - History query parameters + +**Spec requirement:** History method parameters (start, end, direction, limit) must be encoded as query string parameters in the HTTP request. + +Tests that history parameters are correctly sent as query string. + +### Setup +```pseudo +channel_name = "test-RSL2b-${random_id()}" +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")) +channel = client.channels.get(channel_name) +``` + +### Test Cases + +| ID | Parameter | Value | Expected Query | +|----|-----------|-------|----------------| +| 1 | start | `1234567890000` | `start=1234567890000` | +| 2 | end | `1234567899999` | `end=1234567899999` | +| 3 | direction | `"backwards"` | `direction=backwards` | +| 4 | direction | `"forwards"` | `direction=forwards` | +| 5 | limit | `50` | `limit=50` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + params = {} + params[test_case.parameter] = test_case.value + + AWAIT channel.history(params) + + request = captured_requests[0] + ASSERT request.url.query_params[test_case.parameter] == str(test_case.value) +``` + +--- + +## RSL2b1 - Default direction is backwards + +**Spec requirement:** When the direction parameter is not specified, the default direction for history queries must be backwards (newest messages first). + +Tests that the default direction for history is backwards (newest first). + +### Setup +```pseudo +channel_name = "test-RSL2b1-${random_id()}" +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")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history() # No direction specified +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Either direction param is absent (server default) or explicitly "backwards" +IF "direction" IN request.url.query_params: + ASSERT request.url.query_params["direction"] == "backwards" +# If absent, server defaults to backwards per spec +``` + +--- + +## RSL2b2 - Limit parameter + +**Spec requirement:** The limit parameter must control the maximum number of messages returned in a single history query. + +Tests that limit parameter restricts the number of returned items. + +### Setup +```pseudo +channel_name = "test-RSL2b2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "id": "msg1", "name": "e", "data": "d", "timestamp": 1000 }, + { "id": "msg2", "name": "e", "data": "d", "timestamp": 2000 }, + { "id": "msg3", "name": "e", "data": "d", "timestamp": 3000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history(limit: 10) + +request = captured_requests[0] +ASSERT request.url.query_params["limit"] == "10" +``` + +--- + +## RSL2b3 - Default limit is 100 + +**Spec requirement:** When the limit parameter is not specified, the default limit must be 100 messages. + +Tests that the default limit is 100 when not specified. + +### Setup +```pseudo +channel_name = "test-RSL2b3-${random_id()}" +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")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history() # No limit specified +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# Either limit param is absent (server default) or explicitly "100" +IF "limit" IN request.url.query_params: + ASSERT request.url.query_params["limit"] == "100" +# If absent, server defaults to 100 per spec +``` + +--- + +## RSL2 - History request URL format + +**Spec requirement:** History requests must use the URL path `/channels//messages` with proper URL encoding of the channel name. + +Tests that history requests use the correct URL path. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Channel Name | Expected Path | +|----|--------------|---------------| +| 1 | `"test-RSL2-simple-${random_id()}"` | `/channels/test-RSL2-simple-.../messages` | +| 2 | `"test-RSL2-with:colon-${random_id()}"` | `/channels/test-RSL2-with%3Acolon-.../messages` | +| 3 | `"test-RSL2-with/slash-${random_id()}"` | `/channels/test-RSL2-with%2Fslash-.../messages` | +| 4 | `"test-RSL2-with space-${random_id()}"` | `/channels/test-RSL2-with%20space-.../messages` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + request_count = 0 + + channel = client.channels.get(test_case.channel_name) + AWAIT channel.history() + + ASSERT request_count == 1 + request = captured_requests[0] + ASSERT request.method == "GET" + ASSERT request.url.path == "/channels/${encode_uri_component(test_case.channel_name)}/messages" +``` + +--- + +## RSL2 - History with time range + +**Spec requirement:** History queries must support start and end time parameters to retrieve messages within a specific time window. + +Tests combining start and end parameters for time-bounded queries. + +### Setup +```pseudo +channel_name = "test-RSL2-timerange-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { "id": "msg1", "name": "e", "data": "d", "timestamp": 1500 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.history( + start: 1000, + end: 2000 +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["start"] == "1000" +ASSERT request.url.query_params["end"] == "2000" +``` diff --git a/uts/rest/unit/channel/idempotency.md b/uts/rest/unit/channel/idempotency.md new file mode 100644 index 000000000..e0c9917c6 --- /dev/null +++ b/uts/rest/unit/channel/idempotency.md @@ -0,0 +1,394 @@ +# Idempotent Publishing Tests + +Spec points: `RSL1k`, `RSL1k1`, `RSL1k2`, `RSL1k3`, `RSL1k4`, `RSL1k5` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock must support: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` callbacks +- Request capture via `captured_requests` arrays +- Request counting via `request_count` variables +- Response configuration with status, headers, and body + +See rest_client.md for the complete `MockHttpClient` interface specification. + +--- + +## RSL1k1 - idempotentRestPublishing default + +**Spec requirement:** The `idempotentRestPublishing` client option must default to `true` for library versions >= 1.2. + +Tests the default value of `idempotentRestPublishing` option. + +### Test Cases + +| ID | Library Version | Expected Default | +|----|-----------------|------------------| +| 1 | >= 1.2 | `true` | + +### Test Steps +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Verify default value +ASSERT client.options.idempotentRestPublishing == true +``` + +--- + +## RSL1k2 - Message ID format when idempotent publishing enabled + +**Spec requirement:** When `idempotentRestPublishing` is enabled, library-generated message IDs must follow the format `:` where base64 is a URL-safe base64-encoded random value and serial is a zero-based sequential integer. + +Tests that library-generated message IDs follow the `:` format. + +### Setup +```pseudo +channel_name = "test-RSL1k2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "data") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT "id" IN body +message_id = body["id"] + +# Format: : +parts = message_id.split(":") +ASSERT parts.length == 2 + +# First part is base64-encoded (url-safe) +ASSERT parts[0] matches pattern "[A-Za-z0-9_-]+" +ASSERT parts[0].length >= 12 # At least 9 bytes base64 encoded + +# Second part is a serial number (starting from 0) +ASSERT parts[1] == "0" +``` + +--- + +## RSL1k2 - Serial increments for batch publish + +**Spec requirement:** When publishing multiple messages in a batch, all messages must share the same base ID with incrementing serial numbers starting from 0. + +Tests that serial numbers increment for each message in a batch. + +### Setup +```pseudo +channel_name = "test-RSL1k2-batch-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +] +AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body) + +# All messages should share the same base but different serials +base_ids = [] +serials = [] + +FOR i, msg IN enumerate(body): + parts = msg["id"].split(":") + base_ids.append(parts[0]) + serials.append(int(parts[1])) + +# Same base for all messages in batch +ASSERT ALL base == base_ids[0] FOR base IN base_ids + +# Sequential serials starting from 0 +ASSERT serials == [0, 1, 2] +``` + +--- + +## RSL1k3 - Separate publishes get unique base IDs + +**Spec requirement:** Each separate publish call must generate a new unique base ID, even for messages published to the same channel. + +Tests that separate publish calls generate unique base IDs. + +### Setup +```pseudo +channel_name = "test-RSL1k3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event1", data: "data1") +AWAIT channel.publish(name: "event2", data: "data2") +``` + +### Assertions +```pseudo +body1 = parse_json(captured_requests[0].body)[0] +body2 = parse_json(captured_requests[1].body)[0] + +base1 = body1["id"].split(":")[0] +base2 = body2["id"].split(":")[0] + +# Different publish calls should have different base IDs +ASSERT base1 != base2 +``` + +--- + +## RSL1k3 - No ID generated when idempotent publishing disabled + +**Spec requirement:** When `idempotentRestPublishing` is false, the library must not automatically generate message IDs. + +Tests that message IDs are not automatically generated when disabled. + +### Setup +```pseudo +channel_name = "test-RSL1k3-disabled-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "data") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +# No automatic ID should be added +ASSERT "id" NOT IN body +``` + +--- + +## RSL1k - Client-supplied ID preserved + +**Spec requirement:** Client-supplied message IDs must be preserved and transmitted exactly as provided, even when `idempotentRestPublishing` is enabled. + +Tests that client-supplied message IDs are not overwritten. + +### Setup +```pseudo +channel_name = "test-RSL1k-preserved-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true # Even with this enabled +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish( + message: Message(id: "my-custom-id", name: "event", data: "data") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +# Client-supplied ID should be preserved exactly +ASSERT body["id"] == "my-custom-id" +``` + +--- + +## RSL1k2 - Same ID used on retry + +**Spec requirement:** When a publish request is retried after a failure, the same message ID(s) must be used to ensure idempotent behavior. + +Tests that the same message ID is used when retrying after failure. + +### Setup +```pseudo +channel_name = "test-RSL1k2-retry-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + + # First request fails with retryable error + IF request_count == 1: + req.respond_with(500, { "error": { "code": 50000 } }) + ELSE: + # Retry succeeds + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "data") +``` + +### Assertions +```pseudo +ASSERT request_count == 2 + +body1 = parse_json(captured_requests[0].body)[0] +body2 = parse_json(captured_requests[1].body)[0] + +# Same ID should be used for retry +ASSERT body1["id"] == body2["id"] +``` + +--- + +## RSL1k - Mixed client and library IDs in batch + +**Spec requirement:** In a batch publish, messages with client-supplied IDs must be preserved, while messages without IDs receive library-generated IDs using the standard format. + +Tests batch publishing with some messages having client IDs and some not. + +### Setup +```pseudo +channel_name = "test-RSL1k-mixed-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + idempotentRestPublishing: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(id: "client-id-1", name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), # No ID - should be generated + Message(id: "client-id-2", name: "event3", data: "data3") +] +AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body) + +# Client IDs preserved +ASSERT body[0]["id"] == "client-id-1" +ASSERT body[2]["id"] == "client-id-2" + +# Library-generated ID for middle message +ASSERT body[1]["id"] matches pattern "[A-Za-z0-9_-]+:[0-9]+" +``` diff --git a/uts/rest/unit/channel/message_versions.md b/uts/rest/unit/channel/message_versions.md new file mode 100644 index 000000000..caa8aa4b9 --- /dev/null +++ b/uts/rest/unit/channel/message_versions.md @@ -0,0 +1,173 @@ +# REST Channel GetMessageVersions Tests + +Spec points: `RSL14`, `RSL14a`, `RSL14a1`, `RSL14b`, `RSL14c` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL14b — getMessageVersions sends GET to correct endpoint + +**Spec requirement:** RSL14b — The SDK must send a GET request to the endpoint `/channels/{channelName}/messages/{serial}/versions`. + +Tests that `getMessageVersions()` sends a GET to the correct URL. + +### Setup +```pseudo +channel_name = "test-RSL14b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "name": "evt", + "data": "v2-data", + "serial": "msg-serial-1", + "action": 1, + "version": { "serial": "vs2", "timestamp": 1700000002000 } + }, + { + "name": "evt", + "data": "v1-data", + "serial": "msg-serial-1", + "action": 0, + "version": { "serial": "vs1", "timestamp": 1700000001000 } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessageVersions("msg-serial-1") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1/versions" +``` + +--- + +## RSL14c — getMessageVersions returns PaginatedResult of Messages + +**Spec requirement:** RSL14c — Returns a `PaginatedResult`. + +Tests that the response is parsed into a paginated result of decoded messages. + +### Setup +```pseudo +channel_name = "test-RSL14c-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + "name": "evt", + "data": "updated-data", + "serial": "msg-serial-1", + "action": 1, + "version": { + "serial": "vs2", + "timestamp": 1700000002000, + "clientId": "user-1", + "description": "edit" + } + }, + { + "name": "evt", + "data": "original-data", + "serial": "msg-serial-1", + "action": 0, + "version": { + "serial": "vs1", + "timestamp": 1700000001000 + } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessageVersions("msg-serial-1") +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 + +ASSERT result.items[0] IS Message +ASSERT result.items[0].data == "updated-data" +ASSERT result.items[0].action == MessageAction.MESSAGE_UPDATE +ASSERT result.items[0].version.serial == "vs2" +ASSERT result.items[0].version.description == "edit" + +ASSERT result.items[1].data == "original-data" +ASSERT result.items[1].action == MessageAction.MESSAGE_CREATE +``` + +--- + +## RSL14a — getMessageVersions passes params as querystring + +**Spec requirement:** RSL14a — Takes an optional second argument of `Dict` params. + +Tests that optional params are sent as query parameters. + +### Setup +```pseudo +channel_name = "test-RSL14a-params-${random_id()}" +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")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.getMessageVersions("msg-serial-1", params: { + "direction": "backwards", + "limit": "10" +}) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["direction"] == "backwards" +ASSERT request.url.query_params["limit"] == "10" +``` diff --git a/uts/rest/unit/channel/publish.md b/uts/rest/unit/channel/publish.md new file mode 100644 index 000000000..d51a1c62f --- /dev/null +++ b/uts/rest/unit/channel/publish.md @@ -0,0 +1,458 @@ +# REST Channel Publish Tests + +Spec points: `RSL1`, `RSL1a`, `RSL1b`, `RSL1c`, `RSL1d`, `RSL1e`, `RSL1h`, `RSL1i`, `RSL1j`, `RSL1l`, `RSL1m` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock must support: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` callbacks +- Request capture via `captured_requests` arrays +- Request counting via `request_count` variables +- Response configuration with status, headers, and body + +See rest_client.md for the complete `MockHttpClient` interface specification. + +--- + +## RSL1a, RSL1b - Publish with name and data + +| Spec | Requirement | +|------|-------------| +| RSL1a | Channel publish method must support publishing a single message with name and data | +| RSL1b | Single message publish must send the message in an array via POST to `/channels//messages` | + +Tests that `publish(name, data)` sends a single message. + +### Setup +```pseudo +channel_name = "test-RSL1a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["serial1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "greeting", data: "hello") +``` + +### Assertions +```pseudo +request = captured_requests[0] + +# RSL1b - single message published +ASSERT request.method == "POST" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages" + +body = parse_json(request.body) +ASSERT body IS List +# NOTE: Some SDKs send a single message as a plain JSON object rather than +# wrapping it in an array. The Ably API accepts both formats. SDKs MAY send +# a single message as either an object or a single-element array. +ASSERT body.length == 1 +ASSERT body[0]["name"] == "greeting" +ASSERT body[0]["data"] == "hello" +``` + +--- + +## RSL1a, RSL1c - Publish with Message array + +| Spec | Requirement | +|------|-------------| +| RSL1a | Channel publish method must support publishing an array of Message objects | +| RSL1c | Publishing multiple messages must send all messages in a single HTTP request | + +Tests that `publish(messages: [...])` sends all messages in a single request. + +### Setup +```pseudo +channel_name = "test-RSL1c-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: { "key": "value" }), + Message(name: "event3", data: bytes([0x01, 0x02, 0x03])) +] +AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +# RSL1c - single request for array +ASSERT request_count == 1 + +request = captured_requests[0] +body = parse_json(request.body) + +ASSERT body.length == 3 +ASSERT body[0]["name"] == "event1" +ASSERT body[0]["data"] == "data1" +ASSERT body[1]["name"] == "event2" +ASSERT body[1]["data"] == { "key": "value" } +# Note: binary data encoding tested separately in encoding tests +``` + +--- + +## RSL1e - Null name and data + +**Spec requirement:** Null values for name and data must be omitted from the transmitted message JSON, not sent as JSON null. + +Tests that null values are omitted from the transmitted message. + +### Setup +```pseudo +channel_name = "test-RSL1e-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Cases + +| ID | name | data | Expected body | +|----|------|------|---------------| +| 1 | `null` | `"hello"` | `[{"data": "hello"}]` | +| 2 | `"event"` | `null` | `[{"name": "event"}]` | +| 3 | `null` | `null` | `[{}]` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + AWAIT channel.publish(name: test_case.name, data: test_case.data) + + body = parse_json(captured_requests[0].body) + ASSERT body == [test_case.expected_body] + ASSERT "name" NOT IN body[0] IF test_case.name IS null + ASSERT "data" NOT IN body[0] IF test_case.data IS null +``` + +--- + +## RSL1h - publish(name, data) signature + +**Spec requirement:** The publish method must support a two-argument signature `publish(name, data)` for publishing a single message. + +Tests that the two-argument form takes no additional arguments and works correctly. + +### Setup +```pseudo +channel_name = "test-RSL1h-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# This is a compile-time/type-system test in strongly-typed languages +# The API should accept exactly (name, data) with no extras +AWAIT channel.publish(name: "event", data: "payload") +# If language allows, verify that extra positional args are rejected at compile time +``` + +### Assertions +```pseudo +ASSERT request_count == 1 +body = parse_json(captured_requests[0].body) +ASSERT body[0]["name"] == "event" +ASSERT body[0]["data"] == "payload" +``` + +--- + +## RSL1i - Message size limit + +**Spec requirement:** Messages exceeding the `maxMessageSize` client option must be rejected before transmission with error code 40009. + +Tests that messages exceeding `maxMessageSize` are rejected with error 40009. + +### Setup +```pseudo +channel_name = "test-RSL1i-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count = request_count + 1 + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + maxMessageSize: 1024 # 1KB limit for testing +)) +channel = client.channels.get(channel_name) +``` + +### Test Cases + +| ID | Message size | Expected | +|----|--------------|----------| +| 1 | 1000 bytes | Success (under limit) | +| 2 | 1024 bytes | Success (at limit) | +| 3 | 1025 bytes | Error 40009 | +| 4 | 10000 bytes | Error 40009 | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + request_count = 0 + + large_data = "x" * test_case.size + + IF test_case.expected == "Success": + AWAIT channel.publish(name: "event", data: large_data) + ASSERT request_count == 1 + ELSE: + ASSERT channel.publish(name: "event", data: large_data) THROWS AblyException WITH: + code == 40009 + ASSERT request_count == 0 # Request never sent +``` + +--- + +## RSL1j - All Message attributes transmitted + +**Spec requirement:** All valid Message attributes (name, data, id, clientId, extras) must be included in the transmitted message payload. + +Tests that all valid Message attributes are included in the encoded message. + +### Setup +```pseudo +channel_name = "test-RSL1j-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +message = Message( + name: "test-event", + data: "test-data", + clientId: "explicit-client-id", # RSL1m tests cover whether this should be sent + id: "custom-message-id", + extras: { "push": { "notification": { "title": "Test" } } } +) + +AWAIT channel.publish(message: message) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body)[0] + +ASSERT body["name"] == "test-event" +ASSERT body["data"] == "test-data" +ASSERT body["id"] == "custom-message-id" +ASSERT body["extras"]["push"]["notification"]["title"] == "Test" +# clientId handling is tested separately in RSL1m tests +``` + +--- + +## RSL1l - Publish params as querystring + +**Spec requirement:** Additional params passed to the publish method must be sent as query string parameters in the HTTP request. + +Tests that additional params are sent as querystring parameters. + +### Setup +```pseudo +channel_name = "test-RSL1l-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +params = { + "customParam": "customValue", + "anotherParam": "123" +} + +AWAIT channel.publish( + message: Message(name: "event", data: "data"), + params: params +) +``` + +### Assertions +```pseudo +request = captured_requests[0] + +ASSERT request.url.query_params["customParam"] == "customValue" +ASSERT request.url.query_params["anotherParam"] == "123" +``` + +--- + +## RSL1m - ClientId not set from library clientId + +| Spec | Requirement | +|------|-------------| +| RSL1m1 | Library must not automatically inject its clientId into messages that don't have one | +| RSL1m2 | Explicit message clientId must be preserved even if it matches library clientId | +| RSL1m3 | Unidentified clients (no library clientId) can publish messages with explicit clientId | + +Tests that the library does not automatically set `Message.clientId` from the client's configured `clientId`. + +### Setup +```pseudo +channel_name = "test-RSL1m-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "library-client-id" +)) +channel = client.channels.get(channel_name) +``` + +### Test Cases (RSL1m1-RSL1m3) + +| ID | Spec | Message clientId | Library clientId | Expected in request | +|----|------|------------------|------------------|---------------------| +| RSL1m1 | Message with no clientId, library has clientId | `null` | `"lib-client"` | clientId absent | +| RSL1m2 | Message clientId matches library clientId | `"lib-client"` | `"lib-client"` | `"lib-client"` | +| RSL1m3 | Unidentified client, message has clientId | `"msg-client"` | `null` | `"msg-client"` | + +### Test Steps +```pseudo +channel_name_m1 = "test-RSL1m1-${random_id()}" +channel_name_m2 = "test-RSL1m2-${random_id()}" +channel_name_m3 = "test-RSL1m3-${random_id()}" + +# RSL1m1 - Message with no clientId +captured_requests = [] + +client_with_id = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "lib-client" +)) +AWAIT client_with_id.channels.get(channel_name_m1).publish(name: "e", data: "d") + +body = parse_json(captured_requests[0].body)[0] +ASSERT "clientId" NOT IN body # Library should not inject its clientId + + +# RSL1m2 - Message clientId matches library +captured_requests = [] + +AWAIT client_with_id.channels.get(channel_name_m2).publish( + message: Message(name: "e", data: "d", clientId: "lib-client") +) + +body = parse_json(captured_requests[0].body)[0] +ASSERT body["clientId"] == "lib-client" # Explicit clientId preserved + + +# RSL1m3 - Unidentified client with message clientId +captured_requests = [] + +client_no_id = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +AWAIT client_no_id.channels.get(channel_name_m3).publish( + message: Message(name: "e", data: "d", clientId: "msg-client") +) + +body = parse_json(captured_requests[0].body)[0] +ASSERT body["clientId"] == "msg-client" +``` + +### Note +RSL1m4 (clientId mismatch rejection) requires an integration test as the server performs the validation. diff --git a/uts/rest/unit/channel/publish_result.md b/uts/rest/unit/channel/publish_result.md new file mode 100644 index 000000000..d454d0010 --- /dev/null +++ b/uts/rest/unit/channel/publish_result.md @@ -0,0 +1,139 @@ +# REST Channel Publish Result Tests + +Spec points: `RSL1n`, `RSL1n1`, `PBR1`, `PBR2a` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL1n — publish() returns PublishResult with serials (single message) + +| Spec | Requirement | +|------|-------------| +| RSL1n | On success, returns a `PublishResult` containing the serials of the published messages | +| PBR2a | `serials` is an array of `String?` corresponding 1:1 to the messages that were published | + +Tests that `publish()` returns a `PublishResult` with a serials array matching the published messages. + +### Setup +```pseudo +channel_name = "test-RSL1n-single-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["serial-abc"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.publish(name: "event", data: "hello") +``` + +### Assertions +```pseudo +ASSERT result IS PublishResult +ASSERT result.serials IS List +ASSERT result.serials.length == 1 +ASSERT result.serials[0] == "serial-abc" +``` + +--- + +## RSL1n — publish() returns PublishResult with serials (batch) + +**Spec requirement:** RSL1n — When publishing multiple messages, the returned `PublishResult.serials` array has one entry per message, corresponding 1:1. + +Tests that batch publish returns serials matching each published message. + +### Setup +```pseudo +channel_name = "test-RSL1n-batch-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, { "serials": ["s1", "s2", "s3"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +] +result = AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +ASSERT result IS PublishResult +ASSERT result.serials.length == 3 +ASSERT result.serials[0] == "s1" +ASSERT result.serials[1] == "s2" +ASSERT result.serials[2] == "s3" +``` + +--- + +## RSL1n — publish() returns PublishResult with null serial (conflated message) + +| Spec | Requirement | +|------|-------------| +| PBR2a | A serial may be null if the message was discarded due to a configured conflation rule | + +Tests that null serials in the response are preserved in the `PublishResult`. + +### Setup +```pseudo +channel_name = "test-RSL1n-null-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, { "serials": [null, "s2"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +messages = [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2") +] +result = AWAIT channel.publish(messages: messages) +``` + +### Assertions +```pseudo +ASSERT result.serials.length == 2 +ASSERT result.serials[0] IS null +ASSERT result.serials[1] == "s2" +``` diff --git a/uts/rest/unit/channel/rest_channel_attributes.md b/uts/rest/unit/channel/rest_channel_attributes.md new file mode 100644 index 000000000..ba70406fc --- /dev/null +++ b/uts/rest/unit/channel/rest_channel_attributes.md @@ -0,0 +1,280 @@ +# REST Channel Attributes and Methods + +Spec points: `RSL7`, `RSL8`, `RSL8a`, `RSL9` + +## 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. + +--- + +## RSL9 - RestChannel name attribute + +**Spec requirement:** `RestChannel#name` attribute is a string containing the channel's name. + +Tests that the channel name attribute returns the name used when getting the channel. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) +``` + +### Assertions +```pseudo +channel = client.channels.get("my-channel") +ASSERT channel.name == "my-channel" + +# Also works with special characters +channel2 = client.channels.get("namespace:channel-name") +ASSERT channel2.name == "namespace:channel-name" +``` + +--- + +## RSL7 - setOptions updates channel options + +**Spec requirement:** `RestChannel#setOptions` takes a `ChannelOptions` object and sets or updates the stored channel options, then indicates success. + +Tests that setOptions updates the stored channel options. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL7") +``` + +### Test Steps +```pseudo +AWAIT channel.setOptions(RestChannelOptions()) +``` + +### Assertions +```pseudo +# setOptions completes without error (indicates success) +# No exception thrown +``` + +--- + +## RSL7 - setOptions stores new options + +**Spec requirement:** `RestChannel#setOptions` sets or updates the stored channel options. + +Tests that options set via setOptions are retained and accessible. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL7-store") +``` + +### Test Steps +```pseudo +# Set options — the effect of channel options is primarily on encryption +# (RSL5) which is not yet implemented. For now, verify the call succeeds +# and options are stored by observing they can be set without error. +AWAIT channel.setOptions(RestChannelOptions()) +``` + +### Assertions +```pseudo +# setOptions completes without error +# Implementation note: once encryption is supported (RSL5), this test +# should verify that cipher params set via setOptions are applied to +# subsequent publish/history operations. +``` + +--- + +## RSL8 - status makes GET request to correct endpoint + +**Spec requirement:** `RestChannel#status` function makes an HTTP GET request to `/channels/`. + +Tests that calling status() sends a GET request to the correct URL path. + +### Setup +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, { + "channelId": "test-RSL8", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 0, + "publishers": 0, + "subscribers": 0, + "presenceConnections": 0, + "presenceMembers": 0, + "presenceSubscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL8") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# Correct HTTP method and path +ASSERT captured_request IS NOT null +ASSERT captured_request.method == "GET" +ASSERT captured_request.url.path ENDS_WITH "/channels/test-RSL8" +``` + +--- + +## RSL8 - status with special characters in channel name + +**Spec requirement:** The channel ID in the URL must be properly encoded. + +Tests that channel names with special characters are URL-encoded in the status request. + +### Setup +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, { + "channelId": "namespace:my channel", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 0, + "publishers": 0, + "subscribers": 0, + "presenceConnections": 0, + "presenceMembers": 0, + "presenceSubscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("namespace:my channel") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +ASSERT captured_request IS NOT null +ASSERT captured_request.method == "GET" +# Channel name must be URI-encoded in the path +ASSERT captured_request.url.path ENDS_WITH "/channels/" + encode_uri_component("namespace:my channel") +``` + +--- + +## RSL8a - status returns ChannelDetails object + +**Spec requirement:** `RestChannel#status` returns a `ChannelDetails` object. + +Tests that the status() response is parsed into a ChannelDetails object with correct attributes. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "channelId": "test-RSL8a", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 5, + "publishers": 2, + "subscribers": 3, + "presenceConnections": 1, + "presenceMembers": 1, + "presenceSubscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-RSL8a") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# Result is a ChannelDetails object (CHD1) +ASSERT result IS ChannelDetails + +# CHD2a: channelId attribute +ASSERT result.channelId == "test-RSL8a" + +# CHD2b: status attribute is a ChannelStatus (CHS1) +ASSERT result.status IS NOT null +ASSERT result.status.isActive == true + +# CHS2b: occupancy metrics +ASSERT result.status.occupancy IS NOT null +ASSERT result.status.occupancy.metrics.connections == 5 +ASSERT result.status.occupancy.metrics.publishers == 2 +ASSERT result.status.occupancy.metrics.subscribers == 3 +``` diff --git a/uts/rest/unit/channel/update_delete_message.md b/uts/rest/unit/channel/update_delete_message.md new file mode 100644 index 000000000..fdab5a448 --- /dev/null +++ b/uts/rest/unit/channel/update_delete_message.md @@ -0,0 +1,528 @@ +# REST Channel UpdateMessage/DeleteMessage/AppendMessage Tests + +Spec points: `RSL15`, `RSL15a`, `RSL15b`, `RSL15b1`, `RSL15b7`, `RSL15c`, `RSL15d`, `RSL15e`, `RSL15f` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/helpers/mock_http.md`. + +--- + +## RSL15b, RSL15b1 — updateMessage sends PATCH with action MESSAGE_UPDATE + +| Spec | Requirement | +|------|-------------| +| RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | +| RSL15b1 | `action` set to `MESSAGE_UPDATE` for `updateMessage()` | + +Tests that `updateMessage()` sends a PATCH request with the correct action. + +### Setup +```pseudo +channel_name = "test-RSL15-update-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", name: "updated", data: "new-data") +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1" + +body = parse_json(request.body) +ASSERT body["action"] == 1 # MESSAGE_UPDATE numeric value +ASSERT body["name"] == "updated" +ASSERT body["data"] == "new-data" +``` + +--- + +## RSL15b, RSL15b1 — deleteMessage sends PATCH with action MESSAGE_DELETE + +| Spec | Requirement | +|------|-------------| +| RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | +| RSL15b1 | `action` set to `MESSAGE_DELETE` for `deleteMessage()` | + +Tests that `deleteMessage()` sends a PATCH request with the correct action. + +### Setup +```pseudo +channel_name = "test-RSL15-delete-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.deleteMessage( + Message(serial: "msg-serial-1") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1" + +body = parse_json(request.body) +ASSERT body["action"] == 2 # MESSAGE_DELETE numeric value +``` + +--- + +## RSL15b, RSL15b1 — appendMessage sends PATCH with action MESSAGE_APPEND + +| Spec | Requirement | +|------|-------------| +| RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | +| RSL15b1 | `action` set to `MESSAGE_APPEND` for `appendMessage()` | + +Tests that `appendMessage()` sends a PATCH request with the correct action. + +### Setup +```pseudo +channel_name = "test-RSL15-append-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.appendMessage( + Message(serial: "msg-serial-1", data: "appended-data") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.method == "PATCH" +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/msg-serial-1" + +body = parse_json(request.body) +ASSERT body["action"] == 5 # MESSAGE_APPEND numeric value +ASSERT body["data"] == "appended-data" +``` + +--- + +## RSL15b7 — version set to MessageOperation when provided + +**Spec requirement:** RSL15b7 — `version` is set to the `MessageOperation` object if provided. + +Tests that the `version` field in the request body contains the MessageOperation fields. + +### Setup +```pseudo +channel_name = "test-RSL15b7-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated"), + operation: MessageOperation( + clientId: "user1", + description: "fixed typo", + metadata: { "reason": "typo" } + ) +) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +ASSERT "version" IN body +ASSERT body["version"]["clientId"] == "user1" +ASSERT body["version"]["description"] == "fixed typo" +ASSERT body["version"]["metadata"]["reason"] == "typo" +``` + +--- + +## RSL15b7 — version absent when no MessageOperation provided + +**Spec requirement:** RSL15b7 — `version` is only set when a `MessageOperation` is provided. + +Tests that `version` is omitted from the request body when no operation is given. + +### Setup +```pseudo +channel_name = "test-RSL15b7-absent-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated") +) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) +ASSERT "version" NOT IN body +``` + +--- + +## RSL15c — does not mutate user-supplied Message + +**Spec requirement:** RSL15c — The SDK must not mutate the user-supplied `Message` object. + +Tests that the original message object is unchanged after calling `updateMessage()`. + +### Setup +```pseudo +channel_name = "test-RSL15c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +original_msg = Message(serial: "s1", name: "orig", data: "original-data") + +AWAIT channel.updateMessage(original_msg) +``` + +### Assertions +```pseudo +# Original message must not have been mutated +ASSERT original_msg.action IS null # No action was set on original +ASSERT original_msg.name == "orig" +ASSERT original_msg.data == "original-data" + +# But the request body should contain the action +body = parse_json(captured_requests[0].body) +ASSERT body["action"] == 1 # MESSAGE_UPDATE +``` + +--- + +## RSL15e — returns UpdateDeleteResult on success + +| Spec | Requirement | +|------|-------------| +| RSL15e | On success, returns an `UpdateDeleteResult` object | +| UDR2a | `versionSerial` `String?` — the new version serial of the updated/deleted message | + +Tests that the response is parsed into an `UpdateDeleteResult`. + +### Setup +```pseudo +channel_name = "test-RSL15e-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "versionSerial": "version-serial-abc" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated") +) +``` + +### Assertions +```pseudo +ASSERT result IS UpdateDeleteResult +ASSERT result.versionSerial == "version-serial-abc" +``` + +--- + +## RSL15e — UpdateDeleteResult with null versionSerial + +**Spec requirement:** UDR2a — `versionSerial` will be null if the message was superseded by a subsequent update before it could be published. + +Tests that a null `versionSerial` in the response is preserved. + +### Setup +```pseudo +channel_name = "test-RSL15e-null-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "versionSerial": null }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +result = AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated") +) +``` + +### Assertions +```pseudo +ASSERT result IS UpdateDeleteResult +ASSERT result.versionSerial IS null +``` + +--- + +## RSL15f — params sent as querystring + +**Spec requirement:** RSL15f — Any params provided in the third argument must be sent in the querystring, with values stringified. + +Tests that optional params are sent as query parameters. + +### Setup +```pseudo +channel_name = "test-RSL15f-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: "s1", data: "updated"), + params: { "key": "value", "num": "42" } +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["key"] == "value" +ASSERT request.url.query_params["num"] == "42" +``` + +--- + +## RSL15a — serial required, throws error if missing + +**Spec requirement:** RSL15a — Takes a first argument of a `Message` object which must contain a populated `serial` field. + +Tests that calling update/delete/append without a serial in the message throws an error. + +### Setup +```pseudo +channel_name = "test-RSL15a-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# updateMessage without serial +AWAIT channel.updateMessage(Message(name: "x", data: "y")) FAILS WITH error +ASSERT error.code == 40003 + +# deleteMessage without serial +AWAIT channel.deleteMessage(Message(name: "x")) FAILS WITH error +ASSERT error.code == 40003 + +# appendMessage without serial +AWAIT channel.appendMessage(Message(data: "y")) FAILS WITH error +ASSERT error.code == 40003 +``` + +--- + +## RSL15d — request body encoded per RSL4 (message data encoding) + +| Spec | Requirement | +|------|-------------| +| RSL15d | The request body must be encoded to the appropriate format per RSC8 | +| RSL15b | Request body is a `Message` object encoded per RSL4 | + +Tests that message data is encoded following the same rules as regular publish. + +### Setup +```pseudo +channel_name = "test-RSL15d-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# JSON object data should be encoded per RSL4 +AWAIT channel.updateMessage( + Message(serial: "s1", data: { "key": "value" }) +) +``` + +### Assertions +```pseudo +body = parse_json(captured_requests[0].body) + +# JSON data should be JSON-encoded as a string with encoding field +ASSERT body["data"] IS String # JSON-encoded string +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == { "key": "value" } +``` + +--- + +## RSL15b — serial URL-encoded in path + +**Spec requirement:** RSL15b — The serial in the PATCH URL must be properly URL-encoded. + +Tests that special characters in the message serial are URL-encoded in the request path. + +### Setup +```pseudo +channel_name = "test-RSL15b-encode-${random_id()}" +captured_requests = [] +serial_with_special_chars = "serial/special:chars" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { "versionSerial": "vs1" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.updateMessage( + Message(serial: serial_with_special_chars, data: "updated") +) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages/" + encode_uri_component(serial_with_special_chars) +``` diff --git a/uts/rest/unit/channels_collection.md b/uts/rest/unit/channels_collection.md new file mode 100644 index 000000000..d3ff58456 --- /dev/null +++ b/uts/rest/unit/channels_collection.md @@ -0,0 +1,259 @@ +# REST Channels Collection Tests + +Spec points: `RSN1`, `RSN2`, `RSN3a`, `RSN3b`, `RSN3c`, `RSN4a`, `RSN4b` + +## Test Type +Unit test - no network calls required + +These tests verify the REST channels collection management functionality. No mock infrastructure is needed as these tests focus on the in-memory collection behavior. + +--- + +## RSN1 - Channels collection accessible via RestClient + +**Spec requirement:** `Channels` is a collection of `RestChannel` objects accessible through `RestClient#channels`. + +Tests that the Rest client exposes a channels collection. + +### Setup +```pseudo +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Assertions +```pseudo +ASSERT client.channels IS RestChannels +ASSERT client.channels IS NOT null +``` + +--- + +## RSN2 - Check if channel exists + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests the `exists()` method returns correct boolean for existing and non-existing channels. + +### Setup +```pseudo +channel_name = "test-RSN2-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Before creating any channel +exists_before = client.channels.exists(channel_name) + +# Create the channel +channel = client.channels.get(channel_name) + +# After creating the channel +exists_after = client.channels.exists(channel_name) + +# Check for non-existent channel +other_channel_name = "test-RSN2-other-${random_id()}" +exists_other = client.channels.exists(other_channel_name) +``` + +### Assertions +```pseudo +ASSERT exists_before == false +ASSERT exists_after == true +ASSERT exists_other == false +``` + +--- + +## RSN2 - Iterate through existing channels + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests that channels can be iterated. + +### Setup +```pseudo +channel_name_a = "test-RSN2-a-${random_id()}" +channel_name_b = "test-RSN2-b-${random_id()}" +channel_name_c = "test-RSN2-c-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create several channels +client.channels.get(channel_name_a) +client.channels.get(channel_name_b) +client.channels.get(channel_name_c) + +# Iterate channels +channel_names = [ch.name FOR ch IN client.channels] +``` + +### Assertions +```pseudo +ASSERT channel_name_a IN channel_names +ASSERT channel_name_b IN channel_names +ASSERT channel_name_c IN channel_names +ASSERT length(channel_names) == 3 +``` + +--- + +## RSN3a - Get creates new channel if none exists + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. `ChannelOptions` can be provided in an optional second argument. + +### Setup +```pseudo +channel_name = "test-RSN3a-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel IS RestChannel +ASSERT channel.name == channel_name +ASSERT client.channels.exists(channel_name) == true +``` + +--- + +## RSN3a - Get returns existing channel + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. + +### Setup +```pseudo +channel_name = "test-RSN3a-existing-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel1 = client.channels.get(channel_name) +channel2 = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 # Same object reference +ASSERT channel1.name == channel_name +``` + +--- + +## RSN3a - Operator subscript creates or returns channel + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. + +### Setup +```pseudo +channel_name = "test-RSN3a-subscript-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel1 = client.channels[channel_name] +channel2 = client.channels.get(channel_name) +channel3 = client.channels[channel_name] +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 +ASSERT channel2 IS SAME AS channel3 +ASSERT channel1.name == channel_name +``` + +--- + +## RSN4a - Release removes channel + +**Spec requirement:** Takes one argument, the channel name, and releases the corresponding channel entity (that is, deletes it to allow it to be garbage collected). + +### Setup +```pseudo +channel_name = "test-RSN4a-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +client.channels.get(channel_name) +ASSERT client.channels.exists(channel_name) == true + +client.channels.release(channel_name) +``` + +### Assertions +```pseudo +ASSERT client.channels.exists(channel_name) == false +``` + +--- + +## RSN4b - Release on non-existent channel is no-op + +**Spec requirement:** Calling `release()` with a channel name that does not correspond to an extant channel entity must return without error. + +### Setup +```pseudo +channel_name = "test-RSN4b-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Release a channel that was never created — should not throw +client.channels.release(channel_name) +``` + +### Assertions +```pseudo +ASSERT client.channels.exists(channel_name) == false +``` + +--- + +## RSN3a - Get after release creates new channel + +**Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists. + +Tests that getting a channel after release creates a fresh instance. + +### Setup +```pseudo +channel_name = "test-RSN3a-release-${random_id()}" + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channel1 = client.channels.get(channel_name) + +client.channels.release(channel_name) + +channel2 = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel1 IS NOT SAME AS channel2 # Different object instances +ASSERT channel2.name == channel_name +ASSERT client.channels.exists(channel_name) == true +``` diff --git a/uts/rest/unit/encoding/message_encoding.md b/uts/rest/unit/encoding/message_encoding.md new file mode 100644 index 000000000..6ef793b8e --- /dev/null +++ b/uts/rest/unit/encoding/message_encoding.md @@ -0,0 +1,961 @@ +# Message Encoding Tests + +Spec points: `RSL4`, `RSL4a`, `RSL4b`, `RSL4c`, `RSL4d`, `RSL6`, `RSL6a`, `RSL6b` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure described in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock supports: +- Intercepting HTTP requests and capturing details (URL, headers, method, body) +- Queueing responses with configurable status, headers, and body +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Recording requests in `captured_requests` arrays +- Request counting with `request_count` variables + +## Fixtures +Tests should use the encoding fixtures from `ably-common` where available for cross-SDK consistency. + +--- + +## RSL4a - String data encoding + +**Spec requirement:** String data must be transmitted without transformation and without an encoding field. + +### Setup +```pseudo +channel_name = "test-RSL4a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # Use JSON for easier inspection +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "plain string data") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["data"] == "plain string data" +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +## RSL4b - JSON object encoding + +**Spec requirement:** JSON objects must be serialized to a JSON string with `encoding: "json"`. + +### Setup +```pseudo +channel_name = "test-RSL4b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: { "key": "value", "nested": { "a": 1 } }) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +# Data should be JSON-serialized string +ASSERT body["data"] IS String +ASSERT parse_json(body["data"]) == { "key": "value", "nested": { "a": 1 } } +ASSERT body["encoding"] == "json" +``` + +--- + +## RSL4c - Binary data encoding with JSON protocol + +**Spec requirement:** Binary data must be base64-encoded when using JSON protocol. + +### Setup +```pseudo +channel_name = "test-RSL4c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON protocol requires base64 for binary +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +binary_data = bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +AWAIT channel.publish(name: "event", data: binary_data) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "base64" +ASSERT base64_decode(body["data"]) == bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +``` + +--- + +## RSL4c - Binary data with MessagePack protocol + +**Spec requirement:** Binary data must be transmitted directly (without base64 encoding) when using MessagePack protocol. + +### Setup +```pseudo +channel_name = "test-RSL4c-msgpack-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true # MessagePack +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +binary_data = bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +AWAIT channel.publish(name: "event", data: binary_data) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = msgpack_decode(request.body)[0] + +# Binary data should be transmitted directly, no base64 +ASSERT body["data"] == bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +## RSL4d - Array data encoding + +**Spec requirement:** Arrays must be JSON-encoded with `encoding: "json"`. + +### Setup +```pseudo +channel_name = "test-RSL4d-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: [1, 2, "three", { "four": 4 }]) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == [1, 2, "three", { "four": 4 }] +``` + +--- + +## RSL6a - Decoding base64 data + +**Spec requirement:** Data with `encoding: "base64"` must be decoded to binary, and the encoding field consumed. + +### Setup +```pseudo +channel_name = "test-RSL6a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "AAECAwQ=", # base64 of [0, 1, 2, 3, 4] + "encoding": "base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == bytes([0x00, 0x01, 0x02, 0x03, 0x04]) +ASSERT message.encoding IS null # Encoding consumed after decode +``` + +--- + +## RSL6a - Decoding JSON data + +**Spec requirement:** Data with `encoding: "json"` must be decoded from JSON string to native object, and the encoding field consumed. + +### Setup +```pseudo +channel_name = "test-RSL6a-json-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "{\"key\":\"value\",\"number\":42}", + "encoding": "json", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == { "key": "value", "number": 42 } +ASSERT message.encoding IS null +``` + +--- + +## RSL6a - Decoding chained encodings + +**Spec requirement:** Chained encodings (e.g., `json/base64`) must be decoded in reverse order (last applied encoding is removed first). When processing chained encodings, decoders MUST handle intermediate data types — for example, after decoding `base64`, the data will be binary bytes; a subsequent `json` decoder MUST convert those bytes to a UTF-8 string before JSON parsing. + +### Setup +```pseudo +channel_name = "test-RSL6a-chained-${random_id()}" +captured_requests = [] + +# Data: {"key":"value"} -> JSON string -> base64 encoded +json_string = "{\"key\":\"value\"}" +base64_of_json = base64_encode(json_string) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": base64_of_json, + "encoding": "json/base64", # Decode base64 first, then JSON + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == { "key": "value" } +ASSERT message.encoding IS null +``` + +--- + +## RSL6b - Unrecognized encoding preserved + +**Spec requirement:** Unrecognized encoding values must be preserved in the encoding field, with only recognized encodings being decoded. + +### Setup +```pseudo +channel_name = "test-RSL6b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "encrypted-data-here", + "encoding": "custom-encryption/base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +# base64 should be decoded, but custom-encryption is unrecognized +ASSERT message.encoding == "custom-encryption" +# Data should be base64-decoded but not further processed +ASSERT message.data IS bytes # Result of base64 decode +``` + +--- + +## RSL6 - Decoding binary data from MessagePack response + +**Spec requirement:** When the server returns a MessagePack response containing binary data (msgpack `bin` type), it must be decoded as binary, not as a string — even if the bytes are valid UTF-8. The msgpack wire format distinguishes `str` and `bin` types, and the SDK must preserve this distinction. + +### Setup +```pseudo +channel_name = "test-RSL6-msgpack-binary-${random_id()}" + +# Construct a msgpack response where the data field uses the msgpack +# bin type (raw bytes), NOT the str type. +binary_payload = bytes([0x48, 0x65, 0x6C, 0x6C, 0x6F]) # "Hello" as bytes — valid UTF-8 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, + body: msgpack_encode([ + { "name": "event", "data": binary_payload } # data as msgpack bin type + ]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +# Binary data must remain binary, NOT be converted to a string +ASSERT message.data IS Binary/Uint8List/[]byte +ASSERT message.data == bytes([0x48, 0x65, 0x6C, 0x6C, 0x6F]) +ASSERT message.encoding IS null +``` + +### Note +This test specifically validates that the SDK does not conflate msgpack `bin` +and `str` types during deserialization. A common bug is for SDKs to deserialize +both types as strings (since the bytes may be valid UTF-8), losing the type +distinction that the server intended. The msgpack `bin` type must always produce +the SDK's binary data type, and the msgpack `str` type must always produce a +string. + +--- + +## RSL6 - Decoding string data from MessagePack response + +**Spec requirement:** When the server returns a MessagePack response containing string data (msgpack `str` type), it must be decoded as a string — not as binary. + +### Setup +```pseudo +channel_name = "test-RSL6-msgpack-string-${random_id()}" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, + body: msgpack_encode([ + { "name": "event", "data": "Hello World" } # data as msgpack str type + ]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data IS String +ASSERT message.data == "Hello World" +ASSERT message.encoding IS null +``` + +--- + +## RSL4 - Encoding fixtures from ably-common + +**Spec requirement:** Implementations must correctly encode data according to standardized test fixtures from `ably-common`. + +### Setup +```pseudo +# Load fixtures from ably-common/test-resources/... +encoding_fixtures = load_fixtures("encoding.json") +``` + +### Test Steps +```pseudo +FOR EACH fixture IN encoding_fixtures: + channel_name = "test-RSL4-fixture-${random_id()}" + captured_requests = [] + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: fixture.use_binary_protocol + )) + channel = client.channels.get(channel_name) + + # Publish with input data + AWAIT channel.publish(name: "event", data: fixture.input_data) + + # Verify encoded format + request = captured_requests[0] + + IF fixture.use_binary_protocol: + body = msgpack_decode(request.body)[0] + ELSE: + body = parse_json(request.body)[0] + + ASSERT body["data"] == fixture.expected_wire_data + ASSERT body["encoding"] == fixture.expected_encoding +``` + +--- + +## Additional Encoding Tests + +### RSL4 - Null data encoding + +**Spec requirement:** Null values must be transmitted without transformation. + +### Setup +```pseudo +channel_name = "test-RSL4-null-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: null) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["data"] IS null +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +### RSL4a - Number data type rejected + +**Spec requirement (RSL4a):** Payloads must be binary, strings, or objects capable of JSON representation. Any other data type should not be permitted and result in an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: 42) FAILS WITH error +ASSERT error IS NOT null +``` + +--- + +### RSL4a - Boolean data type rejected + +**Spec requirement (RSL4a):** Payloads must be binary, strings, or objects capable of JSON representation. Any other data type should not be permitted and result in an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: true) FAILS WITH error +ASSERT error IS NOT null +``` + +--- + +### RSL6 - Decoding UTF-8 encoded data + +**Spec requirement:** Data with `encoding: "utf-8/base64"` must decode base64 first, then interpret as UTF-8 string. + +### Setup +```pseudo +channel_name = "test-RSL6-utf8-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": "SGVsbG8gV29ybGQ=", # base64 of UTF-8 "Hello World" + "encoding": "utf-8/base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +ASSERT message.data == "Hello World" +ASSERT message.data IS String +ASSERT message.encoding IS null +``` + +--- + +### RSL6 - Complex chained encoding + +**Spec requirement:** Multiple encoding layers must be decoded in correct order. + +### Setup +```pseudo +channel_name = "test-RSL6-complex-${random_id()}" +captured_requests = [] + +# Create data: object -> JSON -> UTF-8 bytes -> base64 +original_object = { "status": "active", "count": 5 } +json_string = to_json(original_object) +utf8_bytes = encode_utf8(json_string) +base64_data = base64_encode(utf8_bytes) + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "id": "msg1", + "name": "event", + "data": base64_data, + "encoding": "json/utf-8/base64", + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +message = history.items[0] +``` + +### Assertions +```pseudo +# Should decode: base64 -> utf-8 -> json +ASSERT message.data == { "status": "active", "count": 5 } +ASSERT message.encoding IS null +``` + +--- + +## Protocol Selection Tests + +### RSL4 - JSON protocol uses correct Content-Type + +**Spec requirement:** When `useBinaryProtocol: false`, requests must use `Content-Type: application/json`. + +### Setup +```pseudo +channel_name = "test-RSL4-json-ct-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Content-Type"] == "application/json" +ASSERT request.headers["Accept"] == "application/json" +``` + +--- + +### RSL4 - MessagePack protocol uses correct Content-Type + +**Spec requirement:** When `useBinaryProtocol: true`, requests must use `Content-Type: application/x-msgpack`. + +### Setup +```pseudo +channel_name = "test-RSL4-msgpack-ct-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "test") +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.headers["Content-Type"] == "application/x-msgpack" +ASSERT request.headers["Accept"] == "application/x-msgpack" +``` + +--- + +## Empty Data Tests + +### RSL4 - Empty string encoding + +**Spec requirement:** Empty strings must be transmitted as empty strings without encoding. + +### Setup +```pseudo +channel_name = "test-RSL4-empty-str-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: "") +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["data"] == "" +ASSERT "encoding" NOT IN body OR body["encoding"] IS null +``` + +--- + +### RSL4 - Empty array encoding + +**Spec requirement:** Empty arrays must be JSON-encoded. + +### Setup +```pseudo +channel_name = "test-RSL4-empty-arr-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: []) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == [] +``` + +--- + +### RSL4 - Empty object encoding + +**Spec requirement:** Empty objects must be JSON-encoded. + +### Setup +```pseudo +channel_name = "test-RSL4-empty-obj-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "serials": ["s1"] }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish(name: "event", data: {}) +``` + +### Assertions +```pseudo +request = captured_requests[0] +body = parse_json(request.body)[0] + +ASSERT body["encoding"] == "json" +ASSERT parse_json(body["data"]) == {} +``` diff --git a/uts/rest/unit/fallback.md b/uts/rest/unit/fallback.md new file mode 100644 index 000000000..998194aa8 --- /dev/null +++ b/uts/rest/unit/fallback.md @@ -0,0 +1,1398 @@ +# Host Fallback and Endpoint Configuration Tests + +Spec points: `RSC15`, `RSC15a`, `RSC15f`, `RSC15j`, `RSC15l`, `RSC15m`, `REC1`, `REC1a`, `REC1b`, `REC1b1`, `REC1b2`, `REC1b3`, `REC1b4`, `REC1c`, `REC1c1`, `REC1c2`, `REC1d`, `REC1d1`, `REC1d2`, `REC2`, `REC2a`, `REC2a1`, `REC2a2`, `REC2b`, `REC2c`, `REC2c1`, `REC2c2`, `REC2c3`, `REC2c4`, `REC2c5`, `REC2c6`, `REC3`, `REC3a`, `REC3b` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `rest_client.md` for the full Mock HTTP Infrastructure specification. These tests use the same `MockHttpClient` interface with `PendingConnection` and `PendingRequest`. + +Fallback tests require the mock to support: +- Connection-level failures (DNS, connection refused, timeout) +- Per-host or per-request response configuration +- Tracking multiple sequential requests to different hosts + +--- + +## RSC15m - Fallback only when fallback domains non-empty + +**Spec requirement:** Fallback retry is only attempted when fallback hosts are configured (non-empty list). + +Tests that fallback behavior is skipped when no fallback hosts are configured. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: [] # Explicitly empty +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Should fail without retry +ASSERT mock_http.captured_requests.length == 1 +ASSERT error.statusCode == 500 +``` + +--- + +## RSC15a - Fallback hosts tried in random order + +**Spec requirement:** When the primary host fails, fallback hosts must be tried in random order to distribute load. + +Tests that fallback hosts are tried when primary fails, in random order. + +### Setup +```pseudo +mock_http = MockHttpClient() +# All requests fail to test full fallback sequence +mock_http.queue_responses( + count: 6, # primary + 5 fallbacks + status: 500, + body: { "error": { "code": 50000 } } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail after all retries +``` + +### Assertions +```pseudo +requests = mock_http.captured_requests + +# First request to primary +ASSERT requests[0].url.host == "main.realtime.ably.net" + +# Subsequent requests to fallback hosts +fallback_hosts_used = [r.url.host FOR r IN requests[1:]] + +expected_fallbacks = [ + "main.a.fallback.ably-realtime.com", + "main.b.fallback.ably-realtime.com", + "main.c.fallback.ably-realtime.com", + "main.d.fallback.ably-realtime.com", + "main.e.fallback.ably-realtime.com" +] + +# All used hosts should be valid fallbacks +ASSERT ALL host IN fallback_hosts_used: host IN expected_fallbacks + +# To test randomness: run test multiple times and verify order varies +# (Implementation note: may need statistical test or seed control) +``` + +--- + +## RSC15l - Qualifying errors trigger fallback + +| Spec | Requirement | +|------|-------------| +| RSC15l1 | Host unreachable errors trigger fallback | +| RSC15l2 | Request timeout errors trigger fallback | +| RSC15l3 | HTTP 5xx status codes (500-504) trigger fallback | + +Tests that specific error conditions trigger fallback retry. + +### Test Cases + +| ID | Spec | Condition | Should Retry | +|----|------|-----------|--------------| +| 1 | RSC15l1 | Host unreachable | Yes | +| 2 | RSC15l2 | Request timeout | Yes | +| 3 | RSC15l3 | HTTP 500 | Yes | +| 4 | RSC15l3 | HTTP 501 | Yes | +| 5 | RSC15l3 | HTTP 502 | Yes | +| 6 | RSC15l3 | HTTP 503 | Yes | +| 7 | RSC15l3 | HTTP 504 | Yes | +| 8 | | HTTP 400 | No | +| 9 | | HTTP 401 | No | +| 10 | | HTTP 404 | No | + +### Setup (HTTP status codes) +```pseudo +FOR EACH test_case IN [500, 501, 502, 503, 504]: + mock_http = MockHttpClient() + mock_http.queue_response(test_case, { "error": { "code": test_case * 100 } }) + mock_http.queue_response(200, { "time": 1234567890000 }) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + AWAIT client.time() + + ASSERT mock_http.captured_requests.length == 2 + ASSERT mock_http.captured_requests[1].url.host != mock_http.captured_requests[0].url.host +``` + +### Setup (Non-retryable errors) +```pseudo +FOR EACH test_case IN [400, 401, 404]: + mock_http = MockHttpClient() + mock_http.queue_response(test_case, { "error": { "code": test_case * 100 } }) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + AWAIT client.time() FAILS WITH error + # Expected to fail + + # Should NOT have retried + ASSERT mock_http.captured_requests.length == 1 +``` + +### Setup (Timeout) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_timeout() # Simulates timeout +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 +)) + +AWAIT client.time() + +ASSERT mock_http.captured_requests.length == 2 +``` + +--- + +## RSC15l4 - CloudFront errors trigger fallback + +**Spec requirement:** Responses with a CloudFront Server header and status >= 400 must trigger fallback retry. + +Tests that responses with CloudFront server header and status >= 400 trigger fallback. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(403, + body: { "error": "Forbidden" }, + headers: { "Server": "CloudFront" } +) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +ASSERT mock_http.captured_requests[1].url.host != "main.realtime.ably.net" +``` + +--- + +## RSC15l - Comprehensive fallback scenarios with different error types + +These tests verify that fallback behavior works correctly for different network and HTTP error conditions. + +### RSC15l - Connection refused triggers fallback + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + request_count++ + IF request_count == 1: + # First attempt (primary host) - connection refused + conn.respond_with_refused() + ELSE: + # Fallback succeeds + conn.respond_with_success() + }, + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +result = AWAIT client.time() + +# Should have succeeded on fallback +ASSERT result IS valid +ASSERT request_count == 2 +``` + +### RSC15l - DNS error triggers fallback + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + request_count++ + IF request_count == 1: + # First attempt - DNS failure + conn.respond_with_dns_error() + ELSE: + # Fallback succeeds + conn.respond_with_success() + }, + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +result = AWAIT client.time() + +ASSERT result IS valid +ASSERT request_count == 2 +``` + +### RSC15l - Connection timeout triggers fallback + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + request_count++ + IF request_count == 1: + # First attempt - connection timeout + conn.respond_with_timeout() + ELSE: + # Fallback succeeds + conn.respond_with_success() + }, + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 +)) + +result = AWAIT client.time() + +ASSERT result IS valid +ASSERT request_count == 2 +``` + +### RSC15l - Request timeout triggers fallback + +```pseudo +request_count = 0 +captured_hosts = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + captured_hosts.append(conn.host) + conn.respond_with_success() + }, + onRequest: (req) => { + request_count++ + IF request_count == 1: + # First request times out + req.respond_with_timeout() + ELSE: + # Fallback succeeds + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 +)) + +result = AWAIT client.time() + +ASSERT result IS valid +ASSERT request_count == 2 +# Should have tried different hosts +ASSERT captured_hosts[0] != captured_hosts[1] +``` + +### RSC15l - HTTP 5xx errors trigger fallback + +```pseudo +FOR EACH status_code IN [500, 501, 502, 503, 504]: + request_count = 0 + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(status_code, {"error": {"code": status_code * 100}}) + ELSE: + req.respond_with(200, {"time": 1234567890000}) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + result = AWAIT client.time() + + ASSERT result IS valid + ASSERT request_count == 2 +``` + +### RSC15l - HTTP 4xx errors do NOT trigger fallback + +```pseudo +FOR EACH status_code IN [400, 401, 404]: + request_count = 0 + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + req.respond_with(status_code, {"error": {"code": status_code * 100}}) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + AWAIT client.time() FAILS WITH error + ASSERT error.statusCode == status_code + + # Should NOT have retried + ASSERT request_count == 1 +``` + +--- + +## RSC15j - Host header matches request host + +**Spec requirement:** The HTTP Host header must match the actual host being requested, including for fallback hosts. + +Tests that the Host header is set correctly for fallback requests. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +request_1 = mock_http.captured_requests[0] +request_2 = mock_http.captured_requests[1] + +# Host header should match the actual host being requested +ASSERT request_1.headers["Host"] == request_1.url.host +ASSERT request_2.headers["Host"] == request_2.url.host +ASSERT request_1.headers["Host"] != request_2.headers["Host"] +``` + +--- + +## RSC15f - Successful fallback host cached + +**Spec requirement:** When a fallback host succeeds, it should be cached and used for subsequent requests (for a limited time). + +Tests that after successful fallback, that host is used for subsequent requests. + +### Setup +```pseudo +mock_http = MockHttpClient() +# First request to primary fails +mock_http.queue_response_for_host("main.realtime.ably.net", 500, { "error": {} }) +# First fallback succeeds +mock_http.queue_response_for_host("main.a.fallback.ably-realtime.com", 200, { "time": 1000 }) +# Second request should go directly to cached fallback +mock_http.queue_response_for_host("main.a.fallback.ably-realtime.com", 200, { "time": 2000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackRetryTimeout: 60000 # 60 seconds +)) +``` + +### Test Steps +```pseudo +# First request - triggers fallback +result1 = AWAIT client.time() + +# Second request - should use cached fallback +result2 = AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 3 + +# Request 1: primary (failed) +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" + +# Request 2: fallback (succeeded) +ASSERT mock_http.captured_requests[1].url.host == "main.a.fallback.ably-realtime.com" + +# Request 3: cached fallback (no retry to primary) +ASSERT mock_http.captured_requests[2].url.host == "main.a.fallback.ably-realtime.com" +``` + +--- + +## RSC15f - Cached fallback expires after timeout + +**Spec requirement:** Cached fallback hosts must expire after `fallbackRetryTimeout` duration, after which the primary host is tried again. + +Tests that cached fallback host is cleared after `fallbackRetryTimeout`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_host("main.realtime.ably.net", 500, { "error": {} }) +mock_http.queue_response_for_host("main.a.fallback.ably-realtime.com", 200, { "time": 1000 }) +# After timeout, primary should be tried again +mock_http.queue_response_for_host("main.realtime.ably.net", 200, { "time": 2000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackRetryTimeout: 100 # 100ms for testing +)) +``` + +### Test Steps +```pseudo +# First request triggers fallback +AWAIT client.time() + +# Wait for timeout to expire +WAIT 150 milliseconds + +# Next request should try primary again +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 3 + +# After timeout, primary is tried again +ASSERT mock_http.captured_requests[2].url.host == "main.realtime.ably.net" +``` + +--- + +# REC1 - Primary Domain Configuration + +## REC1a - Default primary domain + +**Spec requirement:** When no endpoint configuration is provided, the default primary domain is `rest.ably.io` for REST and `realtime.ably.io` for Realtime. + +Tests that the default primary domain is used when no endpoint options are specified. + +> **Note:** The spec defines the legacy default as `rest.ably.io` for REST and `realtime.ably.io` for Realtime. SDKs adopting the new `endpoint` routing policy (REC1b) should use `main.realtime.ably.net` as the new default. SDKs still using the legacy `restHost`/`realtimeHost` pattern should assert against `rest.ably.io` / `realtime.ably.io` respectively. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +``` + +--- + +## REC1b2 - Endpoint option as explicit hostname (with period) + +Tests that when `endpoint` contains a period (`.`), it's treated as an explicit hostname. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "custom.ably.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "custom.ably.example.com" +``` + +--- + +## REC1b2 - Endpoint option as localhost + +Tests that `endpoint: "localhost"` is treated as an explicit hostname. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "localhost" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "localhost" +``` + +--- + +## REC1b2 - Endpoint option as IPv6 address + +Tests that `endpoint` containing `::` is treated as an explicit hostname (IPv6). + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "::1" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +# IPv6 addresses may be bracketed in URLs +ASSERT mock_http.captured_requests[0].url.host == "::1" OR + mock_http.captured_requests[0].url.host == "[::1]" +``` + +--- + +## REC1b3 - Endpoint option as nonprod routing policy + +Tests that `endpoint: "nonprod:[id]"` resolves to `[id].realtime.ably-nonprod.net`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "nonprod:staging" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "staging.realtime.ably-nonprod.net" +``` + +--- + +## REC1b4 - Endpoint option as production routing policy + +Tests that `endpoint: "[id]"` (without period or nonprod prefix) resolves to `[id].realtime.ably.net`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated environment option + +Tests that specifying both `endpoint` and `environment` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox", + environment: "production" # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated restHost option + +Tests that specifying both `endpoint` and `restHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox", + restHost: "custom.host.com" # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated realtimeHost option + +Tests that specifying both `endpoint` and `realtimeHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox", + realtimeHost: "custom.realtime.com" # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1b1 - Endpoint conflicts with deprecated fallbackHostsUseDefault option + +Tests that specifying both `endpoint` and `fallbackHostsUseDefault` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox", + fallbackHostsUseDefault: true # Deprecated, conflicts with endpoint +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1c2 - Deprecated environment option determines primary domain + +Tests that the deprecated `environment` option sets primary domain to `[id].realtime.ably.net`. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox" # Deprecated but still supported +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +``` + +--- + +## REC1c1 - Environment conflicts with restHost + +Tests that specifying both `environment` and `restHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox", + restHost: "custom.host.com" +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1c1 - Environment conflicts with realtimeHost + +Tests that specifying both `environment` and `realtimeHost` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox", + realtimeHost: "custom.realtime.com" +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC1d1 - Deprecated restHost option determines primary domain + +Tests that the deprecated `restHost` option sets the primary domain. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.rest.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "custom.rest.example.com" +``` + +--- + +## REC1d2 - Deprecated realtimeHost option determines primary domain (when restHost not set) + +Tests that `realtimeHost` sets primary domain when `restHost` is not specified. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeHost: "custom.realtime.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests[0].url.host == "custom.realtime.example.com" +``` + +--- + +## REC1d - restHost takes precedence over realtimeHost + +Tests that when both `restHost` and `realtimeHost` are specified, `restHost` is used for REST requests. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "rest.example.com", + realtimeHost: "realtime.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +# REST client uses restHost, not realtimeHost +ASSERT mock_http.captured_requests[0].url.host == "rest.example.com" +``` + +--- + +# REC2 - Fallback Domains Configuration + +## REC2c1 - Default fallback domains + +**Spec requirement:** When using default configuration, fallback domains follow the pattern `[a-e].ably-realtime.com`. + +Tests that default configuration provides the standard fallback domains. + +> **Note:** The spec defines the legacy fallback pattern as `[a-e].ably-realtime.com`. SDKs adopting the new `endpoint` routing policy (REC1b) should use `main.[a-e].fallback.ably-realtime.com`. SDKs still using the legacy pattern should assert against `[a-e].ably-realtime.com`. + +### Setup +```pseudo +mock_http = MockHttpClient() +# Primary fails +mock_http.queue_response(500, { "error": { "code": 50000 } }) +# Fallback succeeds +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" + +expected_fallbacks = [ + "main.a.fallback.ably-realtime.com", + "main.b.fallback.ably-realtime.com", + "main.c.fallback.ably-realtime.com", + "main.d.fallback.ably-realtime.com", + "main.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2a2 - Custom fallbackHosts option + +Tests that the `fallbackHosts` option overrides default fallbacks. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["fb1.example.com", "fb2.example.com", "fb3.example.com"] +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +ASSERT mock_http.captured_requests[1].url.host IN ["fb1.example.com", "fb2.example.com", "fb3.example.com"] +``` + +--- + +## REC2a1 - fallbackHosts conflicts with fallbackHostsUseDefault + +Tests that specifying both `fallbackHosts` and `fallbackHostsUseDefault` is invalid. + +### Setup +```pseudo +# No mock needed - should fail during client construction +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["fb1.example.com"], + fallbackHostsUseDefault: true +)) FAILS WITH error +ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" +``` + +--- + +## REC2b - Deprecated fallbackHostsUseDefault option + +Tests that `fallbackHostsUseDefault: true` uses the default fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.host.com", # Would normally disable fallbacks + fallbackHostsUseDefault: true # Force default fallbacks +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "custom.host.com" + +# Should use default fallbacks despite custom restHost +expected_fallbacks = [ + "main.a.fallback.ably-realtime.com", + "main.b.fallback.ably-realtime.com", + "main.c.fallback.ably-realtime.com", + "main.d.fallback.ably-realtime.com", + "main.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c2 - Explicit hostname endpoint has no fallbacks + +Tests that when `endpoint` is an explicit hostname, fallback domains are empty. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "custom.ably.example.com" # Contains period = explicit hostname +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail with no fallback +``` + +### Assertions +```pseudo +# No fallback attempted - only one request +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "custom.ably.example.com" +``` + +--- + +## REC2c3 - Nonprod routing policy fallback domains + +Tests that nonprod routing policy has corresponding nonprod fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "nonprod:staging" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "staging.realtime.ably-nonprod.net" + +expected_fallbacks = [ + "staging.a.fallback.ably-realtime-nonprod.com", + "staging.b.fallback.ably-realtime-nonprod.com", + "staging.c.fallback.ably-realtime-nonprod.com", + "staging.d.fallback.ably-realtime-nonprod.com", + "staging.e.fallback.ably-realtime-nonprod.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c4 - Production routing policy fallback domains (via endpoint) + +Tests that production routing policy via `endpoint` has corresponding fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" + +expected_fallbacks = [ + "sandbox.a.fallback.ably-realtime.com", + "sandbox.b.fallback.ably-realtime.com", + "sandbox.c.fallback.ably-realtime.com", + "sandbox.d.fallback.ably-realtime.com", + "sandbox.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c5 - Production routing policy fallback domains (via deprecated environment) + +Tests that production routing policy via deprecated `environment` has corresponding fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + environment: "sandbox" # Deprecated +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" + +expected_fallbacks = [ + "sandbox.a.fallback.ably-realtime.com", + "sandbox.b.fallback.ably-realtime.com", + "sandbox.c.fallback.ably-realtime.com", + "sandbox.d.fallback.ably-realtime.com", + "sandbox.e.fallback.ably-realtime.com" +] +ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks +``` + +--- + +## REC2c6 - Custom restHost has no fallbacks + +Tests that deprecated `restHost` option results in no fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.rest.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail with no fallback +``` + +### Assertions +```pseudo +# No fallback attempted +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "custom.rest.example.com" +``` + +--- + +## REC2c6 - Custom realtimeHost has no fallbacks + +Tests that deprecated `realtimeHost` option results in no fallback domains. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, { "error": { "code": 50000 } }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeHost: "custom.realtime.example.com" +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +# Expected to fail with no fallback +``` + +### Assertions +```pseudo +# No fallback attempted +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "custom.realtime.example.com" +``` + +--- + +# REC3 - Connectivity Check URL + +## REC3a - Default connectivity check URL + +Tests that the default connectivity check URL is `https://internet-up.ably-realtime.com/is-the-internet-up.txt`. + +### Note +This test is primarily relevant for Realtime clients that perform connectivity checks. The connectivity check URL is used to verify internet connectivity before attempting to connect. + +### Setup +```pseudo +mock_http = MockHttpClient() +# Queue response for connectivity check +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 200, + "yes" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Trigger connectivity check (implementation-specific) +# Some libraries expose this, others do it internally +result = AWAIT client.connection.checkConnectivity() +# OR: observe that connectivity check request was made during connection +``` + +### Assertions +```pseudo +connectivity_requests = mock_http.captured_requests.filter( + r => r.url.path CONTAINS "is-the-internet-up" +) +ASSERT connectivity_requests.length >= 1 +ASSERT connectivity_requests[0].url.toString() == "https://internet-up.ably-realtime.com/is-the-internet-up.txt" + +CLOSE_CLIENT(client) +``` + +--- + +## REC3b - Custom connectivity check URL + +Tests that the `connectivityCheckUrl` option overrides the default. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://custom.example.com/connectivity", + 200, + "ok" +) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + connectivityCheckUrl: "https://custom.example.com/connectivity" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.connection.checkConnectivity() +``` + +### Assertions +```pseudo +connectivity_requests = mock_http.captured_requests.filter( + r => r.url.host == "custom.example.com" +) +ASSERT connectivity_requests.length >= 1 +ASSERT connectivity_requests[0].url.toString() == "https://custom.example.com/connectivity" + +# Should NOT request the default URL +default_requests = mock_http.captured_requests.filter( + r => r.url.host == "internet-up.ably-realtime.com" +) +ASSERT default_requests.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## REC3 - Connectivity check response validation + +Tests that the connectivity check expects a specific response. + +### Test Cases + +| ID | Response | Expected Result | +|----|----------|-----------------| +| 1 | HTTP 200 with body "yes" | Connected | +| 2 | HTTP 200 with body "no" | Not connected | +| 3 | HTTP 200 with empty body | Not connected | +| 4 | HTTP 404 | Not connected | +| 5 | Network error | Not connected | + +### Setup (Case 1 - Success) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 200, + "yes" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +result = AWAIT client.connection.checkConnectivity() + +ASSERT result == true + +CLOSE_CLIENT(client) +``` + +### Setup (Case 2 - Wrong body) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 200, + "no" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +result = AWAIT client.connection.checkConnectivity() + +ASSERT result == false + +CLOSE_CLIENT(client) +``` + +### Setup (Case 4 - HTTP error) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response_for_url( + "https://internet-up.ably-realtime.com/is-the-internet-up.txt", + 404, + "Not Found" +) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +result = AWAIT client.connection.checkConnectivity() + +ASSERT result == false + +CLOSE_CLIENT(client) +``` diff --git a/uts/rest/unit/helpers/mock_http.md b/uts/rest/unit/helpers/mock_http.md new file mode 100644 index 000000000..6cd99961a --- /dev/null +++ b/uts/rest/unit/helpers/mock_http.md @@ -0,0 +1,227 @@ +# Mock HTTP Infrastructure + +This document specifies the mock HTTP infrastructure for REST unit tests. All REST unit tests that need to intercept HTTP requests should reference this document. + +## Purpose + +The mock infrastructure enables unit testing of REST client behavior without making real network calls. It supports: + +1. **Intercepting HTTP requests** - Capture the URL, headers, method, and body of outgoing requests +2. **Controlling request outcomes** - Simulate various connection results including successful responses, connection refused, DNS errors, timeouts, and other network-level failures +3. **Injecting responses** - Configure responses (status, headers, body) to be returned +4. **Capturing requests** - Record all request details for test assertions + +## Installation Mechanism + +The mechanism for injecting the mock is implementation-specific and not part of the public API. Possible approaches include: + +- Dependency injection of HTTP client interface +- Platform-specific mocking (e.g., URLProtocol in Swift, HttpClientHandler in .NET) +- Test doubles or mocking frameworks +- Package-level variable substitution + +## Mock Interface + +```pseudo +interface MockHttpClient: + # Awaitable event triggers for test code + await_connection_attempt(timeout?: Duration): Future + await_request(timeout?: Duration): Future + + # Test management + reset() # Clear all state + +interface PendingConnection: + host: String + port: Int + tls: Boolean + timestamp: Time + + # Methods for test code to respond to the connection attempt + respond_with_success() # Connection succeeds, allows HTTP requests + respond_with_refused() # Connection refused at network level + respond_with_timeout() # Connection times out (unresponsive) + respond_with_dns_error() # DNS resolution fails + +interface PendingRequest: + url: URL + method: String # GET, POST, etc. + headers: Map + body: Bytes + timestamp: Time + + # Methods for test code to respond to the HTTP request + respond_with(status: Int, body: Any, headers?: Map) + respond_with_delay(delay: Duration, status: Int, body: Any, headers?: Map) + respond_with_timeout() # Request timeout (after connection established) +``` + +## Handler-Based Configuration + +For simple test scenarios, implementations may support handler-based configuration: + +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + }, + onRequest: (req) => { + IF req.url.path == "/time": + req.respond_with(200, {"time": 1234567890000}) + ELSE: + req.respond_with(404, {"error": {"code": 40400}}) + } +) +``` + +Handlers are called automatically when connection attempts or requests occur. The await-based API should always be available for tests that need to coordinate responses with test state. + +### When to Use Each Pattern + +**Handler pattern** (recommended for most tests): +- Response is predetermined based on URL, method, or request count +- Simple scenarios with known request/response pairs +- No need to coordinate with external test state + +**Await pattern** (for advanced scenarios): +- Need to inspect request details before deciding how to respond +- Test logic depends on external state not known at setup time +- Complex coordination between request timing and test assertions + +## Example: Handler Pattern + +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +result = AWAIT client.time() + +ASSERT captured_requests.length == 1 +ASSERT captured_requests[0].url.path == "/time" +``` + +## Example: Handler with State (Different Responses by Count) + +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(500, {"error": {"code": 50000}}) + ELSE: + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# First request fails, triggers retry, second succeeds +result = AWAIT client.time() + +ASSERT request_count == 2 +``` + +## Example: Await Pattern + +```pseudo +mock_http = MockHttpClient() +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Start request in background +request_future = client.time() + +# Wait for and handle connection +connection = AWAIT mock_http.await_connection_attempt() +connection.respond_with_success() + +# Wait for and handle HTTP request +request = AWAIT mock_http.await_request() +ASSERT request.headers["X-Ably-Version"] IS NOT null +request.respond_with(200, {"time": 1234567890000}) + +# Complete the operation +result = AWAIT request_future +``` + +## Connection-Level Failures + +The mock distinguishes between connection-level and request-level failures: + +**Connection-level failures** (handled by `PendingConnection`): +- `respond_with_refused()` - TCP connection refused +- `respond_with_timeout()` - Connection attempt times out +- `respond_with_dns_error()` - DNS resolution fails + +**Request-level failures** (handled by `PendingRequest`): +- `respond_with(4xx/5xx, ...)` - HTTP error response +- `respond_with_timeout()` - Request times out after connection established + +```pseudo +# Connection refused example +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) + +# vs HTTP 500 error example +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(500, {"error": {...}}) +) +``` + +## Test Isolation + +Each test should: + +1. Create a fresh mock HTTP client +2. Install/inject the mock +3. Create the REST client +4. Perform test steps and assertions +5. Clean up the mock + +```pseudo +BEFORE EACH TEST: + mock_http = MockHttpClient() + install_mock(mock_http) + +AFTER EACH TEST: + uninstall_mock() +``` + +## Timer Mocking for Timeouts + +Tests that verify timeout behavior should use timer mocking where practical to avoid slow tests. + +**Approaches (in order of preference):** + +1. **Mock/fake timers** - Use framework-provided timer mocking + ```pseudo + enable_fake_timers() + request_future = client.time() + ADVANCE_TIME(1000) # Instantly trigger timeout + ``` + +2. **Dependency injection** - Library accepts clock interface in tests + +3. **Short timeouts** - Use very short timeout values + ```pseudo + client = Rest(options: ClientOptions(httpRequestTimeout: 50)) + ``` + +4. **Actual delays** - Last resort if mocking unavailable diff --git a/uts/rest/unit/logging.md b/uts/rest/unit/logging.md new file mode 100644 index 000000000..21377d416 --- /dev/null +++ b/uts/rest/unit/logging.md @@ -0,0 +1,197 @@ +# Logging Tests + +Spec points: `RSC2`, `RSC3`, `RSC4`, `TO3b`, `TO3c` + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## Purpose + +Tests the logging support for the Ably client. The logging API uses a structured +format where each log event has a fixed message string and a context map of +key-value pairs, rather than interpolated strings. + +The `LogHandler` signature is: +``` +LogHandler(level: LogLevel, message: String, context: Map) +``` + +--- + +## RSC2 - Default log level is warn + +**Spec requirement:** The default log level is `warn`. Only `error` and `warn` level +events should be emitted when the default level is used. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# Default level is warn, so info/debug/verbose messages should be filtered +ASSERT ALL log IN captured_logs: log.level IN [error, warn] +``` + +--- + +## TO3b - Log level can be changed + +**Spec requirement:** The log level can be changed via `ClientOptions.logLevel`. +Setting the level to `verbose` should capture all log events. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: verbose, + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# With verbose, should have info+debug+verbose messages +info_logs = captured_logs.filter(l => l.level == info) +ASSERT info_logs.length > 0 + +# Must have an info log for the time() method entry (not checked directly, +# but client creation emits "Client created" at info level) +ASSERT ANY log IN captured_logs: log.level == info + +# Must have a debug log for the HTTP request +debug_logs = captured_logs.filter(l => l.level == debug) +ASSERT ANY log IN debug_logs: log.message CONTAINS "HTTP request" +``` + +--- + +## TO3c - Custom log handler receives structured events + +**Spec requirement:** A custom log handler provided via `ClientOptions.logHandler` +receives structured log events with level, message, and context. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +handler = (level, message, context) => captured_logs.push({level, message, context}) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: info, + logHandler: handler +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# Custom handler was called +ASSERT captured_logs.length > 0 + +# Structured context is provided +ASSERT ANY log IN captured_logs: log.context IS NOT EMPTY +``` + +--- + +## TO3c2 - Structured context contains expected keys + +**Spec requirement:** The structured context map contains relevant key-value pairs +for the log event. HTTP request logs include method, host, and path. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: debug, + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# Find the HTTP request log +http_logs = captured_logs.filter(l => l.message CONTAINS "HTTP request" AND l.level == debug) +ASSERT http_logs.length >= 1 +ASSERT "method" IN http_logs[0].context +ASSERT "host" IN http_logs[0].context +ASSERT "path" IN http_logs[0].context +``` + +--- + +## RSC2b - LogLevel.none produces no log events + +**Spec requirement:** Setting log level to `none` should suppress all log output. + +### Setup +```pseudo +captured_logs = [] + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, [1704067200000]) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "app.key:secret", + logLevel: none, + logHandler: (level, message, context) => captured_logs.push({level, message, context}) +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() + +# No logs should be captured +ASSERT captured_logs.length == 0 +``` diff --git a/uts/rest/unit/presence/rest_presence.md b/uts/rest/unit/presence/rest_presence.md new file mode 100644 index 000000000..3a6fbb550 --- /dev/null +++ b/uts/rest/unit/presence/rest_presence.md @@ -0,0 +1,1599 @@ +# REST Presence Unit Tests + +Spec points: `RSL3`, `RSP1`, `RSP1a`, `RSP1b`, `RSP3`, `RSP3a1`, `RSP3a2`, `RSP3a3`, `RSP4`, `RSP4a`, `RSP4b1`, `RSP4b2`, `RSP4b3`, `RSP5` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure described in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock supports: +- Intercepting HTTP requests and capturing details (URL, headers, method, body) +- Queueing responses with configurable status, headers, and body +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Recording requests in `captured_requests` arrays +- Request counting with `request_count` variables + +--- + +## RSP1, RSL3 - RestPresence object associated with channel + +### RSP1a, RSL3 - Presence accessible via RestChannel#presence + +**Spec requirement:** Each `RestChannel` provides access to a `RestPresence` object via the `presence` property (RSP1a). The `RestChannel#presence` attribute contains a `RestPresence` object for this channel (RSL3). + +```pseudo +channel_name = "test-RSP1a-${random_id()}" + +Given a REST client with mocked HTTP +And a channel channel_name +When accessing channel.presence +Then a RestPresence object is returned +And the presence object is associated with channel_name +``` + +### RSP1b - Same presence object returned for same channel + +**Spec requirement:** The same `RestPresence` instance must be returned for multiple accesses to the same channel's presence property. + +```pseudo +channel_name = "test-RSP1b-${random_id()}" + +Given a REST client with mocked HTTP +And a channel = client.channels.get(channel_name) +When accessing channel.presence multiple times +Then the same RestPresence instance is returned each time +``` + +--- + +## RSP3 - RestPresence#get + +### RSP3a - Get sends GET request to presence endpoint + +**Spec requirement:** The `get` method sends a GET request to `/channels//presence` and returns a `PaginatedResult`. + +### Setup +```pseudo +channel_name = "test-RSP3a-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + req.respond_with(200, [ + { "action": 1, "clientId": "client1", "data": "hello" }, + { "action": 1, "clientId": "client2", "data": "world" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT request_count == 1 +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].url.path == "/channels/" + encode_uri_component(channel_name) + "/presence" +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 +``` + +--- + +### RSP3b - Get returns PresenceMessage objects + +**Spec requirement:** The response items must be decoded into `PresenceMessage` objects with all fields correctly populated. + +### Setup +```pseudo +channel_name = "test-RSP3b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "user123", + "connectionId": "conn456", + "data": "status data", + "encoding": null, + "timestamp": 1234567890000 + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items.length == 1 +ASSERT result.items[0] IS PresenceMessage +ASSERT result.items[0].action == PresenceAction.present # action 1 +ASSERT result.items[0].clientId == "user123" +ASSERT result.items[0].connectionId == "conn456" +ASSERT result.items[0].data == "status data" +ASSERT result.items[0].timestamp == 1234567890000 +``` + +--- + +### RSP3c - Get with no members returns empty list + +**Spec requirement:** When no presence members exist, `get` returns an empty list in the `PaginatedResult`. + +### Setup +```pseudo +channel_name = "test-RSP3c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items IS List +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +``` + +--- + +### RSP3a1a - Get with limit parameter + +**Spec requirement:** The `limit` parameter must be included in the query string when specified. + +### Setup +```pseudo +channel_name = "test-RSP3a1a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "client1" }, + { "action": 1, "clientId": "client2" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(limit: 50) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "50" +``` + +--- + +### RSP3a1b - Get limit defaults to 100 + +**Spec requirement:** When no limit is specified, the default limit of 100 is used (or not explicitly sent). + +### Setup +```pseudo +channel_name = "test-RSP3a1b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT "limit" NOT IN captured_requests[0].url.query_params + OR captured_requests[0].url.query_params["limit"] == "100" +``` + +--- + +### RSP3a1c - Get limit maximum is 1000 + +**Spec requirement:** The maximum allowed limit value is 1000. + +### Setup +```pseudo +channel_name = "test-RSP3a1c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(limit: 1000) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "1000" +``` + +--- + +### RSP3a2 - Get with clientId filter + +**Spec requirement:** The `clientId` parameter filters presence members by client identifier. + +### Setup +```pseudo +channel_name = "test-RSP3a2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "specific-client", "data": "filtered" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(clientId: "specific-client") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["clientId"] == "specific-client" +``` + +--- + +### RSP3a3 - Get with connectionId filter + +**Spec requirement:** The `connectionId` parameter filters presence members by connection identifier. + +### Setup +```pseudo +channel_name = "test-RSP3a3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "client1", "connectionId": "conn123" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get(connectionId: "conn123") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["connectionId"] == "conn123" +``` + +--- + +### RSP3 - Get with multiple filters + +**Spec requirement:** Multiple query parameters can be combined in a single request. + +### Setup +```pseudo +channel_name = "test-RSP3-multi-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get( + limit: 25, + clientId: "user1", + connectionId: "conn1" +) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "25" +ASSERT captured_requests[0].url.query_params["clientId"] == "user1" +ASSERT captured_requests[0].url.query_params["connectionId"] == "conn1" +``` + +--- + +## RSP4 - RestPresence#history + +### RSP4a - History sends GET request to presence history endpoint + +| Spec | Requirement | +|------|-------------| +| RSP4 | History method fetches presence event history | +| RSP4a | Returns `PaginatedResult` | + +### Setup +```pseudo +channel_name = "test-RSP4a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 2, "clientId": "client1", "data": "entered" }, + { "action": 4, "clientId": "client1", "data": "left" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].url.path == "/channels/" + encode_uri_component(channel_name) + "/presence/history" +ASSERT result IS PaginatedResult +``` + +--- + +### RSP4a - History returns PaginatedResult of PresenceMessage + +**Spec requirement:** History responses contain `PresenceMessage` objects with various action types. + +### Setup +```pseudo +channel_name = "test-RSP4a-result-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 2, "clientId": "user1", "data": "d1", "timestamp": 1000 }, + { "action": 3, "clientId": "user1", "data": "d2", "timestamp": 2000 }, + { "action": 4, "clientId": "user1", "data": "d3", "timestamp": 3000 } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 3 +ASSERT result.items[0].action == PresenceAction.enter # action 2 +ASSERT result.items[1].action == PresenceAction.leave # action 3 +ASSERT result.items[2].action == PresenceAction.update # action 4 +``` + +--- + +### RSP4b1a - History with start parameter + +**Spec requirement:** The `start` parameter filters events from a given timestamp (inclusive). + +### Setup +```pseudo +channel_name = "test-RSP4b1a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +start_time = 1609459200000 # 2021-01-01 00:00:00 UTC +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(start: start_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +``` + +--- + +### RSP4b1b - History with end parameter + +**Spec requirement:** The `end` parameter filters events up to a given timestamp (inclusive). + +### Setup +```pseudo +channel_name = "test-RSP4b1b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +end_time = 1609545600000 # 2021-01-02 00:00:00 UTC +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(end: end_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" +``` + +--- + +### RSP4b1c - History with start and end parameters + +**Spec requirement:** Start and end parameters can be combined to define a time range. + +### Setup +```pseudo +channel_name = "test-RSP4b1c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +start_time = 1609459200000 +end_time = 1609545600000 +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history( + start: start_time, + end: end_time +) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" +``` + +--- + +### RSP4b1d - History accepts DateTime objects for start/end + +**Spec requirement:** Language-specific DateTime objects should be accepted and converted to milliseconds since epoch. + +### Setup +```pseudo +channel_name = "test-RSP4b1d-${random_id()}" +# Language-specific: if the language supports DateTime/Date objects +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +start_datetime = DateTime(2021, 1, 1, 0, 0, 0, UTC) # language-specific +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(start: start_datetime) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +``` + +--- + +### RSP4b2a - History with direction backwards (default) + +**Spec requirement:** The default direction is `backwards` (newest first). + +### Setup +```pseudo +channel_name = "test-RSP4b2a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT "direction" NOT IN captured_requests[0].url.query_params + OR captured_requests[0].url.query_params["direction"] == "backwards" +``` + +--- + +### RSP4b2b - History with direction forwards + +**Spec requirement:** The `direction` parameter can be set to `forwards` (oldest first). + +### Setup +```pseudo +channel_name = "test-RSP4b2b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(direction: "forwards") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["direction"] == "forwards" +``` + +--- + +### RSP4b2c - History with direction backwards explicit + +**Spec requirement:** The `direction` parameter can be explicitly set to `backwards`. + +### Setup +```pseudo +channel_name = "test-RSP4b2c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(direction: "backwards") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["direction"] == "backwards" +``` + +--- + +### RSP4b3a - History with limit parameter + +**Spec requirement:** The `limit` parameter controls the maximum number of results per page. + +### Setup +```pseudo +channel_name = "test-RSP4b3a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(limit: 50) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "50" +``` + +--- + +### RSP4b3b - History limit defaults to 100 + +**Spec requirement:** When no limit is specified, the default is 100. + +### Setup +```pseudo +channel_name = "test-RSP4b3b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT "limit" NOT IN captured_requests[0].url.query_params + OR captured_requests[0].url.query_params["limit"] == "100" +``` + +--- + +### RSP4b3c - History limit maximum is 1000 + +**Spec requirement:** The maximum allowed limit is 1000. + +### Setup +```pseudo +channel_name = "test-RSP4b3c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history(limit: 1000) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["limit"] == "1000" +``` + +--- + +### RSP4 - History with all parameters + +**Spec requirement:** All query parameters can be combined in a single request. + +### Setup +```pseudo +channel_name = "test-RSP4-all-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history( + start: 1609459200000, + end: 1609545600000, + direction: "forwards", + limit: 50 +) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" +ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" +ASSERT captured_requests[0].url.query_params["direction"] == "forwards" +ASSERT captured_requests[0].url.query_params["limit"] == "50" +``` + +--- + +## RSP5 - Presence message decoding + +### RSP5a - String data decoded as string + +**Spec requirement:** Plain string data must be decoded without modification. + +### Setup +```pseudo +channel_name = "test-RSP5a-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 1, "clientId": "c1", "data": "plain string data" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data == "plain string data" +ASSERT result.items[0].data IS String +``` + +--- + +### RSP5b - JSON encoded data decoded to object + +**Spec requirement:** Data with `encoding: "json"` must be decoded from JSON string to native object. + +### Setup +```pseudo +channel_name = "test-RSP5b-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "{\"status\":\"online\",\"count\":42}", + "encoding": "json" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["status"] == "online" +ASSERT result.items[0].data["count"] == 42 +ASSERT result.items[0].encoding == null # encoding consumed +``` + +--- + +### RSP5c - Base64 encoded data decoded to binary + +**Spec requirement:** Data with `encoding: "base64"` must be decoded from base64 to binary. + +### Setup +```pseudo +channel_name = "test-RSP5c-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "SGVsbG8gV29ybGQ=", # "Hello World" in base64 + "encoding": "base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Binary/Uint8List/[]byte +ASSERT result.items[0].data == bytes("Hello World") +ASSERT result.items[0].encoding == null # encoding consumed +``` + +--- + +### RSP5 - Binary presence data decoded from MessagePack response + +**Spec requirement:** When a presence response is returned in MessagePack format with binary data (msgpack `bin` type), the data must be decoded as binary, not as a string — even if the bytes are valid UTF-8. This parallels the RSL6 msgpack binary decoding test for channel messages. + +### Setup +```pseudo +channel_name = "test-RSP5-msgpack-binary-${random_id()}" + +# Binary payload using msgpack bin type (valid UTF-8 bytes) +binary_payload = bytes([0x73, 0x6F, 0x6D, 0x65, 0x20, 0x64, 0x61, 0x74, 0x61]) # "some data" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, + body: msgpack_encode([ + { "action": 1, "clientId": "client1", "data": binary_payload } + ]), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +# Binary data must remain binary, NOT be converted to a string +ASSERT result.items[0].data IS Binary/Uint8List/[]byte +ASSERT result.items[0].data == bytes([0x73, 0x6F, 0x6D, 0x65, 0x20, 0x64, 0x61, 0x74, 0x61]) +ASSERT result.items[0].encoding IS null +``` + +--- + +### RSP5d - UTF-8 encoded data decoded correctly + +**Spec requirement:** Data with `encoding: "utf-8/base64"` must be decoded through both layers. + +### Setup +```pseudo +channel_name = "test-RSP5d-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "SGVsbG8gV29ybGQ=", # base64 of UTF-8 bytes + "encoding": "utf-8/base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data == "Hello World" +ASSERT result.items[0].data IS String +``` + +--- + +### RSP5e - Chained encoding decoded in order + +**Spec requirement:** Chained encodings (e.g., `json/base64`) must be decoded in reverse order (last applied, first removed). + +### Setup +```pseudo +channel_name = "test-RSP5e-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": "eyJrZXkiOiJ2YWx1ZSJ9", # base64 of {"key":"value"} + "encoding": "json/base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +# Decoding order: base64 first, then json +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["key"] == "value" +``` + +--- + +### RSP5f - History messages also decoded + +**Spec requirement:** Encoding decoding applies to both `get` and `history` methods. + +### Setup +```pseudo +channel_name = "test-RSP5f-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 2, + "clientId": "c1", + "data": "{\"event\":\"entered\"}", + "encoding": "json" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["event"] == "entered" +``` + +--- + +### RSP5g - Cipher decoding with channel options + +**Spec requirement:** Encrypted data with cipher encoding must be decrypted using channel cipher options. + +### Setup +```pseudo +channel_name = "test-RSP5g-${random_id()}" +captured_requests = [] +cipher_key = base64_decode("WUP6u0K7MXI5Zeo0VppPwg==") + +# Encrypted data for {"secret":"data"} +encrypted_data = "HO4cYSP8LybPYBPZPHQOtuD53yrD3YV3NBoTEYBh4U0=" + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { + "action": 1, + "clientId": "c1", + "data": encrypted_data, + "encoding": "json/utf-8/cipher+aes-128-cbc/base64" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get(channel_name, options: RestChannelOptions( + cipher: CipherParams(key: cipher_key, algorithm: "aes", mode: "cbc") +)) +``` + +### Test Steps +```pseudo +result = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items[0].data IS Object/Map +# Decryption applied based on cipher+aes-128-cbc encoding +``` + +--- + +## Pagination + +### RSP_Pagination_1 - Get returns paginated result with Link header + +**Spec requirement:** Responses with Link headers must support pagination via `hasNext()` and `next()`. + +### Setup +```pseudo +channel_name = "test-RSP-pagination1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [ + { "action": 1, "clientId": "client1" }, + { "action": 1, "clientId": "client2" } + ], + headers: { + "Link": "; rel=\"next\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT result.items.length == 2 +ASSERT result.hasNext() == true +``` + +--- + +### RSP_Pagination_2 - Get next page fetches from Link URL + +**Spec requirement:** Calling `next()` must use the URL from the Link header to fetch the next page. + +### Setup +```pseudo +channel_name = "test-RSP-pagination2-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "action": 1, "clientId": "client1" }], + headers: { "Link": "; rel=\"next\"" } + ) + ELSE: + req.respond_with(200, body: [{ "action": 1, "clientId": "client2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +page1 = AWAIT client.channels.get(channel_name).presence.get() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +ASSERT page1.items[0].clientId == "client1" +ASSERT page2.items[0].clientId == "client2" +ASSERT page2.hasNext() == false +``` + +--- + +### RSP_Pagination_3 - History pagination works the same + +**Spec requirement:** History results must support the same pagination behavior as get. + +### Setup +```pseudo +channel_name = "test-RSP-pagination3-${random_id()}" +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "action": 2, "clientId": "c1", "timestamp": 3000 }], + headers: { "Link": "; rel=\"next\"" } + ) + ELSE: + req.respond_with(200, body: [{ "action": 4, "clientId": "c1", "timestamp": 1000 }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +page1 = AWAIT client.channels.get(channel_name).presence.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +ASSERT page1.items[0].action == PresenceAction.enter +ASSERT page2.items[0].action == PresenceAction.leave +``` + +--- + +## Error Handling + +### RSP_Error_1 - Get with server error throws AblyException + +**Spec requirement:** Server errors must be raised as `AblyException` with appropriate error code and status. + +### Setup +```pseudo +channel_name = "test-RSP-error1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(500, { + "error": { + "code": 50000, + "statusCode": 500, + "message": "Internal server error" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() FAILS WITH error +ASSERT error.code == 50000 +ASSERT error.statusCode == 500 +``` + +--- + +### RSP_Error_2 - History with invalid auth throws AblyException + +**Spec requirement:** Authentication errors must raise `AblyException` with code 40101. + +### Setup +```pseudo +channel_name = "test-RSP-error2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(401, { + "error": { + "code": 40101, + "statusCode": 401, + "message": "Invalid credentials" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "invalid.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() FAILS WITH error +ASSERT error.code == 40101 +ASSERT error.statusCode == 401 +``` + +--- + +### RSP_Error_3 - Get with channel not found + +**Spec requirement:** 404 responses must raise `AblyException` with code 40400. + +### Setup +```pseudo +channel_name = "test-RSP-error3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(404, { + "error": { + "code": 40400, + "statusCode": 404, + "message": "Channel not found" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() FAILS WITH error +ASSERT error.code == 40400 +ASSERT error.statusCode == 404 +``` + +--- + +## Request Headers + +### RSP_Headers_1 - Get includes standard headers + +**Spec requirement:** All REST requests must include standard Ably headers (X-Ably-Version, Ably-Agent, Accept). + +### Setup +```pseudo +channel_name = "test-RSP-headers1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT "X-Ably-Version" IN captured_requests[0].headers +ASSERT captured_requests[0].headers["Ably-Agent"] contains "ably-" +ASSERT "Accept" IN captured_requests[0].headers +``` + +--- + +### RSP_Headers_2 - History includes authorization header + +**Spec requirement:** Authenticated requests must include the Authorization header. + +### Setup +```pseudo +channel_name = "test-RSP-headers2-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT "Authorization" IN captured_requests[0].headers +ASSERT captured_requests[0].headers["Authorization"] starts with "Basic " +``` + +--- + +### RSP_Headers_3 - Request ID included when enabled + +**Spec requirement:** When `addRequestIds` is enabled, a unique `request_id` query parameter must be included. + +### Setup +```pseudo +channel_name = "test-RSP-headers3-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + addRequestIds: true +)) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get(channel_name).presence.get() +``` + +### Assertions +```pseudo +ASSERT "request_id" IN captured_requests[0].url.query_params +ASSERT captured_requests[0].url.query_params["request_id"] IS NOT empty +``` + +--- + +## PresenceAction Values + +### RSP_Action_1 - All presence actions correctly mapped + +**Spec requirement:** All presence action values must be correctly mapped between wire protocol and SDK types. + +### Setup +```pseudo +channel_name = "test-RSP-action1-${random_id()}" +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "action": 0, "clientId": "c1" }, # absent + { "action": 1, "clientId": "c2" }, # present + { "action": 2, "clientId": "c3" }, # enter + { "action": 3, "clientId": "c4" }, # leave + { "action": 4, "clientId": "c5" } # update + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.channels.get(channel_name).presence.history() +``` + +### Assertions +```pseudo +ASSERT result.items[0].action == PresenceAction.absent +ASSERT result.items[1].action == PresenceAction.present +ASSERT result.items[2].action == PresenceAction.enter +ASSERT result.items[3].action == PresenceAction.leave +ASSERT result.items[4].action == PresenceAction.update +``` + +Note: Action values may vary by SDK. The wire protocol uses: +- 0 = absent +- 1 = present +- 2 = enter +- 3 = leave (some SDKs use 4) +- 4 = update (some SDKs use 3) + +Verify against your SDK's specific mapping. diff --git a/uts/rest/unit/push/push_admin_publish.md b/uts/rest/unit/push/push_admin_publish.md new file mode 100644 index 000000000..79232cc7e --- /dev/null +++ b/uts/rest/unit/push/push_admin_publish.md @@ -0,0 +1,330 @@ +# PushAdmin Publish Tests + +Spec points: `RSH1`, `RSH1a` + +## 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. + +--- + +## RSH1 — client.push.admin exposes PushAdmin object + +**Spec requirement:** RSH1 — `Push#admin` object provides the PushAdmin interface. + +Tests that the REST client exposes a `push.admin` object of the correct type. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}) +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Assertions +```pseudo +ASSERT client.push IS Push +ASSERT client.push.admin IS PushAdmin +ASSERT client.push.admin.deviceRegistrations IS PushDeviceRegistrations +ASSERT client.push.admin.channelSubscriptions IS PushChannelSubscriptions +``` + +--- + +## RSH1a — publish sends POST to /push/publish + +**Spec requirement:** RSH1a — `publish(recipient, data)` performs an HTTP request to `/push/publish`. + +Tests that `push.admin.publish()` sends a POST with correct recipient and data. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.publish( + recipient: { + "transportType": "apns", + "deviceToken": "foo" + }, + data: { + "notification": { + "title": "Test", + "body": "Hello" + } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/publish" + +body = parse_json(request.body) +ASSERT body["recipient"]["transportType"] == "apns" +ASSERT body["recipient"]["deviceToken"] == "foo" +ASSERT body["notification"]["title"] == "Test" +ASSERT body["notification"]["body"] == "Hello" +``` + +--- + +## RSH1a — publish with clientId recipient + +**Spec requirement:** RSH1a — Tests should exist with valid recipient details. + +Tests that publish works with a `clientId` recipient. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.publish( + recipient: { + "clientId": "user-123" + }, + data: { + "data": { + "key": "value" + } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +body = parse_json(captured_requests[0].body) +ASSERT body["recipient"]["clientId"] == "user-123" +ASSERT body["data"]["key"] == "value" +``` + +--- + +## RSH1a — publish with deviceId recipient + +**Spec requirement:** RSH1a — Tests should exist with valid recipient details. + +Tests that publish works with a `deviceId` recipient. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.publish( + recipient: { + "deviceId": "device-abc" + }, + data: { + "notification": { + "title": "Device Push" + } + } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +body = parse_json(captured_requests[0].body) +ASSERT body["recipient"]["deviceId"] == "device-abc" +ASSERT body["notification"]["title"] == "Device Push" +``` + +--- + +## RSH1a — publish rejects empty recipient + +**Spec requirement:** RSH1a — Empty values for `recipient` should be immediately rejected. + +Tests that calling publish with an empty recipient throws an error without making an HTTP request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: {}, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code == 40000 + +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSH1a — publish rejects empty data + +**Spec requirement:** RSH1a — Empty values for `data` should be immediately rejected. + +Tests that calling publish with empty data throws an error without making an HTTP request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: { "clientId": "user-123" }, + data: {} +) FAILS WITH error +ASSERT error.code == 40000 + +# No HTTP request should have been made +ASSERT captured_requests.length == 0 +``` + +--- + +## RSH1a — publish rejects null recipient + +**Spec requirement:** RSH1a — Empty values for `recipient` should be immediately rejected. + +Tests that calling publish with a null recipient throws an error. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(201, {}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: null, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code == 40000 + +ASSERT captured_requests.length == 0 +``` + +--- + +## RSH1a — publish propagates server error + +**Spec requirement:** RSH1a — Tests should exist with invalid recipient details. + +Tests that a server error response is propagated 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 recipient" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: { "transportType": "invalid" }, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` diff --git a/uts/rest/unit/push/push_channel_subscriptions.md b/uts/rest/unit/push/push_channel_subscriptions.md new file mode 100644 index 000000000..588d528b8 --- /dev/null +++ b/uts/rest/unit/push/push_channel_subscriptions.md @@ -0,0 +1,592 @@ +# PushChannelSubscriptions Tests + +Spec points: `RSH1c`, `RSH1c1`, `RSH1c2`, `RSH1c3`, `RSH1c4`, `RSH1c5` + +## 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. + +--- + +## RSH1c1 — list returns paginated PushChannelSubscription filtered by channel + +**Spec requirement:** RSH1c1 — `#list(params)` performs a request to `/push/channelSubscriptions` and returns a paginated result with `PushChannelSubscription` objects filtered by the provided params. + +Tests that `list()` sends a GET with `channel` filter and returns a `PaginatedResult`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "my-channel", + "deviceId": "device-001" + }, + { + "channel": "my-channel", + "clientId": "client-abc" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.list({"channel": "my-channel"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["channel"] == "my-channel" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 +ASSERT result.items[0] IS PushChannelSubscription +ASSERT result.items[0].channel == "my-channel" +ASSERT result.items[0].deviceId == "device-001" +ASSERT result.items[1].clientId == "client-abc" +``` + +--- + +## RSH1c1 — list filters by deviceId and clientId + +**Spec requirement:** RSH1c1 — A test should exist filtering by `deviceId` and/or `clientId`. + +Tests that `list()` forwards `deviceId` and `clientId` as query parameters. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "notifications", + "deviceId": "device-001" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.list({ + "deviceId": "device-001", + "clientId": "client-abc" +}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +ASSERT captured_requests[0].url.queryParams["clientId"] == "client-abc" +ASSERT result.items.length == 1 +``` + +--- + +## RSH1c1 — list supports limit for pagination + +**Spec requirement:** RSH1c1 — A test should exist controlling the pagination with the `limit` attribute. + +Tests that `list()` forwards the `limit` parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "channel": "ch-1", + "deviceId": "device-001" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.list({"limit": "5"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["limit"] == "5" +``` + +--- + +## RSH1c2 — listChannels returns paginated channel names + +**Spec requirement:** RSH1c2 — `#listChannels(params)` performs a request to `/push/channels` and returns a paginated result with `String` objects. + +Tests that `listChannels()` sends a GET to the correct endpoint and returns a paginated list of channel name strings. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, ["channel-1", "channel-2", "channel-3"]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.listChannels({}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/channels" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 3 +ASSERT result.items[0] == "channel-1" +ASSERT result.items[1] == "channel-2" +ASSERT result.items[2] == "channel-3" +``` + +--- + +## RSH1c2 — listChannels supports limit and pagination + +**Spec requirement:** RSH1c2 — A test should exist using the `limit` attribute and pagination. + +Tests that `listChannels()` forwards the `limit` parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, ["channel-1"]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.listChannels({"limit": "1"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["limit"] == "1" +ASSERT result.items.length == 1 +``` + +--- + +## RSH1c3 — save issues POST with PushChannelSubscription + +**Spec requirement:** RSH1c3 — `#save(pushChannelSubscription)` issues a `POST` request to `/push/channelSubscriptions` using the `PushChannelSubscription` object argument. + +Tests that `save()` sends a POST with the subscription in the body and returns the saved `PushChannelSubscription`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "channel": "my-channel", + "deviceId": "device-001" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + deviceId: "device-001" +) + +result = AWAIT client.push.admin.channelSubscriptions.save(subscription) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "POST" +ASSERT request.url.path == "/push/channelSubscriptions" + +body = parse_json(request.body) +ASSERT body["channel"] == "my-channel" +ASSERT body["deviceId"] == "device-001" + +ASSERT result IS PushChannelSubscription +ASSERT result.channel == "my-channel" +ASSERT result.deviceId == "device-001" +``` + +--- + +## RSH1c3 — save updates existing subscription + +**Spec requirement:** RSH1c3 — A test should exist for a successful subsequent save with an update. + +Tests that saving an existing subscription performs an update. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(200, { + "channel": "my-channel", + "clientId": "client-abc" + }) + ELSE: + req.respond_with(200, { + "channel": "my-channel", + "clientId": "client-abc" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + clientId: "client-abc" +) + +result1 = AWAIT client.push.admin.channelSubscriptions.save(subscription) +result2 = AWAIT client.push.admin.channelSubscriptions.save(subscription) +``` + +### Assertions +```pseudo +ASSERT request_count == 2 +ASSERT result1.channel == "my-channel" +ASSERT result2.channel == "my-channel" +``` + +--- + +## RSH1c3 — save propagates server error + +**Spec requirement:** RSH1c3 — A test should exist for a failed save operation. + +Tests that a server error during save is propagated 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 subscription" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + deviceId: "device-001" +) + +AWAIT client.push.admin.channelSubscriptions.save(subscription) FAILS WITH error +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` + +--- + +## RSH1c4 — remove issues DELETE with clientId subscription attributes + +**Spec requirement:** RSH1c4 — `#remove(push_channel_subscription)` issues a `DELETE` request to `/push/channelSubscriptions` and deletes the channel subscription using the attributes as params to the `DELETE` request. + +Tests that `remove()` sends a DELETE with the subscription's attributes as query parameters for a `clientId`-based subscription. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + clientId: "client-abc" +) + +AWAIT client.push.admin.channelSubscriptions.remove(subscription) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["clientId"] == "client-abc" +``` + +--- + +## RSH1c4 — remove issues DELETE with deviceId subscription attributes + +**Spec requirement:** RSH1c4 — A test should exist that deletes a `deviceId` channel subscription. + +Tests that `remove()` sends a DELETE with the subscription's attributes as query parameters for a `deviceId`-based subscription. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +subscription = PushChannelSubscription( + channel: "my-channel", + deviceId: "device-001" +) + +AWAIT client.push.admin.channelSubscriptions.remove(subscription) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "DELETE" +ASSERT captured_requests[0].url.path == "/push/channelSubscriptions" +ASSERT captured_requests[0].url.queryParams["channel"] == "my-channel" +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +``` + +--- + +## RSH1c4 — remove succeeds for nonexistent subscription + +**Spec requirement:** RSH1c4 — A test should exist that deletes a subscription that does not exist but still succeeds. + +Tests that removing a nonexistent subscription does not throw an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +subscription = PushChannelSubscription( + channel: "nonexistent-channel", + clientId: "nonexistent-client" +) + +# Should not throw — server returns success even for nonexistent subscriptions +AWAIT client.push.admin.channelSubscriptions.remove(subscription) +``` + +--- + +## RSH1c5 — removeWhere issues DELETE with clientId param + +**Spec requirement:** RSH1c5 — `#removeWhere(params)` issues a `DELETE` request to `/push/channelSubscriptions` and deletes the matching channel subscriptions provided in `params`. + +Tests that `removeWhere()` sends a DELETE with `clientId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.channelSubscriptions.removeWhere({"clientId": "client-abc"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/channelSubscriptions" +ASSERT request.url.queryParams["clientId"] == "client-abc" +``` + +--- + +## RSH1c5 — removeWhere issues DELETE with deviceId param + +**Spec requirement:** RSH1c5 — A test should exist that deletes channel subscriptions by `deviceId`. + +Tests that `removeWhere()` sends a DELETE with `deviceId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.channelSubscriptions.removeWhere({"deviceId": "device-001"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "DELETE" +ASSERT captured_requests[0].url.path == "/push/channelSubscriptions" +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +``` + +--- + +## RSH1c5 — removeWhere succeeds with no matching subscriptions + +**Spec requirement:** RSH1c5 — A test should exist that issues a delete for subscriptions with no matching params and checks the operation still succeeds. + +Tests that `removeWhere()` succeeds even when no subscriptions match the params. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw — server returns success even with no matching subscriptions +AWAIT client.push.admin.channelSubscriptions.removeWhere({"clientId": "nonexistent-client"}) +``` diff --git a/uts/rest/unit/push/push_device_registrations.md b/uts/rest/unit/push/push_device_registrations.md new file mode 100644 index 000000000..c9d500031 --- /dev/null +++ b/uts/rest/unit/push/push_device_registrations.md @@ -0,0 +1,642 @@ +# PushDeviceRegistrations Tests + +Spec points: `RSH1b`, `RSH1b1`, `RSH1b2`, `RSH1b3`, `RSH1b4`, `RSH1b5` + +## 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. + +--- + +## RSH1b1 — get returns DeviceDetails for known device + +**Spec requirement:** RSH1b1 — `#get(deviceId)` performs a request to `/push/deviceRegistrations/:deviceId` and returns a `DeviceDetails` object. + +Tests that `get()` sends a GET request with the correct path and returns a parsed `DeviceDetails`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "id": "device-001", + "clientId": "client-abc", + "formFactor": "phone", + "platform": "ios", + "metadata": { "model": "iPhone 14" }, + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-123" }, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +device = AWAIT client.push.admin.deviceRegistrations.get("device-001") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("device-001") + +ASSERT device IS DeviceDetails +ASSERT device.id == "device-001" +ASSERT device.clientId == "client-abc" +ASSERT device.formFactor == "phone" +ASSERT device.platform == "ios" +ASSERT device.metadata["model"] == "iPhone 14" +ASSERT device.push.recipient["transportType"] == "apns" +ASSERT device.push.state == "Active" +``` + +--- + +## RSH1b1 — get returns error for unknown device + +**Spec requirement:** RSH1b1 — Results in a not found error if the device cannot be found. + +Tests that `get()` propagates a 404 error when the device does not exist. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(404, { + "error": { + "code": 40400, + "statusCode": 404, + "message": "Device not found" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.deviceRegistrations.get("nonexistent-device") FAILS WITH error +ASSERT error.code == 40400 +ASSERT error.statusCode == 404 +``` + +--- + +## RSH1b1 — get URL-encodes deviceId + +**Spec requirement:** RSH1b1 — `#get(deviceId)` performs a request to `/push/deviceRegistrations/:deviceId`. + +Tests that the deviceId is properly URL-encoded in the request path. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "id": "device/with special:chars", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": {}, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.get("device/with special:chars") +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.path == "/push/deviceRegistrations/" + encode_uri_component("device/with special:chars") +``` + +--- + +## RSH1b2 — list returns paginated DeviceDetails filtered by deviceId + +**Spec requirement:** RSH1b2 — `#list(params)` performs a request to `/push/deviceRegistrations` and returns a paginated result with `DeviceDetails` objects filtered by the provided params. + +Tests that `list()` sends a GET with `deviceId` filter and returns a `PaginatedResult`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "device-001", + "clientId": "client-abc", + "platform": "ios", + "formFactor": "phone", + "push": { "recipient": {}, "state": "Active" } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"deviceId": "device-001"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.queryParams["deviceId"] == "device-001" + +ASSERT result IS PaginatedResult +ASSERT result.items.length == 1 +ASSERT result.items[0] IS DeviceDetails +ASSERT result.items[0].id == "device-001" +``` + +--- + +## RSH1b2 — list returns paginated DeviceDetails filtered by clientId + +**Spec requirement:** RSH1b2 — A test should exist filtering by `clientId`. + +Tests that `list()` sends a GET with `clientId` filter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "device-001", + "clientId": "client-abc", + "platform": "ios", + "formFactor": "phone", + "push": { "recipient": {}, "state": "Active" } + }, + { + "id": "device-002", + "clientId": "client-abc", + "platform": "android", + "formFactor": "tablet", + "push": { "recipient": {}, "state": "Active" } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"clientId": "client-abc"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["clientId"] == "client-abc" +ASSERT result.items.length == 2 +ASSERT result.items[0].clientId == "client-abc" +ASSERT result.items[1].clientId == "client-abc" +``` + +--- + +## RSH1b2 — list supports limit for pagination + +**Spec requirement:** RSH1b2 — A test should exist controlling the pagination with the `limit` attribute. + +Tests that `list()` forwards the `limit` parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, [ + { + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { "recipient": {}, "state": "Active" } + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"limit": "2"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].url.queryParams["limit"] == "2" +``` + +--- + +## RSH1b3 — save issues PUT with DeviceDetails + +**Spec requirement:** RSH1b3 — `#save(device)` issues a `PUT` request to `/push/deviceRegistrations/:deviceId` using the `DeviceDetails` object argument. + +Tests that `save()` sends a PUT with the device details in the body and returns the saved `DeviceDetails`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "id": "device-001", + "clientId": "client-abc", + "platform": "ios", + "formFactor": "phone", + "metadata": {}, + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-123" }, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +device = DeviceDetails( + id: "device-001", + clientId: "client-abc", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-123" } + ) +) + +result = AWAIT client.push.admin.deviceRegistrations.save(device) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "PUT" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("device-001") + +body = parse_json(request.body) +ASSERT body["id"] == "device-001" +ASSERT body["clientId"] == "client-abc" +ASSERT body["platform"] == "ios" +ASSERT body["formFactor"] == "phone" +ASSERT body["push"]["recipient"]["transportType"] == "apns" + +ASSERT result IS DeviceDetails +ASSERT result.id == "device-001" +ASSERT result.push.state == "Active" +``` + +--- + +## RSH1b3 — save updates existing device + +**Spec requirement:** RSH1b3 — A test should exist for a successful subsequent save with an update. + +Tests that `save()` can update an already-registered device. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + # First save — initial registration + req.respond_with(200, { + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-old" }, + "state": "Active" + } + }) + ELSE: + # Second save — update + req.respond_with(200, { + "id": "device-001", + "platform": "ios", + "formFactor": "phone", + "push": { + "recipient": { "transportType": "apns", "deviceToken": "token-new" }, + "state": "Active" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +device = DeviceDetails( + id: "device-001", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-old" } + ) +) + +result1 = AWAIT client.push.admin.deviceRegistrations.save(device) + +updated_device = DeviceDetails( + id: "device-001", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-new" } + ) +) + +result2 = AWAIT client.push.admin.deviceRegistrations.save(updated_device) +``` + +### Assertions +```pseudo +ASSERT result1.push.recipient["deviceToken"] == "token-old" +ASSERT result2.push.recipient["deviceToken"] == "token-new" +ASSERT request_count == 2 +``` + +--- + +## RSH1b3 — save propagates server error + +**Spec requirement:** RSH1b3 — A test should exist for a failed save operation. + +Tests that a server error during save is propagated 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 device details" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +device = DeviceDetails( + id: "device-001", + platform: "ios", + formFactor: "phone", + push: DevicePushDetails(recipient: {}) +) + +AWAIT client.push.admin.deviceRegistrations.save(device) FAILS WITH error +ASSERT error.code == 40000 +ASSERT error.statusCode == 400 +``` + +--- + +## RSH1b4 — remove issues DELETE for device + +**Spec requirement:** RSH1b4 — `#remove(deviceId)` issues a `DELETE` request to `/push/deviceRegistrations/:deviceId`. + +Tests that `remove()` sends a DELETE request with 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(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove("device-001") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component("device-001") +``` + +--- + +## RSH1b4 — remove succeeds for nonexistent device + +**Spec requirement:** RSH1b4 — A test should exist that deletes a device that does not exist but still succeeds. + +Tests that removing a nonexistent device does not throw an error. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw — server returns success even for nonexistent devices +AWAIT client.push.admin.deviceRegistrations.remove("nonexistent-device") +``` + +--- + +## RSH1b5 — removeWhere issues DELETE with clientId param + +**Spec requirement:** RSH1b5 — `#removeWhere(params)` issues a `DELETE` request to `/push/deviceRegistrations` and deletes the registered devices matching the provided `params`. + +Tests that `removeWhere()` sends a DELETE with `clientId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.removeWhere({"clientId": "client-abc"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "DELETE" +ASSERT request.url.path == "/push/deviceRegistrations" +ASSERT request.url.queryParams["clientId"] == "client-abc" +``` + +--- + +## RSH1b5 — removeWhere issues DELETE with deviceId param + +**Spec requirement:** RSH1b5 — A test should exist that deletes devices by `deviceId`. + +Tests that `removeWhere()` sends a DELETE with `deviceId` as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.push.admin.deviceRegistrations.removeWhere({"deviceId": "device-001"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests[0].method == "DELETE" +ASSERT captured_requests[0].url.path == "/push/deviceRegistrations" +ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" +``` + +--- + +## RSH1b5 — removeWhere succeeds with no matching devices + +**Spec requirement:** RSH1b5 — A test should exist that issues a delete for devices with no matching params and checks the operation still succeeds. + +Tests that `removeWhere()` succeeds even when no devices match the params. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw — server returns success even with no matching devices +AWAIT client.push.admin.deviceRegistrations.removeWhere({"clientId": "nonexistent-client"}) +``` diff --git a/uts/rest/unit/request.md b/uts/rest/unit/request.md new file mode 100644 index 000000000..b657240ba --- /dev/null +++ b/uts/rest/unit/request.md @@ -0,0 +1,996 @@ +# REST Client request() Tests + +Spec points: `RSC19`, `RSC19b`, `RSC19c`, `RSC19d`, `RSC19e`, `RSC19f`, `RSC19f1`, `HP1`, `HP3`, `HP4`, `HP5`, `HP6`, `HP7`, `HP8` + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers +- Await-based API for coordinating test responses + +See `rest_client.md` for detailed mock interface documentation. + +## Overview + +The `request()` method provides a generic way to make HTTP requests to Ably endpoints with all built-in library functionality (authentication, paging, fallback hosts, protocol encoding). + +--- + +## RSC19f - Method signature supports required HTTP methods + +**Spec requirement:** The `request()` method must support GET, POST, PUT, PATCH, and DELETE HTTP methods. + +Tests that the request() method supports GET, POST, PUT, PATCH, and DELETE methods. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) +``` + +### Test Cases + +| ID | Method | Path | Expected | +|----|--------|------|----------| +| 1 | GET | /test | Success | +| 2 | POST | /test | Success | +| 3 | PUT | /test | Success | +| 4 | PATCH | /test | Success | +| 5 | DELETE | /test | Success | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + + response = AWAIT client.request(test_case.method, test_case.path, version: 3) + + ASSERT captured_requests.length == 1 + request = captured_requests[0] + ASSERT request.method == test_case.method + ASSERT request.url.path == test_case.path +``` + +--- + +## RSC19f - Query parameters passed correctly + +**Spec requirement:** The `params` argument must add query parameters to the request URL. + +Tests that the params argument adds URL query parameters. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/channels/test/messages", + version: 3, + params: { "limit": "10", "direction": "backwards" } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.url.query_params["limit"] == "10" +ASSERT request.url.query_params["direction"] == "backwards" +``` + +--- + +## RSC19f - Custom headers passed correctly + +**Spec requirement:** The `headers` argument must add custom HTTP headers to the request. + +Tests that the headers argument adds custom HTTP headers. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", + version: 3, + headers: { "X-Custom-Header": "custom-value", "X-Another": "another-value" } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.headers["X-Custom-Header"] == "custom-value" +ASSERT request.headers["X-Another"] == "another-value" +``` + +--- + +## RSC19f - Request body sent correctly + +**Spec requirement:** The `body` argument must be included in the request and encoded according to the configured protocol. + +Tests that the body argument is included in the request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(201, { "id": "123" }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON for easier inspection +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/channels/test/messages", + version: 3, + body: { "name": "event", "data": "payload" } +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +body = json_decode(request.body) +ASSERT body["name"] == "event" +ASSERT body["data"] == "payload" +``` + +--- + +## RSC19f1 - X-Ably-Version header uses explicit version parameter + +Tests that the version parameter sets the X-Ably-Version header. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Version | Expected Header | +|----|---------|-----------------| +| 1 | 2 | "2" | +| 2 | 3 | "3" | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(200, []) + + response = AWAIT client.request("GET", "/test", version: test_case.version) + + request = mock_http.captured_requests[0] + ASSERT request.headers["X-Ably-Version"] == test_case.expected_header +``` + +--- + +## RSC19b - Uses configured authentication + +**Spec requirement:** The `request()` method must use the REST client's configured authentication mechanism (Basic auth for API keys, Bearer token for token auth). + +Tests that request() uses the REST client's configured authentication mechanism. + +### Test Case 1: Basic authentication (API key) + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT "Authorization" IN request.headers +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " + +# Verify the base64 encoded credentials +credentials = base64_decode(request.headers["Authorization"].substring(6)) +ASSERT credentials == "appId.keyId:keySecret" +``` + +### Test Case 2: Token authentication + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(token: "my-token-string")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT "Authorization" IN request.headers +ASSERT request.headers["Authorization"] STARTS_WITH "Bearer " +``` + +--- + +## RSC19c - Protocol headers set correctly (JSON) + +Tests that Accept and Content-Type headers reflect the configured protocol. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "data": "test" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT request.headers["Accept"] == "application/json" +ASSERT request.headers["Content-Type"] == "application/json" +``` + +--- + +## RSC19c - Protocol headers set correctly (MsgPack) + +Tests that Accept and Content-Type headers reflect MsgPack protocol when configured. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, msgpack_encode([])) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true # MsgPack +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "data": "test" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT request.headers["Accept"] == "application/x-msgpack" +ASSERT request.headers["Content-Type"] == "application/x-msgpack" +``` + +--- + +## RSC19c - Request body encoded according to protocol + +Tests that the request body is encoded using the configured protocol. + +### Test Case 1: JSON encoding + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "name": "event", "data": { "nested": "value" } } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +# Body should be valid JSON +body = json_decode(request.body) +ASSERT body["name"] == "event" +ASSERT body["data"]["nested"] == "value" +``` + +### Test Case 2: MsgPack encoding + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, msgpack_encode([])) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("POST", "/test", + version: 3, + body: { "name": "event", "data": "value" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +# Body should be valid MsgPack +body = msgpack_decode(request.body) +ASSERT body["name"] == "event" +ASSERT body["data"] == "value" +``` + +--- + +## RSC19c - Response body decoded according to Content-Type + +Tests that the response body is automatically decoded based on Content-Type header. + +### Test Case 1: JSON response + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: json_encode([{ "id": "1", "name": "item1" }, { "id": "2", "name": "item2" }]), + headers: { "Content-Type": "application/json" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +ASSERT items.length == 2 +ASSERT items[0]["id"] == "1" +ASSERT items[1]["name"] == "item2" +``` + +### Test Case 2: MsgPack response + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: msgpack_encode([{ "id": "1" }]), + headers: { "Content-Type": "application/x-msgpack" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +ASSERT items.length == 1 +ASSERT items[0]["id"] == "1" +``` + +--- + +## RSC19d, HP4 - HttpPaginatedResponse provides status code + +| Spec | Requirement | +|------|-------------| +| RSC19d | Request returns HttpPaginatedResponse | +| HP4 | Response provides HTTP status code | + +Tests that the response object provides access to the HTTP status code. + +### Setup +```pseudo +mock_http = MockHttpClient() +``` + +### Test Cases + +| ID | Status Code | +|----|-------------| +| 1 | 200 | +| 2 | 201 | +| 3 | 400 | +| 4 | 404 | +| 5 | 500 | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + + IF test_case.status_code >= 400: + mock_http.queue_response(test_case.status_code, + { "error": { "code": test_case.status_code * 100, "message": "Error" } }) + ELSE: + mock_http.queue_response(test_case.status_code, []) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + response = AWAIT client.request("GET", "/test", version: 3) + + ASSERT response.statusCode == test_case.status_code +``` + +--- + +## RSC19d, HP5 - HttpPaginatedResponse provides success indicator + +Tests that the success property correctly reflects 2xx status codes. + +### Setup +```pseudo +mock_http = MockHttpClient() +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Status Code | Expected Success | +|----|-------------|------------------| +| 1 | 200 | true | +| 2 | 201 | true | +| 3 | 204 | true | +| 4 | 299 | true | +| 5 | 300 | false | +| 6 | 400 | false | +| 7 | 500 | false | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + + IF test_case.status_code >= 400: + mock_http.queue_response(test_case.status_code, + { "error": { "code": test_case.status_code * 100, "message": "Error" } }) + ELSE: + mock_http.queue_response(test_case.status_code, []) + + response = AWAIT client.request("GET", "/test", version: 3) + + ASSERT response.success == test_case.expected_success +``` + +--- + +## RSC19d, HP6 - HttpPaginatedResponse provides error code from header + +Tests that the errorCode property extracts the value from X-Ably-Errorcode header. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(401, + body: { "error": { "code": 40101, "message": "Unauthorized" } }, + headers: { "X-Ably-Errorcode": "40101" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.errorCode == 40101 +``` + +--- + +## RSC19d, HP7 - HttpPaginatedResponse provides error message from header + +Tests that the errorMessage property extracts the value from X-Ably-Errormessage header. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(401, + body: { "error": { "code": 40101, "message": "Unauthorized" } }, + headers: { + "X-Ably-Errorcode": "40101", + "X-Ably-Errormessage": "Token expired" + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.errorMessage == "Token expired" +``` + +--- + +## RSC19d, HP8 - HttpPaginatedResponse provides all response headers + +Tests that all response headers are accessible. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: [], + headers: { + "Content-Type": "application/json", + "X-Request-Id": "req-123", + "X-Custom-Header": "custom-value" + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +headers = response.headers +ASSERT headers["Content-Type"] == "application/json" +ASSERT headers["X-Request-Id"] == "req-123" +ASSERT headers["X-Custom-Header"] == "custom-value" +``` + +--- + +## RSC19d, HP3 - HttpPaginatedResponse provides response items + +Tests that the items() method returns the decoded response body. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, [ + { "id": "msg1", "name": "event1", "data": "data1" }, + { "id": "msg2", "name": "event2", "data": "data2" } +]) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/channels/test/messages", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +ASSERT items.length == 2 +ASSERT items[0]["id"] == "msg1" +ASSERT items[1]["id"] == "msg2" +``` + +--- + +## RSC19d, HP1 - HttpPaginatedResponse pagination support + +| Spec | Requirement | +|------|-------------| +| RSC19d | Request returns HttpPaginatedResponse | +| HP1 | Response supports pagination with Link headers | + +Tests that multi-page responses can be navigated using next(). + +### Setup +```pseudo +mock_http = MockHttpClient() + +# First page +mock_http.queue_response(200, + body: [{ "id": "1" }, { "id": "2" }], + headers: { + "Link": '; rel="next"' + } +) + +# Second page +mock_http.queue_response(200, + body: [{ "id": "3" }], + headers: {} # No "next" link - last page +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/channels/test/messages", version: 3) +``` + +### Assertions +```pseudo +# First page +items1 = response.items() +ASSERT items1.length == 2 +ASSERT response.hasNext() == true + +# Navigate to second page +response = AWAIT response.next() +items2 = response.items() +ASSERT items2.length == 1 +ASSERT items2[0]["id"] == "3" +ASSERT response.hasNext() == false +``` + +--- + +## RSC19d - Non-array response handling + +Tests that non-array responses are handled correctly (wrapped as single item). + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/time", version: 3) +items = response.items() +``` + +### Assertions +```pseudo +# Non-array response should be accessible +ASSERT items.length == 1 OR items["time"] == 1234567890000 +# Implementation may vary - either wrap in array or return object directly +``` + +--- + +## RSC19e - Network error handling + +**Spec requirement:** Network errors must be properly propagated to the caller after all fallback attempts are exhausted. + +Tests that network errors are properly propagated after fallback attempts. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: [] # Disable fallback for this test +)) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/test", version: 3) FAILS WITH error +ASSERT error.code == 80000 OR error.message CONTAINS "network" OR error.message CONTAINS "connection" +``` + +--- + +## RSC19e - Timeout error handling + +Tests that request timeouts are properly handled. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_delayed_response( + delay: 5000, # 5 second delay + status: 200, + body: [] +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000, # 1 second timeout + fallbackHosts: [] # Disable fallback +)) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/test", version: 3) FAILS WITH error +ASSERT error.code == 50003 OR error.message CONTAINS "timeout" +``` + +--- + +## RSC19e - HTTP error status does not trigger fallback + +Tests that HTTP error responses (4xx, 5xx with valid Ably error body) are returned directly without fallback retry. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(400, + body: { "error": { "code": 40000, "message": "Bad request" } }, + headers: { "X-Ably-Errorcode": "40000" } +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["a.ably-realtime.com", "b.ably-realtime.com"] +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +# Should return the error response, not retry to fallback +ASSERT response.statusCode == 400 +ASSERT response.success == false +ASSERT response.errorCode == 40000 + +# Only one request should have been made (no fallback) +ASSERT mock_http.captured_requests.length == 1 +``` + +--- + +## RSC19e, RSC15 - Fallback hosts tried on server errors + +Tests that fallback hosts are attempted when primary host returns server error without valid Ably error. + +### Setup +```pseudo +mock_http = MockHttpClient() + +# Primary host fails with non-Ably 500 error +mock_http.queue_response(500, + body: "Internal Server Error", + headers: { "Content-Type": "text/plain" } +) + +# Fallback succeeds +mock_http.queue_response(200, [{ "id": "1" }]) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackHosts: ["fallback.ably-realtime.com"] +)) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("GET", "/test", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.statusCode == 200 +ASSERT response.success == true + +# Two requests: primary failed, fallback succeeded +ASSERT mock_http.captured_requests.length == 2 +ASSERT mock_http.captured_requests[1].url.host == "fallback.ably-realtime.com" +``` + +--- + +## RSC19b - Cannot override authentication + +Tests that the request() method does not allow overriding the configured authentication via custom headers. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, []) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Attempt to override auth with custom header +response = AWAIT client.request("GET", "/test", + version: 3, + headers: { "Authorization": "Bearer malicious-token" } +) +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] + +# The configured Basic auth should be used, not the custom header +ASSERT request.headers["Authorization"] STARTS_WITH "Basic " +# Should NOT contain the attempted override +ASSERT request.headers["Authorization"] != "Bearer malicious-token" +``` + +### Note +This behavior may vary by implementation. Some libraries may allow header override while others enforce configured auth. The spec states authentication is "unconditional" per RSC19b. + +--- + +## RSC19f - Path with leading slash + +Tests that paths are handled correctly whether or not they include a leading slash. + +### Setup +```pseudo +mock_http = MockHttpClient() +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Cases + +| ID | Path | Expected Path in Request | +|----|------|--------------------------| +| 1 | "/channels/test" | "/channels/test" | +| 2 | "channels/test" | "/channels/test" | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(200, []) + + response = AWAIT client.request("GET", test_case.path, version: 3) + + request = mock_http.captured_requests[0] + ASSERT request.url.path == test_case.expected_path +``` + +--- + +## RSC19d - Empty response handling + +Tests that empty responses (204 No Content) are handled correctly. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(204, + body: null, # No body + headers: {} +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +response = AWAIT client.request("DELETE", "/channels/test/messages/123", version: 3) +``` + +### Assertions +```pseudo +ASSERT response.statusCode == 204 +ASSERT response.success == true +items = response.items() +ASSERT items IS null OR items.length == 0 +``` diff --git a/uts/rest/unit/request_endpoint.md b/uts/rest/unit/request_endpoint.md new file mode 100644 index 000000000..16abb31b8 --- /dev/null +++ b/uts/rest/unit/request_endpoint.md @@ -0,0 +1,172 @@ +# Request Endpoint Tests + +Spec points: `RSC25` + +## 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. + +--- + +## RSC25 - Requests sent to primary domain first + +**Spec requirement:** Requests are sent to the `primary domain` as determined by `REC1`. New HTTP requests (except where `RSC15f` applies and a cached fallback host is in effect) are first attempted against the `primary domain`. + +### RSC25 - Default primary domain used for requests + +Tests that REST requests are sent to the default primary domain when no endpoint configuration is provided. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == DEFAULT_REST_HOST +``` + +--- + +### RSC25 - Custom endpoint used for requests + +Tests that REST requests are sent to a custom production routing policy domain. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + endpoint: "sandbox" +)) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 1 +ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +``` + +--- + +### RSC25 - Multiple requests all go to primary domain + +Tests that successive requests continue to use the primary domain (no unexpected host switching). + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +AWAIT client.time() +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 3 +FOR EACH request IN mock_http.captured_requests: + ASSERT request.url.host == DEFAULT_REST_HOST +``` + +--- + +### RSC25 - Primary domain tried first before fallback + +Tests that when the primary host fails and a fallback succeeds, the primary was attempted first. + +#### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(500, {"error": {"code": 50000}}) + ELSE: + req.respond_with(200, {"time": 1234567890000}) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.time() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 +# First request was to primary domain +ASSERT mock_http.captured_requests[0].url.host == DEFAULT_REST_HOST +# Second request was to a fallback domain (not primary) +ASSERT mock_http.captured_requests[1].url.host != DEFAULT_REST_HOST +``` + +--- + +### RSC25 - Request path preserved when sent to primary domain + +Tests that the request path and query parameters are correctly constructed when sent to the primary domain. + +#### Setup +```pseudo +mock_http = MockHttpClient( + onRequest: (req) => { + req.respond_with(200, []) + } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +#### Test Steps +```pseudo +AWAIT client.channels.get("test-channel").history() +``` + +#### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 1 +request = mock_http.captured_requests[0] +ASSERT request.url.host == DEFAULT_REST_HOST +ASSERT request.url.path == "/channels/test-channel/messages" +ASSERT request.method == "GET" +``` diff --git a/uts/rest/unit/rest_client.md b/uts/rest/unit/rest_client.md new file mode 100644 index 000000000..94ae4893e --- /dev/null +++ b/uts/rest/unit/rest_client.md @@ -0,0 +1,540 @@ +# REST Client Tests + +Spec points: `RSC5`, `RSC7`, `RSC7b`, `RSC7c`, `RSC7d`, `RSC7e`, `RSC8`, `RSC8a`, `RSC8b`, `RSC8c`, `RSC8d`, `RSC8e`, `RSC13`, `RSC17`, `RSC18` + +## 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. + +--- + +## RSC5 - Auth Attribute + +**Spec requirement:** `RestClient#auth` attribute provides access to the `Auth` object that was instantiated with the `ClientOptions` provided in the `RestClient` constructor. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) +``` + +### Assertions +```pseudo +ASSERT client.auth IS NOT null +ASSERT client.auth IS Auth +``` + +--- + +## RSC7e - X-Ably-Version header + +**Spec requirement:** All REST requests must include the `X-Ably-Version` header with the spec version. + +Tests that all REST requests include the `X-Ably-Version` header. + +### Setup +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT captured_request IS NOT null +ASSERT "X-Ably-Version" IN captured_request.headers +ASSERT captured_request.headers["X-Ably-Version"] matches pattern "[0-9.]+" +``` + +--- + +## RSC7d, RSC7d1, RSC7d2 - Ably-Agent header + +| Spec | Requirement | +|------|-------------| +| RSC7d | All requests must include Ably-Agent header | +| RSC7d1 | Header format: space-separated key/value pairs | +| RSC7d2 | Must include library name and version | + +Tests that all REST requests include the `Ably-Agent` header with correct format. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT "Ably-Agent" IN request.headers + +agent = request.headers["Ably-Agent"] +# Format: key[/value] entries joined by spaces +# Must include at least library name/version +ASSERT agent matches pattern "ably-[a-z]+/[0-9]+\\.[0-9]+\\.[0-9]+" +# May include additional entries like platform info +``` + +--- + +## RSC7c - Request ID when addRequestIds enabled + +**Spec requirement:** When `addRequestIds` is true, all requests must include a `request_id` query parameter with a unique URL-safe identifier. + +Tests that `request_id` query parameter is included when `addRequestIds` is true. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + addRequestIds: true +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT "request_id" IN request.url.query_params + +request_id = request.url.query_params["request_id"] +# Should be url-safe base64 encoded, at least 12 characters (9 bytes base64) +ASSERT request_id.length >= 12 +ASSERT request_id matches pattern "[A-Za-z0-9_-]+" +``` + +--- + +## RSC7c - Request ID preserved on fallback retry + +**Spec requirement:** The same `request_id` must be preserved when retrying a failed request to fallback hosts. + +Tests that the same `request_id` is used when retrying to a fallback host. + +### Setup +```pseudo +mock_http = MockHttpClient() +# First request fails with 500 (triggers fallback retry) +mock_http.queue_response(500, { "error": { "code": 50000 } }) +# Retry succeeds +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + addRequestIds: true, + fallbackHosts: ["a.example.com", "b.example.com"] +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 2 + +request_id_1 = mock_http.captured_requests[0].url.query_params["request_id"] +request_id_2 = mock_http.captured_requests[1].url.query_params["request_id"] + +ASSERT request_id_1 == request_id_2 # Same ID for retry +``` + +--- + +## RSC8a, RSC8b - Protocol selection + +| Spec | Requirement | +|------|-------------| +| RSC8a | MessagePack protocol is used by default | +| RSC8b | JSON protocol used when `useBinaryProtocol` is false | + +Tests that the correct protocol (MessagePack or JSON) is used based on configuration. + +**Note:** This test covers both `Content-Type` and `Accept` headers for the configured protocol. RSC8c below tests the same assertions in a single-case form for clarity. The two tests are complementary — RSC8a/b focuses on protocol *selection*, RSC8c on header *consistency*. + +### Setup +```pseudo +mock_http = MockHttpClient() +``` + +### Test Cases + +| ID | useBinaryProtocol | Expected Content-Type | +|----|-------------------|----------------------| +| 1 | `true` (default) | `application/x-msgpack` | +| 2 | `false` | `application/json` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(201, { "serials": ["s1"] }) + + client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: test_case.useBinaryProtocol + )) + + AWAIT client.channels.get("test").publish(name: "e", data: "d") + + request = mock_http.captured_requests[0] + ASSERT request.headers["Content-Type"] == test_case.expected_content_type + ASSERT request.headers["Accept"] == test_case.expected_content_type +``` + +--- + +## RSC8c - Accept and Content-Type headers + +**Spec requirement:** Accept and Content-Type headers must match the configured protocol (application/json or application/x-msgpack). + +Tests that Accept and Content-Type headers reflect the configured protocol. + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(201, { "serials": ["s1"] }) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # JSON for easier inspection +)) +``` + +### Test Steps +```pseudo +AWAIT client.channels.get("test").publish(name: "e", data: "d") +``` + +### Assertions +```pseudo +request = mock_http.captured_requests[0] +ASSERT request.headers["Accept"] == "application/json" +ASSERT request.headers["Content-Type"] == "application/json" +``` + +--- + +## RSC8d - Handle mismatched response Content-Type + +**Spec requirement:** The client must be able to decode responses in either JSON or MessagePack format, regardless of which format was requested. + +Tests that responses with different Content-Type than requested are still processed if supported. + +### Setup +```pseudo +mock_http = MockHttpClient() +# Client requests JSON but server returns msgpack +mock_http.queue_response(200, + body: msgpack_encode({ "time": 1234567890000 }), + headers: { "Content-Type": "application/x-msgpack" } +) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: false # Client prefers JSON +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Should successfully parse msgpack response despite requesting JSON +ASSERT result IS DateTime OR result == 1234567890000 +``` + +--- + +## RSC8e - Unsupported Content-Type handling + +**Spec requirement:** When the server returns an unsupported Content-Type, the client must raise an error with code 40013 for 2xx responses, or propagate the HTTP status code for error responses. + +Tests error handling when server returns unsupported Content-Type. + +### Test Cases + +| ID | Status Code | Content-Type | Expected Error Code | +|----|-------------|--------------|---------------------| +| 1 | 500 | `text/html` | 500 (status propagated) | +| 2 | 200 | `text/html` | 40013 | + +### Setup (Case 1 - Error status) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(500, + body: "Server Error", + headers: { "Content-Type": "text/html" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps (Case 1) +```pseudo +AWAIT client.time() FAILS WITH error +ASSERT error.statusCode == 500 +# Note: the error message is not asserted here because the 500 path +# hits the SDK's generic error-response handling (which attempts to +# parse the body as a JSON error and falls back to a generic message). +# The key assertion is that the HTTP status code is propagated. +``` + +### Setup (Case 2 - Success status but bad content) +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, + body: "OK", + headers: { "Content-Type": "text/html" } +) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps (Case 2) +```pseudo +AWAIT client.time() FAILS WITH error +ASSERT error.statusCode == 400 +ASSERT error.code == 40013 +``` + +--- + +## RSC8 - Error response decoded from MessagePack + +**Spec requirement:** When the server returns an error response with `Content-Type: application/x-msgpack`, the SDK must decode the error body using MessagePack (not JSON). The error code, status code, and message must be correctly extracted. This is the default behaviour when `useBinaryProtocol` is `true` (the default), because the `Accept: application/x-msgpack` header causes the server to return all responses — including errors — in MessagePack format. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(400, + body: msgpack_encode({ + "error": { + "code": 40099, + "statusCode": 400, + "message": "Test error" + } + }), + headers: { "Content-Type": "application/x-msgpack" } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: true # Default — server returns msgpack +)) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40099 +ASSERT error.statusCode == 400 +ASSERT error.message == "Test error" +``` + +### Note +A common implementation bug is to always parse error response bodies as JSON +(e.g. `response.json()`), regardless of the response `Content-Type`. When the +server returns a MessagePack-encoded error body, the JSON parse fails silently +and the SDK falls back to a generic error code (e.g. 50000 InternalError), +losing the real error information. The SDK must check the response +`Content-Type` and use the appropriate deserializer. + +--- + +## RSC13 - Request timeouts + +**Spec requirement:** HTTP requests must respect the `httpRequestTimeout` option and fail with code 50003 when the timeout is exceeded. + +Tests that configured timeouts are applied to HTTP requests. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success() +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + httpRequestTimeout: 1000 # 1 second timeout +)) +``` + +### Test Steps +```pseudo +time_future = client.time() + +# Wait for request and respond with delay +request = AWAIT mock_http.await_request() +request.respond_with_delay(5000, 200, {"time": 1234567890000}) + +AWAIT time_future FAILS WITH error +ASSERT error.code == 50003 OR error.message CONTAINS "timeout" +``` + +### Note +The timeout must be enforced at the SDK level (wrapping the HTTP execute call), +not solely by the HTTP library's built-in timeout. HTTP library timeouts +typically do not fire with mock clients since no real network I/O occurs. + +The recommended implementation pattern is: +- The mock client's `execute()` sleeps for the configured delay before returning +- The SDK wraps the `execute()` call with its own timeout (using the language's + async timeout mechanism) +- The SDK timeout fires before the mock delay completes, producing the expected error + +This avoids requiring complex async connection-level mocking (`await_request` / +`respond_with_delay`) and keeps the test fast — the test only waits for the +short timeout duration (e.g. 100ms), not the full mock delay. + +--- + +## RSC17 - ClientId Attribute + +**Spec requirement:** When instantiating a `RestClient`, if a `clientId` attribute is set in `ClientOptions`, then the `Auth#clientId` attribute will contain the provided `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "explicit-client-id" +)) +``` + +### Assertions +```pseudo +ASSERT client.clientId == "explicit-client-id" +ASSERT client.clientId == client.auth.clientId +``` + +--- + +## RSC18 - TLS configuration + +**Spec requirement:** The `tls` option controls whether HTTPS (true, default) or HTTP (false) is used for REST requests. + +Tests that TLS setting controls protocol used. + +### Test Cases + +| ID | tls | Expected Scheme | +|----|-----|-----------------| +| 1 | `true` (default) | `https` | +| 2 | `false` | `http` | + +### Setup +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) +``` + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + mock_http.reset() + mock_http.queue_response(200, { "time": 1234567890000 }) + + client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + tls: test_case.tls + )) + + AWAIT client.time() + + request = mock_http.captured_requests[0] + ASSERT request.url.scheme == test_case.expected_scheme +``` + +--- + +## RSC18 - Basic auth over HTTP rejected + +**Spec requirement:** Basic authentication (API key) must be rejected when `tls` is false. Token authentication is permitted over HTTP. Error code 40103. + +Tests that Basic authentication is rejected when TLS is disabled. + +### Setup +```pseudo +# No mock needed - should fail before making request +``` + +### Test Steps +```pseudo +Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + tls: false +)) FAILS WITH error +ASSERT error.code == 40103 OR error.message CONTAINS "insecure" OR error.message CONTAINS "TLS" +``` + +### Note +Token auth over HTTP should be allowed. Only Basic auth (API key) should be rejected. + +### Additional Test - Token auth over HTTP allowed +```pseudo +mock_http = MockHttpClient() +mock_http.queue_response(200, { "time": 1234567890000 }) + +client = Rest(options: ClientOptions( + token: "some-token-string", + tls: false +)) + +result = AWAIT client.time() +# Should succeed - token auth over HTTP is permitted +ASSERT result IS valid +``` + +--- + +## Test Infrastructure Notes + +See `uts/test/rest/unit/helpers/mock_http.md` for mock installation, test isolation, and timer mocking guidance. diff --git a/uts/rest/unit/stats.md b/uts/rest/unit/stats.md new file mode 100644 index 000000000..e5c89f8fa --- /dev/null +++ b/uts/rest/unit/stats.md @@ -0,0 +1,682 @@ +# Stats API Tests + +Spec points: `RSC6`, `RSC6a`, `RSC6b1`, `RSC6b2`, `RSC6b3`, `RSC6b4` + +## 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. + +## Purpose + +Tests the `stats()` method which retrieves application statistics from Ably. The stats endpoint requires authentication and returns paginated results. + +--- + +## RSC6a - stats() returns PaginatedResult with Stats objects + +**Spec requirement:** Returns a `PaginatedResult` page containing `Stats` objects in the `PaginatedResult#items` attribute returned from the stats request. + +Tests that `stats()` makes a GET request to `/stats` and returns a PaginatedResult containing Stats objects. + +### Setup +```pseudo +captured_requests = [] +stats_data = [ + { + "intervalId": "2024-01-01:00:00", + "unit": "hour", + "all": { + "messages": {"count": 100, "data": 5000}, + "all": {"count": 100, "data": 5000} + } + }, + { + "intervalId": "2024-01-01:01:00", + "unit": "hour", + "all": { + "messages": {"count": 150, "data": 7500}, + "all": {"count": 150, "data": 7500} + } + } +] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, stats_data) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.stats() +``` + +### Assertions +```pseudo +# Result should be a PaginatedResult +ASSERT result IS PaginatedResult +ASSERT result.items.length == 2 + +# Stats objects should have correct fields +ASSERT result.items[0].intervalId == "2024-01-01:00:00" +ASSERT result.items[0].unit == "hour" +ASSERT result.items[1].intervalId == "2024-01-01:01:00" + +# Verify correct endpoint and method +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.path == "/stats" +``` + +--- + +## RSC6a - stats() sends authenticated request with standard headers + +**Spec requirement:** The `/stats` endpoint requires authentication. Requests must include valid credentials and standard Ably headers. + +Tests that stats() sends an authenticated request with standard Ably headers. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Request must be authenticated +ASSERT "Authorization" IN request.headers + +# Standard Ably headers must be present +ASSERT "X-Ably-Version" IN request.headers +ASSERT "Ably-Agent" IN request.headers +``` + +--- + +## RSC6b1 - stats() with start parameter + +**Spec requirement:** `start` is an optional timestamp field represented as milliseconds since epoch. If provided, must be equal to or less than `end` if provided or to the current time otherwise. + +Tests that the `start` parameter is sent as milliseconds since epoch. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +start_time = DateTime(2024, 1, 1, 0, 0, 0, UTC) +AWAIT client.stats(start: start_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) +``` + +--- + +## RSC6b1 - stats() with end parameter + +**Spec requirement:** `end` is an optional timestamp field represented as milliseconds since epoch. + +Tests that the `end` parameter is sent as milliseconds since epoch. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +end_time = DateTime(2024, 1, 31, 23, 59, 59, UTC) +AWAIT client.stats(end: end_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) +``` + +--- + +## RSC6b1 - stats() with start and end parameters + +**Spec requirement:** `start` and `end` are optional timestamp fields. `start`, if provided, must be equal to or less than `end` if provided. + +Tests that both `start` and `end` are sent as query parameters when provided together. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +start_time = DateTime(2024, 1, 1, 0, 0, 0, UTC) +end_time = DateTime(2024, 1, 31, 23, 59, 59, UTC) +AWAIT client.stats(start: start_time, end: end_time) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) +ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) +``` + +--- + +## RSC6b2 - stats() with direction parameter + +**Spec requirement:** `direction` backwards or forwards; if omitted the direction defaults to the REST API default (backwards). + +Tests that the `direction` parameter is sent as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats(direction: "forwards") +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["direction"] == "forwards" +``` + +--- + +## RSC6b2 - stats() direction defaults to backwards + +**Spec requirement:** If omitted the direction defaults to the REST API default (backwards). + +Tests that when direction is not specified, it is either omitted from the query (letting the server apply the default) or sent as "backwards". + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Direction should either be absent (server default) or "backwards" +ASSERT "direction" NOT IN request.query_params + OR request.query_params["direction"] == "backwards" +``` + +--- + +## RSC6b3 - stats() with limit parameter + +**Spec requirement:** `limit` supports up to 1,000 items; if omitted the limit defaults to the REST API default (100). + +Tests that the `limit` parameter is sent as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats(limit: 10) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["limit"] == "10" +``` + +--- + +## RSC6b3 - stats() limit defaults to 100 + +**Spec requirement:** If omitted the limit defaults to the REST API default (100). + +Tests that when limit is not specified, it is either omitted from the query (letting the server apply the default) or sent as "100". + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Limit should either be absent (server default) or "100" +ASSERT "limit" NOT IN request.query_params + OR request.query_params["limit"] == "100" +``` + +--- + +## RSC6b4 - stats() with unit parameter + +**Spec requirement:** `unit` is the period for which the stats will be aggregated by, values supported are `minute`, `hour`, `day` or `month`; if omitted the unit defaults to the REST API default (`minute`). + +Tests that each valid unit value is sent as a query parameter. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Cases + +| ID | Unit | +|----|------| +| 1 | minute | +| 2 | hour | +| 3 | day | +| 4 | month | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + AWAIT client.stats(unit: test_case.unit) + + ASSERT captured_requests.length == 1 + request = captured_requests[0] + ASSERT request.query_params["unit"] == test_case.unit +``` + +--- + +## RSC6b4 - stats() unit defaults to minute + +**Spec requirement:** If omitted the unit defaults to the REST API default (`minute`). + +Tests that when unit is not specified, it is either omitted from the query (letting the server apply the default) or sent as "minute". + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Unit should either be absent (server default) or "minute" +ASSERT "unit" NOT IN request.query_params + OR request.query_params["unit"] == "minute" +``` + +--- + +## RSC6b - stats() with all parameters combined + +| Spec | Requirement | +|------|-------------| +| RSC6b1 | `start` and `end` timestamp parameters | +| RSC6b2 | `direction` parameter | +| RSC6b3 | `limit` parameter | +| RSC6b4 | `unit` parameter | + +Tests that all parameters can be used together in a single request. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +start_time = DateTime(2024, 1, 1, 0, 0, 0, UTC) +end_time = DateTime(2024, 1, 31, 23, 59, 59, UTC) +AWAIT client.stats( + start: start_time, + end: end_time, + direction: "forwards", + limit: 50, + unit: "hour" +) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) +ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) +ASSERT request.query_params["direction"] == "forwards" +ASSERT request.query_params["limit"] == "50" +ASSERT request.query_params["unit"] == "hour" +``` + +--- + +## RSC6a - stats() with no parameters sends no query params + +**Spec requirement:** All parameters are optional. When no parameters are provided, the request should omit query parameters (letting the server apply defaults). + +Tests that calling stats() with no arguments sends a clean GET to `/stats`. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.path == "/stats" + +# No query parameters should be sent (server applies defaults) +ASSERT request.query_params IS empty +``` + +--- + +## RSC6a - stats() pagination with Link headers + +**Spec requirement:** Returns a `PaginatedResult` page. PaginatedResult supports navigation via Link headers (TG4, TG6). + +Tests that stats results support pagination navigation using Link headers. + +### Setup +```pseudo +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(200, + [{"intervalId": "2024-01-01:01:00", "unit": "hour"}], + headers: {"Link": '; rel="next"'} + ) + ELSE: + req.respond_with(200, + [{"intervalId": "2024-01-01:00:00", "unit": "hour"}] + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +page1 = AWAIT client.stats(limit: 1) +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# First page +ASSERT page1.items.length == 1 +ASSERT page1.items[0].intervalId == "2024-01-01:01:00" +ASSERT page1.hasNext() == true +ASSERT page1.isLast() == false + +# Second page +ASSERT page2.items.length == 1 +ASSERT page2.items[0].intervalId == "2024-01-01:00:00" +ASSERT page2.hasNext() == false +ASSERT page2.isLast() == true +``` + +--- + +## RSC6a - stats() empty results + +**Spec requirement:** Returns a `PaginatedResult` page containing `Stats` objects. Must handle empty result sets correctly. + +Tests that stats() handles empty results correctly. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.stats() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +ASSERT result.isLast() == true +``` + +--- + +## RSC6a - stats() error handling + +**Spec requirement:** Errors from the stats endpoint must be properly propagated to the caller. + +Tests that errors from the stats endpoint are properly propagated. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(401, { + "error": { + "message": "Unauthorized", + "code": 40100, + "statusCode": 401 + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.stats() FAILS WITH error +ASSERT error.statusCode == 401 +ASSERT error.code == 40100 +``` diff --git a/uts/rest/unit/time.md b/uts/rest/unit/time.md new file mode 100644 index 000000000..525b7cc9a --- /dev/null +++ b/uts/rest/unit/time.md @@ -0,0 +1,234 @@ +# Time API Tests + +Spec points: `RSC16` + +## Test Type +Unit test with mocked HTTP client + +## Mock Configuration + +These tests use the mock HTTP infrastructure defined in `rest_client.md`. The mock supports: +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Capturing requests via `captured_requests` arrays +- Configurable responses with status codes, bodies, and headers + +See `rest_client.md` for detailed mock interface documentation. + +## Purpose + +Tests the `time()` method which retrieves the current server time from Ably. + +**Note:** The `time()` endpoint does NOT require authentication. Do not use it for testing authentication - use the channel status endpoint instead. + +--- + +## RSC16 - time() returns server time + +**Spec requirement:** The `time()` method retrieves the server time from the `/time` endpoint and returns it as a DateTime or timestamp. + +Tests that `time()` returns the server time as a DateTime/timestamp. + +### Setup +```pseudo +captured_requests = [] +server_time_ms = 1704067200000 # 2024-01-01 00:00:00 UTC + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [server_time_ms]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Result should be a DateTime matching the server timestamp +ASSERT result IS DateTime +ASSERT result.millisecondsSinceEpoch == server_time_ms + +# Verify correct endpoint was called +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.path == "/time" +``` + +--- + +## RSC16 - time() request format + +**Spec requirement:** The time request must be a GET request to `/time` with standard Ably headers. + +Tests that the time request is correctly formatted. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [1704067200000]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 +request = captured_requests[0] + +# Should be GET request to /time +ASSERT request.method == "GET" +ASSERT request.path == "/time" + +# Should have standard Ably headers +ASSERT "X-Ably-Version" IN request.headers +ASSERT "Ably-Agent" IN request.headers +``` + +--- + +## RSC16 - time() does not require authentication + +**Spec requirement:** The `/time` endpoint does not require authentication and should not send an Authorization header, even when credentials are available. + +Tests that time() does not send authentication credentials, even when the client has them. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [1704067200000]) + } +) +install_mock(mock_http) + +# Client has credentials, but time() should not use them +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Should succeed +ASSERT result IS DateTime + +# Request should not have Authorization header even though client has credentials +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT "Authorization" NOT IN request.headers +``` + +--- + +## RSC16 - time() works without TLS + +**Spec requirement:** The `/time` endpoint does not require authentication, so it should be callable over HTTP (non-TLS) without sending credentials. The RSC18 restriction (no basic auth over non-TLS) does not apply because time() doesn't send authentication. + +Tests that time() succeeds over HTTP (non-TLS) without sending credentials. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [1704067200000]) + } +) +install_mock(mock_http) + +# Client with API key but using token auth to avoid RSC18 restriction +# on authenticated operations. time() should still work over HTTP. +client = Rest(options: ClientOptions( + key: "app.key:secret", + tls: false, + useTokenAuth: true +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.time() +``` + +### Assertions +```pseudo +# Should succeed without sending authentication over HTTP +ASSERT result IS DateTime + +# Request should use HTTP (not HTTPS) +ASSERT captured_requests.length == 1 +request = captured_requests[0] +ASSERT request.url.scheme == "http" + +# Request should not have Authorization header +ASSERT "Authorization" NOT IN request.headers +``` + +### Note +This test verifies that the RSC18 check (which rejects basic auth over non-TLS connections) is only applied to operations that require authentication. The `time()` endpoint is unauthenticated, so it should work regardless of TLS settings. The client constructor still requires credentials, but time() doesn't use them. + +--- + +## RSC16 - time() error handling + +**Spec requirement:** Errors from the `/time` endpoint should be properly propagated to the caller. + +Tests that errors from the time endpoint are properly propagated. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(500, { + "error": { + "message": "Internal server error", + "code": 50000, + "statusCode": 500 + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "app.key:secret")) +``` + +### Test Steps +```pseudo +AWAIT client.time() FAILS WITH error +ASSERT error.statusCode == 500 +ASSERT error.code == 50000 +``` diff --git a/uts/rest/unit/types/error_types.md b/uts/rest/unit/types/error_types.md new file mode 100644 index 000000000..6406880ea --- /dev/null +++ b/uts/rest/unit/types/error_types.md @@ -0,0 +1,231 @@ +# Error Types Tests + +Spec points: `TI1`, `TI2`, `TI3`, `TI4`, `TI5` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required - these verify type structure. + +--- + +## TI1-TI5 - ErrorInfo attributes + +**Spec requirement:** ErrorInfo type must provide all required attributes according to TI1-TI5 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TI1 | code | Ably-specific error code | +| TI2 | statusCode | HTTP status code | +| TI3 | message | Human-readable error message | +| TI4 | href | URL for more information | +| TI5 | cause | Underlying cause error/exception | + +Tests that `ErrorInfo` (or `AblyException`) has all required attributes. + +### Test Cases + +| ID | Spec | Attribute | Type | Description | +|----|------|-----------|------|-------------| +| 1 | TI1 | `code` | Integer | Ably-specific error code | +| 2 | TI2 | `statusCode` | Integer | HTTP status code | +| 3 | TI3 | `message` | String | Human-readable error message | +| 4 | TI4 | `href` | String | URL for more information | +| 5 | TI5 | `cause` | Error/Exception | Underlying cause | + +### Test Steps +```pseudo +# TI1 - code attribute +error = ErrorInfo(code: 40000) +ASSERT error.code == 40000 + +# TI2 - statusCode attribute +error = ErrorInfo(code: 40100, statusCode: 401) +ASSERT error.statusCode == 401 + +# TI3 - message attribute +error = ErrorInfo( + code: 40000, + statusCode: 400, + message: "Bad request: invalid parameter" +) +ASSERT error.message == "Bad request: invalid parameter" + +# TI4 - href attribute (optional) +error = ErrorInfo( + code: 40000, + href: "https://help.ably.io/error/40000" +) +ASSERT error.href == "https://help.ably.io/error/40000" + +# TI5 - cause attribute (optional) +original_error = Exception("Network failure") +error = ErrorInfo( + code: 50003, + statusCode: 500, + message: "Timeout", + cause: original_error +) +ASSERT error.cause == original_error +``` + +--- + +## TI - ErrorInfo from JSON response + +**Spec requirement:** ErrorInfo type must support deserialization from Ably JSON error responses. + +Tests that `ErrorInfo` can be deserialized from Ably error response. + +### Test Steps +```pseudo +json_response = { + "error": { + "code": 40100, + "statusCode": 401, + "message": "Token expired", + "href": "https://help.ably.io/error/40100" + } +} + +error = ErrorInfo.fromJson(json_response["error"]) + +ASSERT error.code == 40100 +ASSERT error.statusCode == 401 +ASSERT error.message == "Token expired" +ASSERT error.href == "https://help.ably.io/error/40100" +``` + +--- + +## TI - ErrorInfo with nested error + +**Spec requirement:** ErrorInfo must support nested error structures with a cause field (TI5). + +Tests parsing error response with nested error structure. + +### Test Steps +```pseudo +json_response = { + "error": { + "code": 50000, + "statusCode": 500, + "message": "Internal error", + "cause": { + "code": 50001, + "message": "Database connection failed" + } + } +} + +error = ErrorInfo.fromJson(json_response["error"]) + +ASSERT error.code == 50000 +ASSERT error.cause IS ErrorInfo OR error.cause IS Exception +IF error.cause IS ErrorInfo: + ASSERT error.cause.code == 50001 + ASSERT error.cause.message == "Database connection failed" +``` + +--- + +## TI - AblyException wraps ErrorInfo + +**Spec requirement:** AblyException (throwable) must wrap ErrorInfo and expose its attributes. + +Tests that `AblyException` (throwable) wraps `ErrorInfo`. + +### Test Steps +```pseudo +error_info = ErrorInfo( + code: 40000, + statusCode: 400, + message: "Bad request" +) + +exception = AblyException(errorInfo: error_info) + +ASSERT exception.code == 40000 +ASSERT exception.statusCode == 400 +ASSERT exception.message == "Bad request" +ASSERT exception.errorInfo == error_info +``` + +--- + +## TI - Common error codes + +**Spec requirement:** ErrorInfo must correctly handle common Ably error codes with their corresponding status codes and meanings. + +Tests that common Ably error codes are handled correctly. + +### Test Cases + +| ID | Code | Status | Meaning | +|----|------|--------|---------| +| 1 | 40000 | 400 | Bad request | +| 2 | 40100 | 401 | Unauthorized | +| 3 | 40101 | 401 | Invalid credentials | +| 4 | 40140 | 401 | Token error | +| 5 | 40142 | 401 | Token expired | +| 6 | 40160 | 401 | Invalid capability | +| 7 | 40300 | 403 | Forbidden | +| 8 | 40400 | 404 | Not found | +| 9 | 50000 | 500 | Internal server error | +| 10 | 50003 | 500 | Timeout | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + error = ErrorInfo( + code: test_case.code, + statusCode: test_case.status, + message: test_case.meaning + ) + + ASSERT error.code == test_case.code + ASSERT error.statusCode == test_case.status +``` + +--- + +## TI - Error string representation + +**Spec requirement:** ErrorInfo must provide a useful string representation including error code, status code, and message. + +Tests that errors have a useful string representation. + +### Test Steps +```pseudo +error = ErrorInfo( + code: 40100, + statusCode: 401, + message: "Unauthorized: token expired" +) + +string_repr = str(error) + +# String should include key information +ASSERT "40100" IN string_repr +ASSERT "401" IN string_repr +ASSERT "Unauthorized" IN string_repr OR "token" IN string_repr +``` + +--- + +## TI - Error equality + +**Spec requirement:** ErrorInfo must support equality comparison based on error attributes. + +Tests that errors can be compared for equality. + +### Test Steps +```pseudo +error1 = ErrorInfo(code: 40000, statusCode: 400, message: "Bad request") +error2 = ErrorInfo(code: 40000, statusCode: 400, message: "Bad request") +error3 = ErrorInfo(code: 40100, statusCode: 401, message: "Unauthorized") + +ASSERT error1 == error2 # Same content +ASSERT error1 != error3 # Different code +``` diff --git a/uts/rest/unit/types/message_types.md b/uts/rest/unit/types/message_types.md new file mode 100644 index 000000000..ee35a2a4f --- /dev/null +++ b/uts/rest/unit/types/message_types.md @@ -0,0 +1,286 @@ +# Message Types Tests + +Spec points: `TM1`, `TM2`, `TM3`, `TM4`, `TM5`, `TM2a`, `TM2b`, `TM2c`, `TM2d`, `TM2e`, `TM2f`, `TM2g`, `TM2h`, `TM2i` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required - these verify type structure and serialization. + +--- + +## TM2a-TM2i - Message attributes + +**Spec requirement:** Message type must provide all required attributes according to TM2a-TM2i specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TM2a | id | Unique message identifier | +| TM2b | name | Event name | +| TM2c | data | Message payload (string, object, or binary) | +| TM2d | clientId | Client ID of the publisher | +| TM2e | connectionId | Connection ID of the publisher | +| TM2f | timestamp | Message timestamp in milliseconds | +| TM2g | encoding | Encoding information for the data | +| TM2h | extras | Additional message metadata | +| TM2i | serial | Server-assigned serial number | + +Tests that `Message` has all required attributes. + +### Test Steps +```pseudo +# TM2a - id attribute +message = Message(id: "unique-id") +ASSERT message.id == "unique-id" + +# TM2b - name attribute +message = Message(name: "event-name") +ASSERT message.name == "event-name" + +# TM2c - data attribute +message = Message(data: "string-data") +ASSERT message.data == "string-data" + +message = Message(data: { "key": "value" }) +ASSERT message.data == { "key": "value" } + +message = Message(data: bytes([0x01, 0x02])) +ASSERT message.data == bytes([0x01, 0x02]) + +# TM2d - clientId attribute +message = Message(clientId: "message-client") +ASSERT message.clientId == "message-client" + +# TM2e - connectionId attribute +message = Message(connectionId: "conn-id") +ASSERT message.connectionId == "conn-id" + +# TM2f - timestamp attribute +message = Message(timestamp: 1234567890000) +ASSERT message.timestamp == 1234567890000 + +# TM2g - encoding attribute +message = Message(encoding: "json/base64") +ASSERT message.encoding == "json/base64" + +# TM2h - extras attribute +message = Message(extras: { + "push": { "notification": { "title": "Hello" } } +}) +ASSERT message.extras["push"]["notification"]["title"] == "Hello" + +# TM2i - serial attribute (server-assigned) +# Serial is typically read-only from server responses +``` + +--- + +## TM3 - Message from JSON (wire format) + +**Spec requirement:** Message type must support deserialization from JSON wire format, including handling encoded data payloads. Field names in the JSON wire format use camelCase (e.g., `clientId`, `connectionId`). SDKs MUST map these to their idiomatic naming conventions (e.g., `client_id` in snake_case languages). + +Tests that `Message` can be deserialized from JSON wire format. + +### Test Steps +```pseudo +json_data = { + "id": "msg-123", + "name": "test-event", + "data": "hello world", + "clientId": "sender-client", + "connectionId": "conn-456", + "timestamp": 1234567890000, + "encoding": null, + "extras": { "headers": { "x-custom": "value" } } +} + +message = Message.fromJson(json_data) + +ASSERT message.id == "msg-123" +ASSERT message.name == "test-event" +ASSERT message.data == "hello world" +ASSERT message.clientId == "sender-client" +ASSERT message.connectionId == "conn-456" +ASSERT message.timestamp == 1234567890000 +ASSERT message.extras["headers"]["x-custom"] == "value" +``` + +--- + +## TM3 - Message with encoded data from JSON + +**Spec requirement:** Message deserialization must decode data based on the encoding field and clear the encoding after decoding. + +Tests that `Message` correctly handles encoded data during deserialization. + +### Test Cases + +| ID | Encoding | Wire Data | Expected Data | +|----|----------|-----------|---------------| +| 1 | `null` | `"plain text"` | `"plain text"` | +| 2 | `"json"` | `"{\"key\":\"value\"}"` | `{ "key": "value" }` | +| 3 | `"base64"` | `"SGVsbG8="` | `bytes("Hello")` | +| 4 | `"json/base64"` | `"eyJrIjoidiJ9"` | `{ "k": "v" }` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + json_data = { + "id": "msg", + "name": "event", + "data": test_case.wire_data, + "encoding": test_case.encoding + } + + message = Message.fromJson(json_data) + + ASSERT message.data == test_case.expected_data + ASSERT message.encoding IS null # Encoding consumed +``` + +--- + +## TM4 - Message to JSON (wire format) + +**Spec requirement:** Message type must support serialization to JSON wire format, automatically encoding non-string data types. + +Tests that `Message` serializes correctly for transmission. + +### Test Steps +```pseudo +message = Message( + id: "custom-id", + name: "outgoing-event", + data: "outgoing-data", + clientId: "sending-client" +) + +json_data = message.toJson() + +ASSERT json_data["id"] == "custom-id" +ASSERT json_data["name"] == "outgoing-event" +ASSERT json_data["data"] == "outgoing-data" +ASSERT json_data["clientId"] == "sending-client" +``` + +--- + +## TM4 - Message with object data to JSON + +**Spec requirement:** Object data must be JSON-encoded with the encoding field set to "json" when serializing for transmission. + +Tests that object data is JSON-encoded for transmission. + +### Test Steps +```pseudo +message = Message( + name: "json-event", + data: { "nested": { "array": [1, 2, 3] } } +) + +json_data = message.toJson() + +# Object should be JSON-encoded with encoding field set +ASSERT json_data["encoding"] == "json" +ASSERT parse_json(json_data["data"]) == { "nested": { "array": [1, 2, 3] } } +``` + +--- + +## TM4 - Message with binary data to JSON + +**Spec requirement:** Binary data must be base64-encoded with the encoding field set to "base64" when serializing for JSON transmission. + +Tests that binary data is base64-encoded for JSON transmission. + +### Test Steps +```pseudo +message = Message( + name: "binary-event", + data: bytes([0x00, 0x01, 0xFF]) +) + +json_data = message.toJson() + +ASSERT json_data["encoding"] == "base64" +ASSERT base64_decode(json_data["data"]) == bytes([0x00, 0x01, 0xFF]) +``` + +--- + +## TM5 - Message equality + +**Spec requirement:** Message type must support equality comparison based on message content and attributes. + +Tests that messages can be compared for equality. + +### Test Steps +```pseudo +message1 = Message(id: "same-id", name: "event", data: "data") +message2 = Message(id: "same-id", name: "event", data: "data") +message3 = Message(id: "different-id", name: "event", data: "data") + +ASSERT message1 == message2 # Same content +ASSERT message1 != message3 # Different id +``` + +--- + +## TM - Message with extras + +**Spec requirement:** Message extras field must support arbitrary metadata including push notification configuration (TM2h). + +Tests that Message extras (push notifications, etc.) are handled correctly. + +### Test Steps +```pseudo +# Push notification extras +message = Message( + name: "push-event", + data: "payload", + extras: { + "push": { + "notification": { + "title": "New Message", + "body": "You have a new notification" + }, + "data": { + "customKey": "customValue" + } + } + } +) + +json_data = message.toJson() + +ASSERT json_data["extras"]["push"]["notification"]["title"] == "New Message" +ASSERT json_data["extras"]["push"]["data"]["customKey"] == "customValue" +``` + +--- + +## TM - Null/missing attributes + +**Spec requirement:** Message type must handle null or missing optional attributes correctly, omitting them from serialization. + +Tests that null or missing attributes are handled correctly. + +### Test Steps +```pseudo +# Minimal message +message = Message() + +# All optional attributes should be null/undefined +ASSERT message.id IS null OR message.id IS undefined +ASSERT message.name IS null OR message.name IS undefined +ASSERT message.data IS null OR message.data IS undefined +ASSERT message.clientId IS null OR message.clientId IS undefined +ASSERT message.timestamp IS null OR message.timestamp IS undefined + +# Serialization should omit null fields +json_data = message.toJson() +ASSERT "id" NOT IN json_data OR json_data["id"] IS null +ASSERT "name" NOT IN json_data OR json_data["name"] IS null +ASSERT "data" NOT IN json_data OR json_data["data"] IS null +``` diff --git a/uts/rest/unit/types/mutable_message_types.md b/uts/rest/unit/types/mutable_message_types.md new file mode 100644 index 000000000..e94e7fadd --- /dev/null +++ b/uts/rest/unit/types/mutable_message_types.md @@ -0,0 +1,298 @@ +# Mutable Message Type Tests + +Spec points: `TM2j`, `TM2r`, `TM2s`, `TM2s1`, `TM2s2`, `TM2s3`, `TM2s4`, `TM2s5`, `TM2u`, `TM5`, `TM8`, `TM8a`, `MOP2a`, `MOP2b`, `MOP2c`, `UDR1`, `UDR2`, `UDR2a`, `TAN1`, `TAN2`, `TAN2a`–`TAN2l` + +## Test Type +Unit test (no mocking needed — pure type construction and serialization) + +--- + +## TM5 — MessageAction enum values + +**Spec requirement:** TM5 — `Message` `Action` enum has the following values in order from zero: `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `META`, `MESSAGE_SUMMARY`, `MESSAGE_APPEND`. + +Tests that the `MessageAction` enum has the correct numeric values for wire serialization. + +### Assertions +```pseudo +ASSERT MessageAction.MESSAGE_CREATE.toInt() == 0 +ASSERT MessageAction.MESSAGE_UPDATE.toInt() == 1 +ASSERT MessageAction.MESSAGE_DELETE.toInt() == 2 +ASSERT MessageAction.META.toInt() == 3 +ASSERT MessageAction.MESSAGE_SUMMARY.toInt() == 4 +ASSERT MessageAction.MESSAGE_APPEND.toInt() == 5 + +# Round-trip from int +ASSERT MessageAction.fromInt(0) == MessageAction.MESSAGE_CREATE +ASSERT MessageAction.fromInt(5) == MessageAction.MESSAGE_APPEND +``` + +--- + +## TM2j, TM2r — Message has action and serial fields + +| Spec | Requirement | +|------|-------------| +| TM2j | `action` enum | +| TM2r | `serial` string — an opaque string that uniquely identifies the message | + +Tests that `Message` supports `action` and `serial` fields, and that `toJson()` serializes `action` as a numeric value. + +### Test Steps +```pseudo +msg = Message( + name: "test", + data: "hello", + serial: "serial-1", + action: MessageAction.MESSAGE_UPDATE +) +``` + +### Assertions +```pseudo +ASSERT msg.serial == "serial-1" +ASSERT msg.action == MessageAction.MESSAGE_UPDATE + +json_data = msg.toJson() +ASSERT json_data["serial"] == "serial-1" +ASSERT json_data["action"] == 1 # Numeric wire value for MESSAGE_UPDATE +ASSERT json_data["name"] == "test" +ASSERT json_data["data"] == "hello" +``` + +--- + +## TM2s — Message.version populated from wire + +| Spec | Requirement | +|------|-------------| +| TM2s | `version` is an object containing information about the latest version of a message | +| TM2s1 | `serial` — an opaque string that identifies the specific version | +| TM2s2 | `timestamp` — time in milliseconds since epoch | +| TM2s3 | `clientId` — string | +| TM2s4 | `description` — string | +| TM2s5 | `metadata` — Dict | + +Tests that `Message.fromJson()` correctly parses the `version` object with all fields. + +### Test Steps +```pseudo +msg = Message.fromJson({ + "serial": "msg-serial-1", + "name": "test", + "data": "hello", + "version": { + "serial": "version-serial-1", + "timestamp": 1700000001000, + "clientId": "editor-1", + "description": "fixed typo", + "metadata": { "reason": "typo", "tool": "editor" } + } +}) +``` + +### Assertions +```pseudo +ASSERT msg.version IS NOT null +ASSERT msg.version IS MessageVersion +ASSERT msg.version.serial == "version-serial-1" +ASSERT msg.version.timestamp == 1700000001000 +ASSERT msg.version.clientId == "editor-1" +ASSERT msg.version.description == "fixed typo" +ASSERT msg.version.metadata["reason"] == "typo" +ASSERT msg.version.metadata["tool"] == "editor" +``` + +--- + +## TM2s1, TM2s2 — Message.version defaults when not on wire + +| Spec | Requirement | +|------|-------------| +| TM2s | If a message does not contain a `version` object the SDK must initialize one and set a subset of fields | +| TM2s1 | If `version.serial` is not received, must be set to the `TM2r` `serial`, if set | +| TM2s2 | If `version.timestamp` is not received, must be set to the `TM2f` `timestamp`, if set | + +Tests that when `version` is absent from the wire, the SDK initializes it with defaults from `serial` and `timestamp`. + +### Test Steps +```pseudo +msg = Message.fromJson({ + "serial": "msg-serial-1", + "timestamp": 1700000000000, + "name": "test", + "data": "hello" +}) +``` + +### Assertions +```pseudo +# version must be initialized even though not on wire +ASSERT msg.version IS NOT null +ASSERT msg.version IS MessageVersion + +# TM2s1: version.serial defaults to message serial +ASSERT msg.version.serial == "msg-serial-1" + +# TM2s2: version.timestamp defaults to message timestamp +ASSERT msg.version.timestamp == 1700000000000 + +# Other fields should be null +ASSERT msg.version.clientId IS null +ASSERT msg.version.description IS null +ASSERT msg.version.metadata IS null +``` + +--- + +## TM2u, TM8a — Message.annotations defaults to empty + +| Spec | Requirement | +|------|-------------| +| TM2u | `annotations` is an object of type `MessageAnnotations`. If not set on the wire, the SDK must set it to an empty `MessageAnnotations` object | +| TM8a | `summary` `Dict` — a missing `summary` field indicates an empty summary | + +Tests that `annotations` is initialized to an empty `MessageAnnotations` when not present on the wire. + +### Test Steps +```pseudo +msg = Message.fromJson({ + "serial": "msg-serial-1", + "name": "test" +}) +``` + +### Assertions +```pseudo +ASSERT msg.annotations IS NOT null +ASSERT msg.annotations IS MessageAnnotations +ASSERT msg.annotations.summary IS NOT null +ASSERT msg.annotations.summary IS empty # No keys +``` + +--- + +## MOP2a–c — MessageOperation fields + +| Spec | Requirement | +|------|-------------| +| MOP2a | `clientId?: String` | +| MOP2b | `description?: String` | +| MOP2c | `metadata?: Dict` | + +Tests that `MessageOperation` can be constructed with all optional fields and that `toJson()` serializes correctly. + +### Test Steps +```pseudo +op = MessageOperation( + clientId: "user-1", + description: "edit description", + metadata: { "reason": "typo", "tool": "editor" } +) +``` + +### Assertions +```pseudo +ASSERT op.clientId == "user-1" +ASSERT op.description == "edit description" +ASSERT op.metadata["reason"] == "typo" +ASSERT op.metadata["tool"] == "editor" + +# Serialization +json_data = op.toJson() +ASSERT json_data["clientId"] == "user-1" +ASSERT json_data["description"] == "edit description" +ASSERT json_data["metadata"]["reason"] == "typo" + +# All-null construction +empty_op = MessageOperation() +ASSERT empty_op.clientId IS null +ASSERT empty_op.description IS null +ASSERT empty_op.metadata IS null + +empty_json = empty_op.toJson() +ASSERT "clientId" NOT IN empty_json +ASSERT "description" NOT IN empty_json +ASSERT "metadata" NOT IN empty_json +``` + +--- + +## UDR2a — UpdateDeleteResult fields + +| Spec | Requirement | +|------|-------------| +| UDR1 | Contains the result of an update or delete message operation | +| UDR2a | `versionSerial` `String?` — the new version serial string | + +Tests that `UpdateDeleteResult` can be constructed from a response map. + +### Assertions +```pseudo +# Non-null versionSerial +result1 = UpdateDeleteResult.fromJson({ "versionSerial": "version-serial-abc" }) +ASSERT result1 IS UpdateDeleteResult +ASSERT result1.versionSerial == "version-serial-abc" + +# Null versionSerial (message superseded) +result2 = UpdateDeleteResult.fromJson({ "versionSerial": null }) +ASSERT result2.versionSerial IS null + +# Missing versionSerial key treated as null +result3 = UpdateDeleteResult.fromJson({}) +ASSERT result3.versionSerial IS null +``` + +--- + +## TAN2 — Annotation type attributes and action encoding + +| Spec | Requirement | +|------|-------------| +| TAN1 | An `Annotation` represents an individual annotation event | +| TAN2a | `id` string | +| TAN2b | `action` enum: `ANNOTATION_CREATE` (0), `ANNOTATION_DELETE` (1) | +| TAN2b1 | In wire protocol action is numeric; SDK exposes as enum | +| TAN2c–TAN2l | Various string, number, and object fields | + +Tests that `Annotation.fromJson()` decodes all fields and that `AnnotationAction` enum has correct numeric values. + +### Test Steps +```pseudo +ann = Annotation.fromJson({ + "id": "ann-id-1", + "action": 0, + "clientId": "user-1", + "name": "like", + "count": 5, + "data": "thumbs-up", + "encoding": null, + "timestamp": 1700000000000, + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "type": "com.example.reaction", + "extras": { "custom": "metadata" } +}) +``` + +### Assertions +```pseudo +ASSERT ann IS Annotation +ASSERT ann.id == "ann-id-1" +ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE +ASSERT ann.clientId == "user-1" +ASSERT ann.name == "like" +ASSERT ann.count == 5 +ASSERT ann.data == "thumbs-up" +ASSERT ann.timestamp == 1700000000000 +ASSERT ann.serial == "ann-serial-1" +ASSERT ann.messageSerial == "msg-serial-1" +ASSERT ann.type == "com.example.reaction" +ASSERT ann.extras["custom"] == "metadata" + +# AnnotationAction numeric values +ASSERT AnnotationAction.ANNOTATION_CREATE.toInt() == 0 +ASSERT AnnotationAction.ANNOTATION_DELETE.toInt() == 1 +ASSERT AnnotationAction.fromInt(0) == AnnotationAction.ANNOTATION_CREATE +ASSERT AnnotationAction.fromInt(1) == AnnotationAction.ANNOTATION_DELETE +``` diff --git a/uts/rest/unit/types/options_types.md b/uts/rest/unit/types/options_types.md new file mode 100644 index 000000000..149b782f1 --- /dev/null +++ b/uts/rest/unit/types/options_types.md @@ -0,0 +1,291 @@ +# Options Types Tests + +Spec points: `TO1`, `TO2`, `TO3`, `AO1`, `AO2` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required - these verify type structure and defaults. + +--- + +## TO3 - ClientOptions attributes + +**Spec requirement:** ClientOptions type must provide all configuration attributes with correct defaults according to TO3 specification. + +Tests that `ClientOptions` has all REST-relevant attributes with correct defaults. + +### Test Cases - Required Attributes + +| ID | Attribute | Type | Default | +|----|-----------|------|---------| +| 1 | `key` | String | (none) | +| 2 | `token` | String | (none) | +| 3 | `tokenDetails` | TokenDetails | (none) | +| 4 | `authCallback` | Function | (none) | +| 5 | `authUrl` | String | (none) | +| 6 | `authMethod` | String | `"GET"` | +| 7 | `authHeaders` | Map | (empty) | +| 8 | `authParams` | Map | (empty) | +| 9 | `clientId` | String | (none) | +| 10 | `endpoint` | String | (none - uses production) | +| 11 | `restHost` | String | `"rest.ably.io"` | +| 12 | `fallbackHosts` | List | (default fallback hosts) | +| 13 | `tls` | Boolean | `true` | +| 14 | `httpRequestTimeout` | Integer | `10000` (10 seconds) | +| 15 | `httpMaxRetryCount` | Integer | `3` | +| 16 | `httpMaxRetryDuration` | Integer | `15000` (15 seconds) | +| 17 | `fallbackRetryTimeout` | Integer | `600000` (10 minutes) | +| 18 | `useBinaryProtocol` | Boolean | `true` | +| 19 | `idempotentRestPublishing` | Boolean | `true` | +| 20 | `addRequestIds` | Boolean | `false` | +| 21 | `queryTime` | Boolean | `false` | +| 22 | `maxMessageSize` | Integer | `65536` (64KB) | +| 23 | `defaultTokenParams` | TokenParams | (none) | + +### Test Steps - Defaults +```pseudo +options = ClientOptions() + +ASSERT options.authMethod == "GET" +ASSERT options.tls == true +ASSERT options.httpRequestTimeout == 10000 +ASSERT options.httpMaxRetryCount == 3 +ASSERT options.useBinaryProtocol == true +ASSERT options.idempotentRestPublishing == true +ASSERT options.addRequestIds == false +ASSERT options.queryTime == false +ASSERT options.maxMessageSize == 65536 +``` + +### Test Steps - Setting Values +```pseudo +options = ClientOptions( + key: "appId.keyId:keySecret", + clientId: "my-client", + endpoint: "sandbox", + tls: false, + httpRequestTimeout: 30000, + useBinaryProtocol: false, + idempotentRestPublishing: false, + addRequestIds: true +) + +ASSERT options.key == "appId.keyId:keySecret" +ASSERT options.clientId == "my-client" +ASSERT options.endpoint == "sandbox" +ASSERT options.tls == false +ASSERT options.httpRequestTimeout == 30000 +ASSERT options.useBinaryProtocol == false +ASSERT options.idempotentRestPublishing == false +ASSERT options.addRequestIds == true +``` + +--- + +## TO3 - ClientOptions with custom hosts + +**Spec requirement:** ClientOptions must support custom host configuration including restHost and fallbackHosts. + +Tests custom host configuration. + +### Test Steps +```pseudo +options = ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.ably.example.com", + fallbackHosts: ["fallback1.example.com", "fallback2.example.com"] +) + +ASSERT options.restHost == "custom.ably.example.com" +ASSERT options.fallbackHosts == ["fallback1.example.com", "fallback2.example.com"] +``` + +--- + +## TO3 - ClientOptions with auth URL + +**Spec requirement:** ClientOptions must support authUrl configuration with customizable HTTP method, headers, and parameters. + +Tests auth URL configuration. + +### Test Steps +```pseudo +options = ClientOptions( + authUrl: "https://auth.example.com/token", + authMethod: "POST", + authHeaders: { "X-API-Key": "secret" }, + authParams: { "scope": "full" } +) + +ASSERT options.authUrl == "https://auth.example.com/token" +ASSERT options.authMethod == "POST" +ASSERT options.authHeaders["X-API-Key"] == "secret" +ASSERT options.authParams["scope"] == "full" +``` + +--- + +## TO3 - ClientOptions with defaultTokenParams + +**Spec requirement:** ClientOptions must support defaultTokenParams for specifying default token request parameters. + +Tests default token parameters configuration. + +### Test Steps +```pseudo +options = ClientOptions( + key: "appId.keyId:keySecret", + defaultTokenParams: TokenParams( + ttl: 7200000, + clientId: "default-client", + capability: "{\"*\":[\"subscribe\"]}" + ) +) + +ASSERT options.defaultTokenParams.ttl == 7200000 +ASSERT options.defaultTokenParams.clientId == "default-client" +ASSERT options.defaultTokenParams.capability == "{\"*\":[\"subscribe\"]}" +``` + +--- + +## AO2 - AuthOptions attributes + +**Spec requirement:** AuthOptions type must provide all authentication-related attributes according to AO2 specification. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| AO2 | key | API key for authentication | +| AO2 | token | Token string for authentication | +| AO2 | tokenDetails | TokenDetails object for authentication | +| AO2 | authCallback | Callback function for token generation | +| AO2 | authUrl | URL for token requests | +| AO2 | authMethod | HTTP method for authUrl requests | +| AO2 | authHeaders | Headers for authUrl requests | +| AO2 | authParams | Parameters for authUrl requests | +| AO2 | queryTime | Whether to query server time | + +Tests that `AuthOptions` has all required attributes. + +### Test Cases + +| ID | Attribute | Type | +|----|-----------|------| +| 1 | `key` | String | +| 2 | `token` | String | +| 3 | `tokenDetails` | TokenDetails | +| 4 | `authCallback` | Function | +| 5 | `authUrl` | String | +| 6 | `authMethod` | String | +| 7 | `authHeaders` | Map | +| 8 | `authParams` | Map | +| 9 | `queryTime` | Boolean | + +### Test Steps +```pseudo +auth_options = AuthOptions( + authUrl: "https://auth.example.com/token", + authMethod: "POST", + authHeaders: { "Authorization": "Bearer api-key" }, + authParams: { "user": "test" }, + queryTime: true +) + +ASSERT auth_options.authUrl == "https://auth.example.com/token" +ASSERT auth_options.authMethod == "POST" +ASSERT auth_options.authHeaders["Authorization"] == "Bearer api-key" +ASSERT auth_options.authParams["user"] == "test" +ASSERT auth_options.queryTime == true +``` + +--- + +## AO - AuthOptions with authCallback + +**Spec requirement:** AuthOptions must support authCallback function for custom token generation logic. + +Tests that `AuthOptions` can hold an authCallback function. + +### Test Steps +```pseudo +callback_called = false + +test_callback = (params) => { + callback_called = true + RETURN TokenDetails(token: "callback-token", expires: now() + 3600000) +} + +auth_options = AuthOptions(authCallback: test_callback) + +# Verify callback is stored and callable +result = auth_options.authCallback(TokenParams()) +ASSERT callback_called == true +ASSERT result.token == "callback-token" +``` + +--- + +## TO - Endpoint affects host selection + +**Spec requirement:** The endpoint option must affect host selection for REST and Realtime connections. + +Tests that endpoint option affects default hosts. + +### Test Cases + +| ID | Endpoint | Expected Rest Host | +|----|----------|--------------------| +| 1 | (none/production) | `rest.ably.io` | +| 2 | `"sandbox"` | `sandbox-rest.ably.io` | +| 3 | `"custom-env"` | `custom-env-rest.ably.io` | + +### Note +The actual host resolution may be tested at the HTTP client level. This test verifies the option is stored correctly. + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + IF test_case.endpoint IS none: + options = ClientOptions(key: "appId.keyId:keySecret") + ELSE: + options = ClientOptions( + key: "appId.keyId:keySecret", + endpoint: test_case.endpoint + ) + + ASSERT options.endpoint == test_case.endpoint +``` + +--- + +## TO - Conflicting options validation + +**Spec requirement:** ClientOptions must validate and detect conflicting configuration options. + +Tests that conflicting options are detected. + +### Test Cases + +| ID | Options | Expected | +|----|---------|----------| +| 1 | `key` + `authCallback` | Valid (authCallback takes precedence) | +| 2 | `restHost` + `endpoint` | Invalid (conflict) | +| 3 | (no auth options) | Invalid | + +### Test Steps (Case 2 - Conflicting hosts) +```pseudo +ClientOptions( + key: "appId.keyId:keySecret", + restHost: "custom.host.com", + endpoint: "sandbox" +) FAILS WITH error +ASSERT error.message CONTAINS "restHost" OR error.message CONTAINS "endpoint" +``` + +### Test Steps (Case 3 - No auth) +```pseudo +Rest(options: ClientOptions()) FAILS WITH error +ASSERT error.message CONTAINS "auth" OR error.message CONTAINS "key" OR error.message CONTAINS "token" +``` diff --git a/uts/rest/unit/types/paginated_result.md b/uts/rest/unit/types/paginated_result.md new file mode 100644 index 000000000..463596668 --- /dev/null +++ b/uts/rest/unit/types/paginated_result.md @@ -0,0 +1,705 @@ +# PaginatedResult Types Tests + +Spec points: `TG1`, `TG2`, `TG3`, `TG4` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +These tests use the mock HTTP infrastructure described in `/Users/paddy/data/worknew/dev/dart-experiments/uts/test/rest/unit/rest_client.md`. + +The mock supports: +- Intercepting HTTP requests and capturing details (URL, headers, method, body) +- Queueing responses with configurable status, headers, and body +- Handler-based configuration with `onConnectionAttempt` and `onRequest` +- Recording requests in `captured_requests` arrays +- Request counting with `request_count` variables + +--- + +## TG1 - PaginatedResult items attribute + +**Spec requirement:** `PaginatedResult` must contain an `items` array with the result data. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "id": "item1", "name": "e1", "data": "d1" }, + { "id": "item2", "name": "e2", "data": "d2" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.items IS List +ASSERT result.items.length == 2 +ASSERT result.items[0].id == "item1" +ASSERT result.items[1].id == "item2" +``` + +--- + +## TG2 - hasNext() and isLast() methods + +**Spec requirement:** `PaginatedResult` must provide `hasNext()` and `isLast()` methods to indicate pagination state. + +### Test Case 1: Has more pages + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.hasNext() == true +ASSERT result.isLast() == false +``` + +--- + +### Test Case 2: No more pages + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item1" }], + headers: {} # No Link header for next + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.hasNext() == false +ASSERT result.isLast() == true +``` + +--- + +## TG3 - next() method + +**Spec requirement:** The `next()` method must fetch the next page using the URL from the Link header. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + # First page + req.respond_with(200, + body: [{ "id": "page1-item1" }, { "id": "page1-item2" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + # Second page + req.respond_with(200, + body: [{ "id": "page2-item1" }], + headers: {} # Last page + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# First page +ASSERT page1.items.length == 2 +ASSERT page1.items[0].id == "page1-item1" +ASSERT page1.hasNext() == true + +# Second page +ASSERT page2.items.length == 1 +ASSERT page2.items[0].id == "page2-item1" +ASSERT page2.hasNext() == false + +# Verify next request used cursor from Link header +next_request = captured_requests[1] +ASSERT "cursor" IN next_request.url.query_params +ASSERT next_request.url.query_params["cursor"] == "abc123" +``` + +--- + +## TG4 - first() method + +**Spec requirement:** The `first()` method must return to the first page using the URL from the Link header's `rel="first"` link. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + # Initial request + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\", ; rel=\"first\"" + } + ) + ELSE IF request_count == 2: + # Next page + req.respond_with(200, + body: [{ "id": "item2" }], + headers: { + "Link": "; rel=\"first\"" + } + ) + ELSE: + # First page again + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +first_page = AWAIT page2.first() +``` + +### Assertions +```pseudo +ASSERT first_page.items[0].id == "item1" +``` + +--- + +## TG - Empty result + +**Spec requirement:** Empty results must be handled correctly with an empty `items` array. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [], + headers: {} + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.items IS List +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +ASSERT result.isLast() == true +``` + +--- + +## TG - Link header parsing + +**Spec requirement:** Various Link header formats must be correctly parsed to determine pagination state and next page URLs. + +### Test Cases + +| ID | Link Header | Expected hasNext | Expected cursor | +|----|-------------|------------------|-----------------| +| 1 | `; rel="next"` | true | `"abc"` | +| 2 | `; rel="next", ; rel="first"` | true | `"abc"` | +| 3 | `; rel="first"` | false | (none) | +| 4 | (empty) | false | (none) | + +### Setup and Execution +```pseudo +FOR EACH test_case IN test_cases: + captured_requests = [] + + mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + + IF test_case.link_header IS NOT empty: + req.respond_with(200, + body: [{ "id": "item" }], + headers: { "Link": test_case.link_header } + ) + ELSE: + req.respond_with(200, + body: [{ "id": "item" }], + headers: {} + ) + } + ) + install_mock(mock_http) + + client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + result = AWAIT client.channels.get("test").history() + + ASSERT result.hasNext() == test_case.expected_hasNext +``` + +--- + +## TG - PaginatedResult type parameter + +**Spec requirement:** `PaginatedResult` must correctly type its items to the expected type `T`. + +### Note +This is primarily a compile-time/type-system verification for strongly-typed languages. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, [ + { "id": "msg1", "name": "event", "data": "test" } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +# History returns PaginatedResult +history_result = AWAIT channel.history() +ASSERT history_result.items[0] IS Message + +# If the language supports generics, verify: +# PaginatedResult cannot be assigned to PaginatedResult +``` + +--- + +## TG - next() on last page + +**Spec requirement:** Calling `next()` on the last page must handle gracefully (return null, empty result, or throw). + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item" }], + headers: {} # No next link + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +ASSERT result.isLast() == true + +next_result = AWAIT result.next() +``` + +### Assertions +```pseudo +# Implementation may either: +# 1. Return null +# 2. Return empty PaginatedResult +# 3. Throw an exception + +ASSERT next_result IS null OR next_result.items.length == 0 +``` + +--- + +## TG - Pagination preserves authentication + +**Spec requirement:** Pagination requests must include the same authentication credentials as the initial request. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "id": "item2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# Both requests should have Authorization header +ASSERT "Authorization" IN captured_requests[0].headers +ASSERT "Authorization" IN captured_requests[1].headers +ASSERT captured_requests[0].headers["Authorization"] == captured_requests[1].headers["Authorization"] +``` + +--- + +## TG - Pagination with relative URLs + +**Spec requirement:** Link headers with relative URLs must be resolved relative to the base REST host. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "id": "item2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + restHost: "rest.ably.io" +)) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# Second request should use full URL +ASSERT captured_requests[1].url.host == "rest.ably.io" +ASSERT captured_requests[1].url.path == "/channels/test/messages" +ASSERT "page" IN captured_requests[1].url.query_params +``` + +--- + +## TG - Multiple Link relations + +**Spec requirement:** Link headers may contain multiple relations (next, first, last) which must all be parsed correctly. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\", ; rel=\"first\", ; rel=\"last\"" + } + ) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +result = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT result.hasNext() == true +# Implementation should be able to navigate to next, first, or last pages +``` + +--- + +## TG - Pagination with presence results + +**Spec requirement:** Pagination must work identically for presence results as it does for message results. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "action": 1, "clientId": "client1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "action": 1, "clientId": "client2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.presence.get() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +ASSERT page1 IS PaginatedResult +ASSERT page1.items[0].clientId == "client1" +ASSERT page2.items[0].clientId == "client2" +``` + +--- + +## TG - Pagination includes request headers + +**Spec requirement:** Pagination requests must include all standard Ably headers (X-Ably-Version, Ably-Agent, etc.). + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(200, body: [{ "id": "item2" }]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() +page2 = AWAIT page1.next() +``` + +### Assertions +```pseudo +# Check headers on pagination request +next_request = captured_requests[1] +ASSERT "X-Ably-Version" IN next_request.headers +ASSERT "Ably-Agent" IN next_request.headers +ASSERT next_request.headers["Ably-Agent"] contains "ably-" +``` + +--- + +## TG - Error handling on next() + +**Spec requirement:** Errors during pagination (e.g., 404, 500) must be raised as `AblyException`. + +### Setup +```pseudo +captured_requests = [] +request_count = 0 + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.push(req) + request_count++ + + IF request_count == 1: + req.respond_with(200, + body: [{ "id": "item1" }], + headers: { + "Link": "; rel=\"next\"" + } + ) + ELSE: + req.respond_with(404, { + "error": { + "code": 40400, + "statusCode": 404, + "message": "Not found" + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +channel = client.channels.get("test") +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history() + +AWAIT page1.next() FAILS WITH error +ASSERT error.statusCode == 404 +ASSERT error.code == 40400 +``` diff --git a/uts/rest/unit/types/presence_message_types.md b/uts/rest/unit/types/presence_message_types.md new file mode 100644 index 000000000..60ba41519 --- /dev/null +++ b/uts/rest/unit/types/presence_message_types.md @@ -0,0 +1,341 @@ +# PresenceMessage Types Tests + +Spec points: `TP1`, `TP2`, `TP3`, `TP3a`, `TP3b`, `TP3c`, `TP3d`, `TP3e`, `TP3f`, `TP3g`, `TP3h`, `TP3i`, `TP4`, `TP5` + +## Test Type +Unit test — pure type/model validation, no mocks required. + +--- + +## TP2 - PresenceAction enum values + +**Spec requirement:** PresenceMessage Action enum has the following values in order +from zero: ABSENT, PRESENT, ENTER, LEAVE, UPDATE. + +### Test Steps +```pseudo +ASSERT PresenceAction.absent.index == 0 +ASSERT PresenceAction.present.index == 1 +ASSERT PresenceAction.enter.index == 2 +ASSERT PresenceAction.leave.index == 3 +ASSERT PresenceAction.update.index == 4 +``` + +--- + +## TP3a-TP3i - PresenceMessage attributes + +**Spec requirement:** PresenceMessage type must provide all required attributes. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TP3a | id | Unique presence message identifier | +| TP3b | action | PresenceAction enum | +| TP3c | clientId | Client ID of the member | +| TP3d | connectionId | Connection ID of the member | +| TP3e | data | Payload (string, object, or binary) | +| TP3f | encoding | Encoding information for data | +| TP3g | timestamp | Timestamp in milliseconds since epoch | +| TP3h | memberKey | String combining connectionId and clientId | +| TP3i | extras | JSON-encodable key-value pairs | + +### Test Steps +```pseudo +# TP3a - id attribute +msg = PresenceMessage(id: "presence-123") +ASSERT msg.id == "presence-123" + +# TP3b - action attribute +msg = PresenceMessage(action: ENTER) +ASSERT msg.action == ENTER + +# TP3c - clientId attribute +msg = PresenceMessage(clientId: "user-1") +ASSERT msg.clientId == "user-1" + +# TP3d - connectionId attribute +msg = PresenceMessage(connectionId: "conn-1") +ASSERT msg.connectionId == "conn-1" + +# TP3e - data attribute (string) +msg = PresenceMessage(data: "hello") +ASSERT msg.data == "hello" + +# TP3e - data attribute (object) +msg = PresenceMessage(data: { "status": "online" }) +ASSERT msg.data == { "status": "online" } + +# TP3f - encoding attribute +msg = PresenceMessage(encoding: "json") +ASSERT msg.encoding == "json" + +# TP3g - timestamp attribute +msg = PresenceMessage(timestamp: 1234567890000) +ASSERT msg.timestamp == 1234567890000 + +# TP3i - extras attribute +msg = PresenceMessage(extras: { "headers": { "x-custom": "value" } }) +ASSERT msg.extras["headers"]["x-custom"] == "value" +``` + +--- + +## TP3h - memberKey combines connectionId and clientId + +**Spec requirement:** memberKey is a string function that combines the connectionId +and clientId to ensure multiple connected clients with the same clientId are uniquely +identifiable. + +### Test Steps +```pseudo +msg = PresenceMessage(connectionId: "conn-1", clientId: "user-1") +ASSERT msg.memberKey == "conn-1:user-1" + +msg2 = PresenceMessage(connectionId: "conn-2", clientId: "user-1") +ASSERT msg2.memberKey == "conn-2:user-1" + +# Same clientId, different connectionId — different memberKey +ASSERT msg.memberKey != msg2.memberKey +``` + +--- + +## TP3d - connectionId defaults from ProtocolMessage + +**Spec requirement:** If connectionId is not present in a received presence message, +it should be set to the connectionId of the encapsulating ProtocolMessage. + +### Test Steps +```pseudo +protocol_msg = ProtocolMessage( + action: PRESENCE, + connectionId: "proto-conn-1", + presence: [ + { "action": "enter", "clientId": "user-1" } + ] +) + +# After processing, the PresenceMessage should inherit connectionId +presence_msg = protocol_msg.presence[0] +ASSERT presence_msg.connectionId == "proto-conn-1" +``` + +--- + +## TP3a - id defaults from ProtocolMessage + +**Spec requirement:** For Realtime messages without an id, the id should be set to +protocolMsgId:index where index is the 0-based position in the presence array. + +### Test Steps +```pseudo +protocol_msg = ProtocolMessage( + action: PRESENCE, + id: "proto-msg-42", + presence: [ + { "action": "enter", "clientId": "alice" }, + { "action": "enter", "clientId": "bob" } + ] +) + +# After processing, presence messages should have derived ids +ASSERT protocol_msg.presence[0].id == "proto-msg-42:0" +ASSERT protocol_msg.presence[1].id == "proto-msg-42:1" +``` + +--- + +## TP3g - timestamp defaults from ProtocolMessage + +**Spec requirement:** If timestamp is not present in a received presence message, +it should be set to the timestamp of the encapsulating ProtocolMessage. + +### Test Steps +```pseudo +protocol_msg = ProtocolMessage( + action: PRESENCE, + timestamp: 9999999, + presence: [ + { "action": "enter", "clientId": "user-1" } + ] +) + +presence_msg = protocol_msg.presence[0] +ASSERT presence_msg.timestamp == 9999999 +``` + +--- + +## TP3 - PresenceMessage from JSON (wire format) + +**Spec requirement:** PresenceMessage must support deserialization from JSON wire format. + +### Test Steps +```pseudo +json_data = { + "id": "pm-123", + "action": "enter", + "clientId": "user-1", + "connectionId": "conn-1", + "data": "hello", + "encoding": null, + "timestamp": 1234567890000, + "extras": { "headers": { "x-key": "x-value" } } +} + +msg = PresenceMessage.fromJson(json_data) + +ASSERT msg.id == "pm-123" +ASSERT msg.action == ENTER +ASSERT msg.clientId == "user-1" +ASSERT msg.connectionId == "conn-1" +ASSERT msg.data == "hello" +ASSERT msg.timestamp == 1234567890000 +ASSERT msg.extras["headers"]["x-key"] == "x-value" +``` + +--- + +## TP3 - PresenceMessage with encoded data from JSON + +**Spec requirement:** Deserialization must decode data based on the encoding field. + +### Test Cases + +| ID | Encoding | Wire Data | Expected Data | +|----|----------|-----------|---------------| +| 1 | `null` | `"plain text"` | `"plain text"` | +| 2 | `"json"` | `"{\"status\":\"online\"}"` | `{ "status": "online" }` | +| 3 | `"base64"` | `"SGVsbG8="` | `bytes("Hello")` | + +### Test Steps +```pseudo +FOR EACH test_case IN test_cases: + json_data = { + "action": "enter", + "clientId": "user-1", + "data": test_case.wire_data, + "encoding": test_case.encoding + } + + msg = PresenceMessage.fromJson(json_data) + + ASSERT msg.data == test_case.expected_data + ASSERT msg.encoding IS null # Encoding consumed +``` + +--- + +## TP3 - PresenceMessage to JSON (wire format) + +**Spec requirement:** PresenceMessage must support serialization to JSON wire format. + +### Test Steps +```pseudo +msg = PresenceMessage( + action: ENTER, + clientId: "user-1", + data: "hello", + extras: { "headers": { "x-key": "x-value" } } +) + +json_data = msg.toJson() + +ASSERT json_data["action"] == "enter" +ASSERT json_data["clientId"] == "user-1" +ASSERT json_data["data"] == "hello" +ASSERT json_data["extras"]["headers"]["x-key"] == "x-value" +``` + +--- + +## TP3 - Null/missing attributes omitted from serialization + +**Spec requirement:** Null or missing optional attributes should be omitted from +serialized output. + +### Test Steps +```pseudo +msg = PresenceMessage(action: ENTER, clientId: "user-1") + +json_data = msg.toJson() + +ASSERT json_data["action"] == "enter" +ASSERT json_data["clientId"] == "user-1" +ASSERT "data" NOT IN json_data OR json_data["data"] IS null +ASSERT "encoding" NOT IN json_data OR json_data["encoding"] IS null +ASSERT "extras" NOT IN json_data OR json_data["extras"] IS null +ASSERT "id" NOT IN json_data OR json_data["id"] IS null +``` + +--- + +## TP4 - fromEncoded and fromEncodedArray + +**Spec requirement:** fromEncoded and fromEncodedArray are alternative constructors +that take an already-deserialized PresenceMessage-like object (or array) and return +decoded and decrypted PresenceMessage(s). Behavior is the same as TM3. + +### Test Steps +```pseudo +# fromEncoded — single message +raw = { + "action": "enter", + "clientId": "user-1", + "data": "{\"status\":\"online\"}", + "encoding": "json" +} + +msg = PresenceMessage.fromEncoded(raw) + +ASSERT msg.action == ENTER +ASSERT msg.clientId == "user-1" +ASSERT msg.data == { "status": "online" } +ASSERT msg.encoding IS null + +# fromEncodedArray — array of messages +raw_array = [ + { "action": "enter", "clientId": "alice", "data": "hello" }, + { "action": "enter", "clientId": "bob", "data": "world" } +] + +messages = PresenceMessage.fromEncodedArray(raw_array) + +ASSERT messages.length == 2 +ASSERT messages[0].clientId == "alice" +ASSERT messages[0].data == "hello" +ASSERT messages[1].clientId == "bob" +ASSERT messages[1].data == "world" +``` + +--- + +## TP5 - PresenceMessage size calculation + +**Spec requirement:** The size of the PresenceMessage is calculated in the same way +as for Message (see TM6). This is used for TO3l8 (maxMessageSize) enforcement. + +### Test Steps +```pseudo +# Size includes clientId + data + extras (same formula as TM6) +msg = PresenceMessage( + action: ENTER, + clientId: "user-1", + data: "hello" +) + +size = msg.size + +# Size should account for clientId (6 bytes) + data (5 bytes) = 11 +ASSERT size == 11 + +# Size with object data (JSON-encoded size) +msg2 = PresenceMessage( + action: ENTER, + clientId: "u", + data: { "key": "value" } +) + +# clientId (1) + JSON-encoded data length +ASSERT msg2.size > 1 +``` diff --git a/uts/rest/unit/types/token_types.md b/uts/rest/unit/types/token_types.md new file mode 100644 index 000000000..b4bc25cae --- /dev/null +++ b/uts/rest/unit/types/token_types.md @@ -0,0 +1,344 @@ +# Token Types Tests + +Spec points: `TD1`, `TD2`, `TD3`, `TD4`, `TD5`, `TK1`, `TK2`, `TK3`, `TK4`, `TK5`, `TK6`, `TE1`, `TE2`, `TE3`, `TE4`, `TE5`, `TE6` + +## Test Type +Unit test - pure type/model validation + +## Mock Configuration +No mocks required for most tests - these verify type structure and serialization. + +--- + +## TD1-TD5 - TokenDetails structure + +**Spec requirement:** TokenDetails type must provide all required attributes according to TD1-TD5 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TD1 | token | The token string | +| TD2 | expires | Expiry time in milliseconds since epoch | +| TD3 | issued | Issue time in milliseconds since epoch | +| TD4 | capability | Capability JSON string | +| TD5 | clientId | Client ID associated with the token | + +Tests that `TokenDetails` has all required attributes. + +### Test Steps +```pseudo +# TD1 - token attribute +token_details = TokenDetails( + token: "test-token", + expires: 1234567890000 +) +ASSERT token_details.token == "test-token" + +# TD2 - expires attribute (milliseconds since epoch) +ASSERT token_details.expires == 1234567890000 + +# TD3 - issued attribute +token_with_issued = TokenDetails( + token: "test-token", + expires: 1234567890000, + issued: 1234567800000 +) +ASSERT token_with_issued.issued == 1234567800000 + +# TD4 - capability attribute (JSON string) +token_with_capability = TokenDetails( + token: "test-token", + expires: 1234567890000, + capability: "{\"*\":[\"*\"]}" +) +ASSERT token_with_capability.capability == "{\"*\":[\"*\"]}" + +# TD5 - clientId attribute +token_with_client = TokenDetails( + token: "test-token", + expires: 1234567890000, + clientId: "my-client" +) +ASSERT token_with_client.clientId == "my-client" +``` + +--- + +## TD - TokenDetails from JSON + +**Spec requirement:** TokenDetails must support deserialization from JSON responses containing token information. + +Tests that `TokenDetails` can be deserialized from JSON response. + +### Test Steps +```pseudo +json_data = { + "token": "deserialized-token", + "expires": 1234567890000, + "issued": 1234567800000, + "capability": "{\"channel-1\":[\"publish\"]}", + "clientId": "json-client", + "keyName": "appId.keyId" +} + +token_details = TokenDetails.fromJson(json_data) + +ASSERT token_details.token == "deserialized-token" +ASSERT token_details.expires == 1234567890000 +ASSERT token_details.issued == 1234567800000 +ASSERT token_details.capability == "{\"channel-1\":[\"publish\"]}" +ASSERT token_details.clientId == "json-client" +``` + +--- + +## TK1-TK6 - TokenParams structure + +**Spec requirement:** TokenParams type must provide all required attributes according to TK1-TK6 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TK1 | ttl | Time to live in milliseconds | +| TK2 | capability | Capability JSON string | +| TK3 | clientId | Client ID for the token | +| TK4 | timestamp | Timestamp in milliseconds since epoch | +| TK5 | nonce | Unique nonce value | +| TK6 | (all) | All attributes combined | + +Tests that `TokenParams` has all required attributes. + +### Test Steps +```pseudo +# TK1 - ttl attribute (milliseconds, nullable) +params = TokenParams(ttl: 3600000) +ASSERT params.ttl == 3600000 + +# TK1 - ttl defaults to null when not specified (RSA5 depends on this) +params = TokenParams() +ASSERT params.ttl IS null + +# TK2 - capability attribute (nullable) +params = TokenParams(capability: "{\"*\":[\"subscribe\"]}") +ASSERT params.capability == "{\"*\":[\"subscribe\"]}" + +# TK2 - capability defaults to null when not specified (RSA6 depends on this) +params = TokenParams() +ASSERT params.capability IS null + +# TK3 - clientId attribute +params = TokenParams(clientId: "param-client") +ASSERT params.clientId == "param-client" + +# TK4 - timestamp attribute (milliseconds since epoch) +params = TokenParams(timestamp: 1234567890000) +ASSERT params.timestamp == 1234567890000 + +# TK5 - nonce attribute +params = TokenParams(nonce: "unique-nonce-value") +ASSERT params.nonce == "unique-nonce-value" + +# TK6 - All attributes together +params = TokenParams( + ttl: 7200000, + capability: "{\"*\":[\"*\"]}", + clientId: "full-client", + timestamp: 1234567890000, + nonce: "full-nonce" +) +ASSERT params.ttl == 7200000 +ASSERT params.capability == "{\"*\":[\"*\"]}" +ASSERT params.clientId == "full-client" +ASSERT params.timestamp == 1234567890000 +ASSERT params.nonce == "full-nonce" +``` + +--- + +## TK - TokenParams to query string + +**Spec requirement:** TokenParams must support conversion to query parameters for token request URLs. + +Tests that `TokenParams` are correctly converted to query parameters. + +### Test Steps +```pseudo +params = TokenParams( + ttl: 3600000, + clientId: "query-client", + capability: "{\"ch\":[\"pub\"]}" +) + +query_map = params.toQueryParams() + +ASSERT query_map["ttl"] == "3600000" +ASSERT query_map["clientId"] == "query-client" +ASSERT query_map["capability"] == "{\"ch\":[\"pub\"]}" +``` + +--- + +## TE1-TE6 - TokenRequest structure + +**Spec requirement:** TokenRequest type must provide all required attributes according to TE1-TE6 specifications. + +| Spec | Attribute | Description | +|------|-----------|-------------| +| TE1 | keyName | API key name (appId.keyId) | +| TE2 | ttl | Time to live in milliseconds | +| TE3 | capability | Capability JSON string | +| TE4 | clientId | Client ID for the token | +| TE5 | timestamp | Timestamp in milliseconds since epoch | +| TE6 | nonce | Unique nonce value | + +Tests that `TokenRequest` has all required attributes. + +### Test Steps +```pseudo +# TE1 - keyName attribute +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-1" +) +ASSERT request.keyName == "appId.keyId" + +# TE2 - ttl attribute (nullable) +request = TokenRequest( + keyName: "appId.keyId", + ttl: 3600000, + timestamp: 1234567890000, + nonce: "nonce-2" +) +ASSERT request.ttl == 3600000 + +# TE2 - ttl defaults to null when not specified (RSA5 depends on this) +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-2b" +) +ASSERT request.ttl IS null + +# TE3 - capability attribute (nullable) +request = TokenRequest( + keyName: "appId.keyId", + capability: "{\"*\":[\"*\"]}", + timestamp: 1234567890000, + nonce: "nonce-3" +) +ASSERT request.capability == "{\"*\":[\"*\"]}" + +# TE3 - capability defaults to null when not specified (RSA6 depends on this) +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-3b" +) +ASSERT request.capability IS null + +# TE4 - clientId attribute +request = TokenRequest( + keyName: "appId.keyId", + clientId: "request-client", + timestamp: 1234567890000, + nonce: "nonce-4" +) +ASSERT request.clientId == "request-client" + +# TE5 - timestamp attribute +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-5" +) +ASSERT request.timestamp == 1234567890000 + +# TE6 - nonce attribute +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "unique-nonce" +) +ASSERT request.nonce == "unique-nonce" +``` + +--- + +## TE - TokenRequest with mac (signature) + +**Spec requirement:** TokenRequest must include a mac (signature) field for authentication. + +Tests that `TokenRequest` includes the mac signature. + +### Test Steps +```pseudo +request = TokenRequest( + keyName: "appId.keyId", + timestamp: 1234567890000, + nonce: "nonce-value", + mac: "signature-base64" +) + +ASSERT request.mac == "signature-base64" +``` + +--- + +## TE - TokenRequest to JSON + +**Spec requirement:** TokenRequest must support serialization to JSON for transmission to the token endpoint. + +Tests that `TokenRequest` serializes correctly for transmission. + +### Test Steps +```pseudo +request = TokenRequest( + keyName: "appId.keyId", + ttl: 3600000, + capability: "{\"*\":[\"*\"]}", + clientId: "json-client", + timestamp: 1234567890000, + nonce: "json-nonce", + mac: "json-mac" +) + +json_data = request.toJson() + +ASSERT json_data["keyName"] == "appId.keyId" +ASSERT json_data["ttl"] == 3600000 +ASSERT json_data["capability"] == "{\"*\":[\"*\"]}" +ASSERT json_data["clientId"] == "json-client" +ASSERT json_data["timestamp"] == 1234567890000 +ASSERT json_data["nonce"] == "json-nonce" +ASSERT json_data["mac"] == "json-mac" +``` + +--- + +## TE - TokenRequest from JSON + +**Spec requirement:** TokenRequest must support deserialization from JSON. + +Tests that `TokenRequest` can be deserialized from JSON. + +### Test Steps +```pseudo +json_data = { + "keyName": "appId.keyId", + "ttl": 7200000, + "capability": "{\"ch\":[\"sub\"]}", + "clientId": "from-json-client", + "timestamp": 1234567899999, + "nonce": "from-json-nonce", + "mac": "from-json-mac" +} + +request = TokenRequest.fromJson(json_data) + +ASSERT request.keyName == "appId.keyId" +ASSERT request.ttl == 7200000 +ASSERT request.capability == "{\"ch\":[\"sub\"]}" +ASSERT request.clientId == "from-json-client" +ASSERT request.timestamp == 1234567899999 +ASSERT request.nonce == "from-json-nonce" +ASSERT request.mac == "from-json-mac" +```