diff --git a/uts/README.md b/uts/README.md index b3799a954..50440d3f7 100644 --- a/uts/README.md +++ b/uts/README.md @@ -1,309 +1,104 @@ -# Test Specifications +# Universal Test Specifications (UTS) -Portable test specifications for Ably REST SDK implementation. +Portable test specifications for Ably client library implementations. Each spec defines what to test in language-neutral pseudocode, which is then translated into runnable tests for each SDK. ## 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 +uts/ +├── rest/ +│ ├── unit/ # REST unit tests (mocked HTTP) +│ │ ├── helpers/ +│ │ │ └── mock_http.md # Mock HTTP infrastructure spec +│ │ ├── auth/ # RSA — authentication +│ │ ├── channel/ # RSL — channel operations +│ │ ├── encoding/ # RSL4/RSL6 — message encoding +│ │ ├── presence/ # RSP — REST presence +│ │ ├── push/ # RSH — push admin +│ │ └── types/ # T* — type definitions +│ └── integration/ # REST integration tests (Ably sandbox) +├── realtime/ +│ ├── unit/ # Realtime unit tests (mocked WebSocket) +│ │ ├── helpers/ +│ │ │ ├── mock_websocket.md # Mock WebSocket infrastructure spec +│ │ │ └── mock_vcdiff.md # Mock VCDiff decoder spec +│ │ ├── auth/ # RSA/RTC8 — realtime auth +│ │ ├── channels/ # RTL/RTS — channels and messages +│ │ ├── client/ # RTC — realtime client +│ │ ├── connection/ # RTN — connection management +│ │ └── presence/ # RTP — realtime presence +│ └── integration/ # Realtime integration tests +│ ├── helpers/ +│ │ └── proxy.md # Proxy infrastructure spec +│ ├── proxy/ # Proxy-based fault injection tests +│ └── *.md # Direct sandbox tests +├── docs/ # Guides and reference +│ ├── writing-test-specs.md # How to write UTS specs +│ ├── writing-derived-tests.md # How to translate specs into SDK tests +│ ├── integration-testing.md # Integration testing policy +│ └── completion-status.md # Spec coverage matrix +└── README.md # This file ``` -## Test Types +## Spec File Counts -### Unit Tests +| Category | Count | Description | +|----------|-------|-------------| +| REST unit | 40 | Mocked HTTP client tests | +| REST integration | 11 | Ably sandbox tests | +| Realtime unit | 54 | Mocked WebSocket tests | +| Realtime integration (direct) | 13 | Direct sandbox tests | +| Realtime integration (proxy) | 7 | Fault injection via Go proxy | +| Helper specs | 4 | Mock infrastructure definitions | +| **Total** | **129** | | -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 +## Three Test Tiers -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) +**Unit tests** use mocked transports (MockHttpClient, MockWebSocket) to verify client-side logic: state machines, request formation, response parsing, timer behaviour, error handling. They are fast and deterministic. -### Integration Tests +**Integration tests** (direct) run against the Ably sandbox to verify that the SDK interoperates correctly with the real service. No fault injection — these test happy-path behaviour and real error responses. -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 +**Integration tests** (proxy) run against the Ably sandbox through a programmable Go proxy that can inject faults: connection drops, suppressed frames, replaced responses, HTTP error injection. These test behaviour that can't be verified without controlling the network path. -#### Sandbox App Management +## Pseudocode Conventions -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. +Test specs use a consistent pseudocode syntax: ```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 +# Setup +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {"result": "ok"}) ) -``` - -### 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. +install_mock(mock_http) +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) -## 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 +# Test 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 -``` +# Assertions +ASSERT result.field == "ok" +ASSERT request.url.path == "/channels/" + encode_uri_component(name) + "/messages" -### Error Testing -```pseudo -AWAIT operation_that_fails() FAILS WITH error -ASSERT error.code == expected_code -``` +# Error testing +AWAIT client.badOperation() FAILS WITH error +ASSERT error.code == 40160 -### URI Path Component Encoding -```pseudo -encode_uri_component(value) +# State transitions +AWAIT_STATE client.connection.state == ConnectionState.connected ``` -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`) +See [docs/writing-test-specs.md](docs/writing-test-specs.md) for the full pseudocode reference, mock patterns, and conventions. -### 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") -``` +## Guides -## Fixtures +- **[Writing Test Specs](docs/writing-test-specs.md)** — How to author UTS specs: mock patterns, pseudocode conventions, proxy test structure, common mistakes +- **[Writing Derived Tests](docs/writing-derived-tests.md)** — How to translate UTS specs into SDK-specific tests, diagnose failures, and record deviations +- **[Integration Testing Policy](docs/integration-testing.md)** — When to write integration vs unit tests, proxy test design principles, test structure conventions +- **[Completion Status](docs/completion-status.md)** — Coverage matrix tracking which spec items have UTS test specs -Where applicable, tests reference fixtures from `ably-common`: -- Encoding/decoding test vectors -- Standard test data -- App setup configuration: `test-resources/test-app-setup.json` +## Go Test Proxy -## Implementation Notes +The programmable proxy for integration testing lives in a separate repository: [ably/uts-proxy](https://github.com/ably/uts-proxy). It sits between the SDK and the Ably sandbox, transparently forwarding traffic while allowing rule-based fault injection. -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 +See `realtime/integration/helpers/proxy.md` for the proxy infrastructure specification used by test specs in this repository. diff --git a/uts/completion-status.md b/uts/docs/completion-status.md similarity index 80% rename from uts/completion-status.md rename to uts/docs/completion-status.md index 43a27031c..da149a36e 100644 --- a/uts/completion-status.md +++ b/uts/docs/completion-status.md @@ -1,6 +1,6 @@ # UTS Test Spec Completion Status -This matrix lists all spec items from the [Ably features spec](../../specification/md/features.md) and indicates which have a UTS test specification. +This matrix lists all spec items from the [Ably features spec](../../specifications/features.md) and indicates which have a UTS test specification. **Legend:** - **Yes** — UTS test spec exists covering this item @@ -31,7 +31,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSC1 | Constructor options (RSC1a–RSC1c) | Yes — `realtime/unit/client/client_options.md`, `realtime/unit/client/realtime_client.md` | +| RSC1 | Constructor options (RSC1a–RSC1c) | Yes — `realtime/unit/client/realtime_client.md` | | RSC2 | Logger default | Yes — `rest/unit/logging.md` | | RSC3 | Log level configuration | Yes — `rest/unit/logging.md` | | RSC4 | Custom logger | Yes — `rest/unit/logging.md` | @@ -42,7 +42,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSC9 | Auth usage for authentication | Information only | | RSC10 | Token error retry handling | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | | RSC13 | Connection and request timeouts | Yes — `rest/unit/rest_client.md` | -| RSC15 | Host fallback behaviour (RSC15a–RSC15n) | Yes — `rest/unit/fallback.md` | +| RSC15 | Host fallback behaviour (RSC15a–RSC15n) | Yes — `rest/unit/fallback.md`, `rest/integration/proxy/rest_fallback.md` | | RSC16 | Time function | Yes — `rest/unit/time.md`, `rest/integration/time_stats.md` | | RSC17 | ClientId attribute | Yes — `rest/unit/rest_client.md` | | RSC18 | TLS configuration | Yes — `rest/unit/rest_client.md`, `rest/unit/time.md` | @@ -62,12 +62,12 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSA1 | Basic Auth requires HTTPS | Yes — `rest/unit/auth/auth_scheme.md` | | RSA2 | Basic Auth default | Yes — `rest/unit/auth/auth_scheme.md` | | RSA3 | Token Auth support (RSA3a–RSA3d) | Yes — `rest/unit/auth/auth_scheme.md` | -| RSA4 | Token Auth selection logic (RSA4a–RSA4g) | Partial — `rest/unit/auth/auth_scheme.md` covers RSA4, RSA4b; `rest/unit/auth/token_renewal.md` covers RSA4b4; `realtime/unit/auth/connection_auth_test.md` covers RSA4; `realtime/unit/connection/error_reason_test.md` covers RSA4c1, RSA4d | +| RSA4 | Token Auth selection logic (RSA4a–RSA4g) | Yes — `rest/unit/auth/auth_scheme.md` covers RSA4, RSA4b; `rest/unit/auth/token_renewal.md` covers RSA4b4; `realtime/unit/auth/connection_auth_test.md` covers RSA4; `realtime/unit/connection/error_reason_test.md` covers RSA4c1, RSA4d; `realtime/unit/auth/token_expiry_non_renewable_test.md` covers RSA4a, RSA4a1, RSA4a2; `realtime/unit/auth/auth_callback_errors_test.md` covers RSA4c, RSA4c1–RSA4c3, RSA4d, RSA4e, RSA4f; `realtime/integration/auth/token_renewal_test.md` covers RSA4b | | RSA5 | TTL for tokens | Yes — `rest/unit/auth/token_request_params.md`, `rest/integration/auth.md` | | RSA6 | Capability JSON | Yes — `rest/unit/auth/token_request_params.md`, `rest/integration/auth.md` | | RSA7 | ClientId and authenticated clients (RSA7a–RSA7e2) | Partial — `rest/unit/auth/client_id.md` covers RSA7, RSA7a–RSA7c; `realtime/integration/auth.md` covers RSA7 | | RSA8 | RequestToken function (RSA8a–RSA8g) | Partial — `rest/unit/auth/auth_callback.md` covers RSA8c, RSA8d; `realtime/unit/auth/connection_auth_test.md` covers RSA8d; `rest/integration/auth.md` covers RSA8; `realtime/integration/auth.md` covers RSA8 | -| RSA9 | CreateTokenRequest (RSA9a–RSA9i) | Partial — `rest/integration/auth.md` covers RSA9 | +| RSA9 | CreateTokenRequest (RSA9a–RSA9i) | Partial — `rest/integration/auth.md` covers RSA9; `realtime/integration/auth/token_request_test.md` covers RSA9a, RSA9g | | RSA10 | Authorize function (RSA10a–RSA10l) | Yes — `rest/unit/auth/authorize.md` | | RSA11 | Base64 encoded API key | Yes — `rest/unit/auth/auth_scheme.md` (with RSA2) | | RSA12 | Auth#clientId attribute (RSA12a–RSA12b) | Yes — `rest/unit/auth/client_id.md` | @@ -87,12 +87,12 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| | RSL1 | Publish function (RSL1a–RSL1n1) | Yes — `rest/unit/channel/publish.md`, `rest/unit/channel/publish_result.md`, `rest/integration/publish.md`, `rest/integration/mutable_messages.md` | -| RSL1k | Idempotent publishing (RSL1k1–RSL1k5) | Yes — `rest/unit/channel/idempotency.md` | +| RSL1k | Idempotent publishing (RSL1k1–RSL1k5) | Yes — `rest/unit/channel/idempotency.md`, `rest/integration/proxy/rest_fallback.md` (RSL1k4 pending proxy enhancement) | | RSL2 | History function (RSL2a–RSL2b3) | Yes — `rest/unit/channel/history.md`, `rest/integration/history.md` | | RSL3 | Presence attribute | Yes — `rest/unit/presence/rest_presence.md` (with RSP1a) | -| RSL4 | Message encoding (RSL4a–RSL4d4) | Yes — `rest/unit/encoding/message_encoding.md` | +| RSL4 | Message encoding (RSL4a–RSL4d4) | Yes — `rest/unit/encoding/message_encoding.md`, `realtime/integration/channels/channel_publish_test.md` | | RSL5 | Message encryption (RSL5a–RSL5c) | | -| RSL6 | Message decoding (RSL6a–RSL6b) | Yes — `rest/unit/encoding/message_encoding.md` | +| RSL6 | Message decoding (RSL6a–RSL6b) | Yes — `rest/unit/encoding/message_encoding.md`, `realtime/integration/channels/channel_publish_test.md` covers RSL6, RSL6a2 | | RSL7 | SetOptions function | Yes — `rest/unit/channel/rest_channel_attributes.md` | | RSL8 | Status function (RSL8a) | Yes — `rest/unit/channel/rest_channel_attributes.md` | | RSL9 | Name attribute | Yes — `rest/unit/channel/rest_channel_attributes.md` | @@ -136,7 +136,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSF1 | Robustness principle | | +| RSF1 | Robustness principle | Yes — `realtime/unit/connection/forwards_compatibility_test.md` | --- @@ -153,7 +153,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC5 | Stats function (RTC5a–RTC5b) | Yes — `realtime/unit/client/realtime_stats.md` (proxies to RSC6 tests) | | RTC6 | Time function (RTC6a) | Yes — `realtime/unit/client/realtime_time.md` (proxies to RSC16 tests) | | RTC7 | Uses configured timeouts | Yes — `realtime/unit/client/realtime_timeouts.md` | -| RTC8 | Authorize function for realtime (RTC8a–RTC8c) | Yes — `realtime/unit/auth/realtime_authorize.md`, `realtime/integration/auth.md` | +| RTC8 | Authorize function for realtime (RTC8a–RTC8c) | Yes — `realtime/unit/auth/realtime_authorize.md`, `realtime/integration/auth.md`; `realtime/integration/proxy/auth_reauth.md` covers RTC8a | | RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | | RTC10–RTC11 | Deleted | N/A | | RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | @@ -179,14 +179,14 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTN11 | Connect function (RTN11a–RTN11f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN11; `realtime/unit/connection/error_reason_test.md` covers RTN11d | | RTN12 | Close function (RTN12a–RTN12f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN12, RTN12a | | RTN13 | Ping function (RTN13a–RTN13e) | Yes — `realtime/unit/connection/connection_ping_test.md` | -| RTN14 | Connection opening failures (RTN14a–RTN14g) | Yes — `realtime/unit/connection/connection_open_failures_test.md` | -| RTN15 | Connection failures when CONNECTED (RTN15a–RTN15j) | Yes — `realtime/unit/connection/connection_failures_test.md` | -| RTN16 | Connection recovery (RTN16a–RTN16m1) | Partial — `realtime/unit/connection/error_reason_test.md` covers RTN16e | +| RTN14 | Connection opening failures (RTN14a–RTN14g) | Yes — `realtime/unit/connection/connection_open_failures_test.md`; `realtime/integration/connection/connection_failures_test.md` covers RTN14a, RTN14g; `realtime/integration/auth/token_renewal_test.md` covers RTN14b; `realtime/integration/proxy/connection_open_failures.md` covers RTN14a–RTN14d, RTN14g | +| RTN15 | Connection failures when CONNECTED (RTN15a–RTN15j) | Yes — `realtime/unit/connection/connection_failures_test.md`; `realtime/integration/proxy/connection_resume.md` covers RTN15a, RTN15b, RTN15c6, RTN15c7, RTN15g, RTN15g2, RTN15h1, RTN15h3, RTN15j | +| RTN16 | Connection recovery (RTN16a–RTN16m1) | Yes — `realtime/unit/connection/connection_recovery_test.md` covers RTN16d, RTN16f, RTN16f1, RTN16g, RTN16g1, RTN16g2, RTN16i, RTN16j, RTN16k, RTN16l; `realtime/integration/proxy/connection_resume.md` covers RTN16d, RTN16l; `realtime/unit/connection/error_reason_test.md` covers RTN16e | | RTN17 | Domain selection and fallback (RTN17a–RTN17j) | Yes — `realtime/unit/connection/fallback_hosts_test.md` | -| RTN19 | Transport state side effects (RTN19a–RTN19b) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN19a, RTN19a2, RTN19b | -| RTN20 | OS network change handling (RTN20a–RTN20c) | | +| RTN19 | Transport state side effects (RTN19a–RTN19b) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN19a, RTN19a2, RTN19b; `realtime/integration/proxy/connection_resume.md` covers RTN19a, RTN19a2 | +| RTN20 | OS network change handling (RTN20a–RTN20c) | Yes — `realtime/unit/connection/network_change_test.md` | | RTN21 | ConnectionDetails override defaults | Partial — `realtime/unit/connection/update_events_test.md` covers RTN21; `realtime/integration/connection_lifecycle_test.md` covers RTN21 | -| RTN22 | Re-authentication request handling (RTN22a) | Yes — `realtime/unit/connection/server_initiated_reauth_test.md` | +| RTN22 | Re-authentication request handling (RTN22a) | Yes — `realtime/unit/connection/server_initiated_reauth_test.md`; `realtime/integration/proxy/auth_reauth.md` covers RTN22 | | RTN23 | Heartbeats (RTN23a–RTN23b) | Yes — `realtime/unit/connection/heartbeat_test.md` | | RTN24 | UPDATE event on CONNECTED while connected | Yes — `realtime/unit/connection/update_events_test.md` | | RTN25 | Connection#errorReason attribute | Yes — `realtime/unit/connection/error_reason_test.md` | @@ -209,18 +209,18 @@ This matrix lists all spec items from the [Ably features spec](../../specificati |-----------|-------------|---------------| | RTL1 | Message and presence processing | Information only | | RTL2 | Channel event emission (RTL2a–RTL2i) | Yes — `realtime/unit/channels/channel_state_events.md` | -| RTL3 | Connection state side effects (RTL3a–RTL3e) | Yes — `realtime/unit/channels/channel_connection_state.md` | -| RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md` | -| RTL5 | Detach function (RTL5a–RTL5l) | Yes — `realtime/unit/channels/channel_detach.md` | -| RTL6 | Publish function (RTL6a–RTL6k) | Yes — `realtime/unit/channels/channel_publish.md` | -| RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md` | +| RTL3 | Connection state side effects (RTL3a–RTL3e) | Yes — `realtime/unit/channels/channel_connection_state.md`; `realtime/integration/proxy/channel_faults.md` covers RTL3d | +| RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md`; `realtime/integration/channels/channel_attach_test.md` covers RTL4, RTL4c; `realtime/integration/proxy/channel_faults.md` covers RTL4f | +| RTL5 | Detach function (RTL5a–RTL5l) | Yes — `realtime/unit/channels/channel_detach.md`; `realtime/integration/channels/channel_attach_test.md` covers RTL5, RTL5d; `realtime/integration/proxy/channel_faults.md` covers RTL5f | +| RTL6 | Publish function (RTL6a–RTL6k) | Yes — `realtime/unit/channels/channel_publish.md`; `realtime/integration/channels/channel_publish_test.md` covers RTL6, RTL6f | +| RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md`; `realtime/integration/channels/channel_subscribe_test.md` covers RTL7, RTL7a, RTL7b, RTL7d | | RTL8 | Unsubscribe function (RTL8a–RTL8c) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL9 | Presence attribute (RTL9a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTL10 | History function (RTL10a–RTL10d) | Yes — `realtime/unit/channels/channel_history.md` covers RTL10a, RTL10b, RTL10c (proxies to RSL2 tests); `realtime/integration/channel_history_test.md` covers RTL10d | | RTL11 | Channel state effect on presence (RTL11a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | -| RTL12 | Additional ATTACHED message handling | Yes — `realtime/unit/channels/channel_additional_attached.md` | +| RTL12 | Additional ATTACHED message handling | Yes — `realtime/unit/channels/channel_additional_attached.md`; `realtime/integration/proxy/channel_faults.md` covers RTL12 | | RTL13 | Server-initiated DETACHED handling (RTL13a–RTL13c) | Yes — `realtime/unit/channels/channel_server_initiated_detach.md` | -| RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md` | +| RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md`; `realtime/integration/channels/channel_attach_test.md` covers RTL14; `realtime/integration/proxy/channel_faults.md` covers RTL14 | | RTL15 | Channel#properties attribute (RTL15a–RTL15b1) | Yes — `realtime/unit/channels/channel_properties.md` | | RTL16 | SetOptions function (RTL16a) | Yes — `realtime/unit/channels/channel_options.md` | | RTL17 | No messages outside ATTACHED state | Yes — `realtime/unit/channels/channel_subscribe.md` | @@ -228,7 +228,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL19 | Base payload storage for vcdiff (RTL19a–RTL19c) | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | | RTL20 | Last message ID storage | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | | RTL21 | Message ordering in arrays | Yes — `realtime/unit/channels/channel_delta_decoding.md` | -| RTL22 | Message filtering (RTL22a–RTL22d) | | +| RTL22 | Message filtering (RTL22a–RTL22d) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL23 | Name attribute | Yes — `realtime/unit/channels/channel_attributes.md` | | RTL24 | ErrorReason attribute | Yes — `realtime/unit/channels/channel_attributes.md` | | RTL25 | WhenState function (RTL25a–RTL25b) | Yes — `realtime/unit/channels/channel_when_state_test.md` | @@ -243,7 +243,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| | RTP1 | HAS_PRESENCE flag and SYNC | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | -| RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | Yes — `realtime/unit/presence/presence_map.md` | +| RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | Yes — `realtime/unit/presence/presence_map.md`; `realtime/integration/presence/presence_sync_test.md` covers RTP2 | | RTP4 | Large member count test | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | | RTP5 | Channel state side effects (RTP5a–RTP5f) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTP6 | Subscribe function (RTP6a–RTP6e) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md`, `realtime/integration/presence_lifecycle_test.md` | @@ -251,13 +251,13 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTP8 | Enter function (RTP8a–RTP8j) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | | RTP9 | Update function (RTP9a–RTP9e) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | | RTP10 | Leave function (RTP10a–RTP10e) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | -| RTP11 | Get function (RTP11a–RTP11d) | Yes — `realtime/unit/presence/realtime_presence_get.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP11 | Get function (RTP11a–RTP11d) | Yes — `realtime/unit/presence/realtime_presence_get.md`, `realtime/integration/presence_lifecycle_test.md`; `realtime/integration/presence/presence_sync_test.md` covers RTP11a | | RTP12 | History function (RTP12a–RTP12d) | Yes — `realtime/unit/presence/realtime_presence_history.md` | | RTP13 | SyncComplete attribute | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTP14 | EnterClient function (RTP14a–RTP14d) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | | RTP15 | EnterClient/UpdateClient/LeaveClient (RTP15a–RTP15f) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | | RTP16 | Connection state conditions (RTP16a–RTP16c) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | -| RTP17 | Internal PresenceMap (RTP17a–RTP17j) | Partial — `realtime/unit/presence/local_presence_map.md` covers RTP17, RTP17b, RTP17h; `realtime/unit/presence/realtime_presence_reentry.md` covers RTP17a, RTP17e, RTP17g, RTP17g1, RTP17i | +| RTP17 | Internal PresenceMap (RTP17a–RTP17j) | Partial — `realtime/unit/presence/local_presence_map.md` covers RTP17, RTP17b, RTP17h; `realtime/unit/presence/realtime_presence_reentry.md` covers RTP17a, RTP17e, RTP17g, RTP17g1, RTP17i; `realtime/integration/proxy/presence_reentry.md` covers RTP17i, RTP17g | | RTP18 | Server-initiated sync (RTP18a–RTP18c) | Yes — `realtime/unit/presence/presence_sync.md` | | RTP19 | PresenceMap cleanup on sync (RTP19a) | Yes — `realtime/unit/presence/presence_sync.md`, `realtime/unit/presence/realtime_presence_channel_state.md` | @@ -277,13 +277,13 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RTB1 | Retry timeout calculation (RTB1a–RTB1b) | | +| RTB1 | Retry timeout calculation (RTB1a–RTB1b) | Yes — `realtime/unit/connection/backoff_jitter_test.md` | ### Forwards Compatibility (Realtime) | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RTF1 | Robustness principle | | +| RTF1 | Robustness principle | Yes — `realtime/unit/connection/forwards_compatibility_test.md` | ### Wrapper SDK Proxy Client @@ -302,7 +302,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSH3 | Activation state machine (RSH3a–RSH3g3) | | | RSH4–RSH5 | Event queueing and sequential handling | | | RSH6 | Push device authentication (RSH6a–RSH6b) | | -| RSH7 | Push channels (RSH7a–RSH7e) | | +| RSH7 | Push channels (RSH7a–RSH7e) | Yes — `rest/unit/push/push_channels.md`, `rest/integration/push_channels.md` | | RSH8 | LocalDevice (RSH8a–RSH8k2) | | --- @@ -340,7 +340,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | TC1–TC2 | Capability | | | CD1–CD2 | ConnectionDetails | | | CP1–CP2 | ChannelProperties | | -| CHD1–CHD2, CHS1–CHS2, CHO1–CHO2, CHM1–CHM2 | Channel status types | | +| CHD1–CHD2, CHS1–CHS2, CHO1–CHO2, CHM1–CHM2 | Channel status types | Yes — `rest/unit/channel/rest_channel_attributes.md` | | BAR1–BAR2 | BatchResult | Partial — `rest/unit/batch_presence.md` covers BAR2 | | BSP1–BSP2 | BatchPublishSpec | | | BPR1–BPR2, BPF1–BPF2 | BatchPublish result types | | @@ -348,7 +348,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | PBR1–PBR2 | PublishResult | Yes — `realtime/unit/channels/channel_publish.md` | | UDR1–UDR2 | UpdateDeleteResult | Yes — `rest/unit/types/mutable_message_types.md` | | TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | Yes — `rest/unit/auth/revoke_tokens.md` | -| MFI1–MFI2 | MessageFilter | | +| MFI1–MFI2 | MessageFilter | Yes — `realtime/unit/channels/channel_subscribe.md` | | REX1–REX2 | ReferenceExtras | | ### Option Types @@ -398,18 +398,33 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTN15a | Unexpected disconnect triggers resume | Yes — `realtime/integration/proxy/connection_resume.md` | | RTN15b/c6 | Resume preserves connectionId | Yes — `realtime/integration/proxy/connection_resume.md` | | RTN15c7 | Failed resume gets new connectionId | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15g/g2 | connectionStateTtl expiry clears resume state | Yes — `realtime/integration/proxy/connection_resume.md` | | RTN15h1 | DISCONNECTED with token error, non-renewable → FAILED | Yes — `realtime/integration/proxy/connection_resume.md` | | RTN15h3 | DISCONNECTED with non-token error → reconnect | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN15j | Fatal ERROR on established connection | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN16d/l | Connection recovery via proxy | Yes — `realtime/integration/proxy/connection_resume.md` | +| RTN19a/a2 | Unacked messages resent after resume | Yes — `realtime/integration/proxy/connection_resume.md` | | RTN23a | Heartbeat starvation causes disconnect | Yes — `realtime/integration/proxy/heartbeat.md` | | RTN23a | heartbeats=true in connection URL | Yes — `realtime/integration/proxy/heartbeat.md` | | RTL4f | Attach timeout (server doesn't respond) | Yes — `realtime/integration/proxy/channel_faults.md` | -| RTL4h | Server responds with ERROR to ATTACH | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL14 | Server responds with ERROR to ATTACH → FAILED | Yes — `realtime/integration/proxy/channel_faults.md` | | RTL5f | Detach timeout (server doesn't respond) | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL12 | ATTACHED with resumed=false triggers reattach | Yes — `realtime/integration/proxy/channel_faults.md` | | RTL13a | Server sends unsolicited DETACHED → reattach | Yes — `realtime/integration/proxy/channel_faults.md` | | RTL14 | Server sends channel ERROR → FAILED | Yes — `realtime/integration/proxy/channel_faults.md` | +| RTL3d | Channels reattach after connection recovery | Yes — `realtime/integration/proxy/channel_faults.md` | +| RSC15l | Connection drop triggers fallback retry | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l2 | Request timeout triggers fallback retry | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l4 | CloudFront Server header triggers fallback | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l | Unreachable endpoint surfaces correct error | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l | HTTP 5xx with/without error body parsed correctly | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSC15l | HTTP 4xx not retried, error parsed | Yes — `rest/integration/proxy/rest_fallback.md` | +| RSL1k4 | Idempotent publish retry deduplication | Pending — `rest/integration/proxy/rest_fallback.md` (needs proxy enhancement) | | RSC10 | Token renewal on HTTP 401 | Yes — `realtime/integration/proxy/rest_faults.md` | -| RSC15a | HTTP 503 error (no fallback) | Yes — `realtime/integration/proxy/rest_faults.md` | +| RSC15m/REC2c2 | HTTP 503 error (no fallback, hosts disabled) | Yes — `realtime/integration/proxy/rest_faults.md` | | RTL6 | End-to-end publish and history | Yes — `realtime/integration/proxy/rest_faults.md` | +| RTN22/RTC8a | Server-initiated re-authentication | Yes — `realtime/integration/proxy/auth_reauth.md` | +| RTP17i/RTP17g | Automatic presence re-entry on non-resumed reattach | Yes — `realtime/integration/proxy/presence_reentry.md` | ## Summary @@ -418,19 +433,19 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **Endpoint config** (REC) | 3 | 3 | Full | | **REST client** (RSC) | 18 | 16 | Partial | | **REST auth** (RSA) | 15 | 15 | Full | -| **REST channels** (RSN) | 4 | 0 | None | +| **REST channels** (RSN) | 4 | 4 | Full | | **REST channel** (RSL) | 13 | 13 | Full | | **REST presence** (RSP) | 5 | 4 | Mostly | | **REST encryption** (RSE) | 2 | 0 | None | | **REST annotations** (RSAN) | 3 | 3 | Full | | **Realtime client** (RTC) | 14 | 14 | Full | -| **Connection** (RTN) | 23 | 18 | Partial | +| **Connection** (RTN) | 23 | 19 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | | **Realtime channel** (RTL) | 28 | 26 | Partial | | **Realtime presence** (RTP) | 15 | 15 | Full | | **Realtime annotations** (RTAN) | 5 | 5 | Full | | **EventEmitter** (RTE) | 6 | 0 | None | -| **Backoff/jitter** (RTB) | 1 | 0 | None | +| **Backoff/jitter** (RTB) | 1 | 1 | Full | | **Wrapper SDK** (WP) | 7 | 0 | None | | **Push notifications** (RSH) | 8 | 1 | Partial | | **Plugins** (PC/PT/VD) | 3 | 2 | Partial | @@ -439,4 +454,4 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **Push types** | 3 | 0 | None | | **Introspection** (CR) | 1 | 0 | None | | **Defaults** (DF) | 1 | 0 | None | -| **Compatibility** (RSF/RTF) | 2 | 0 | None | +| **Compatibility** (RSF/RTF) | 2 | 2 | Full | diff --git a/uts/docs/integration-testing.md b/uts/docs/integration-testing.md new file mode 100644 index 000000000..fa7fd0a6c --- /dev/null +++ b/uts/docs/integration-testing.md @@ -0,0 +1,361 @@ +# Integration Testing Policy + +This document defines the policy for integration tests in the UTS test suite. It covers what to test, how tests are organised, and the distinction between direct sandbox tests and proxy-based tests. + +## Relationship to Unit Tests + +Unit tests use mocked transports (MockWebSocket, MockHttpClient) to verify client-side logic: state machines, request formation, response parsing, timer behaviour, error handling. They are fast and deterministic. + +Integration tests verify that the SDK interoperates correctly with the real Ably service. They run against the Ably sandbox and exercise the actual network path. + +**Integration tests do not replace unit tests.** Every spec point that has an integration test should also have a unit test. The integration test adds confidence that the mocked behaviour in the unit test matches reality. + +## What to Test + +Integration tests should cover spec points where correctness depends on agreement between client and server. Not every spec point needs an integration test — only those where a unit test alone leaves meaningful doubt. + +### Selection Criteria + +Choose spec points for integration testing when they fall into one or more of these categories: + +#### 1. Request/Response Shape Interop + +The SDK constructs a request (HTTP or protocol message) and the server must accept it, or the server sends a response and the SDK must parse it correctly. + +Examples: +- Auth token obtained via `createTokenRequest` is accepted by the server (RSA9) +- WebSocket connection URL parameters are accepted (RTN2) +- Channel attach/detach protocol messages round-trip correctly (RTL4, RTL13) +- Publish with various data types round-trips through the server (RSL1, RTL6) + +#### 2. Error Response Interop + +The server rejects invalid requests with specific error codes, and the SDK must surface those errors correctly. + +Examples: +- Invalid API key produces the correct error code and state transition (RTN14b) +- Token expiry triggers renewal flow (RSA4b) +- Insufficient capability produces channel FAILED (RTL4e) + +#### 3. Data Encoding Round-Trips + +Data passes through the SDK's encoding layer, through the server, and back. The round-trip must preserve data integrity. + +Examples: +- String, binary, and JSON data types are preserved through publish/subscribe (RSL4, RSL6) +- Presence data encoding round-trips (RTP8) +- Message extras survive the round-trip + +#### 4. Stateful Protocol Sequences + +Multi-step interactions where the server's state machine and the client's state machine must agree. + +Examples: +- Connection resume after disconnect (RTN15) — proxy required +- Presence SYNC protocol (RTP2) — server-initiated, can't be mocked faithfully +- Channel reattach after server-initiated detach (RTL13) — proxy required +- Heartbeat timeout detection (RTN23) — proxy required to starve heartbeats + +### What NOT to Test + +Do not write integration tests for: +- Pure client-side logic (option parsing, state machine transitions that don't depend on server responses) +- Behaviour that is fully exercised by unit tests with high confidence (e.g. event emitter semantics, channel name validation) +- Timing-sensitive retry logic where the integration test would be flaky without the proxy +- Features that require server-side configuration not available in the sandbox + +## Directory Structure + +Integration test specs are organised to mirror the unit test structure: + +``` +realtime/ + unit/ # Unit tests (mock transport) + auth/ + connection_auth_test.md + realtime_authorize_test.md + channels/ + channel_attach_test.md + channel_publish_test.md + ... + connection/ + auto_connect_test.md + connection_failures_test.md + ... + presence/ + realtime_presence_enter_test.md + ... + integration/ # Integration tests + auth/ # Direct sandbox tests + connection_auth_test.md + realtime_authorize_test.md + channels/ + channel_attach_test.md + channel_publish_test.md + ... + connection/ + connection_lifecycle_test.md + ... + presence/ + presence_lifecycle_test.md + ... + helpers/ + proxy.md # Proxy infrastructure spec + proxy/ # Proxy-based tests (sandbox + proxy) + connection_open_failures.md + connection_resume.md + heartbeat.md + channel_faults.md + rest_faults.md + auth_reauth.md + presence_reentry.md +``` + +### Segregation Rationale + +Tests that require the proxy are segregated into `integration/proxy/` because: + +1. **Different infrastructure requirements** — proxy tests need the proxy binary running, port allocation, and proxy session lifecycle management. Direct sandbox tests need only network access to the sandbox. +2. **Different CI configuration** — proxy tests can run on a different schedule or be gated on proxy availability, without affecting direct integration tests. +3. **Different failure modes** — proxy test failures may indicate proxy bugs, port conflicts, or proxy/SDK version mismatches, not just SDK issues. +4. **Clear authoring signal** — when writing a test, the file location encodes whether the proxy is needed. No conditional skip logic inside test files. + +### Shared Spec Points + +A single spec point may have tests in multiple tiers. For example, RTN15 (connection resume): + +- `unit/connection/connection_failures_test.md` — mock transport verifies client-side state transitions and retry logic +- `integration/proxy/connection_resume.md` — proxy verifies the resume protocol works against the real server + +This is expected and correct. The unit test verifies client logic; the integration test verifies client-server agreement. + +## Test Structure Conventions + +### Sandbox Setup + +Every integration test file includes the standard sandbox provisioning: + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Proxy Setup (integration-proxy only) + +Proxy tests additionally set up a proxy session per test or group of tests. See `realtime/integration/helpers/proxy.md` for the proxy infrastructure API. + +```pseudo +BEFORE EACH TEST: + session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [ ...initial rules... ] + ) + +AFTER EACH TEST: + session.close() +``` + +### Client Options + +Integration test clients use: + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", # Direct sandbox tests + useBinaryProtocol: false, + autoConnect: false +)) +``` + +Proxy test clients use: + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Channel Names + +Channel names must be unique per test to avoid cross-test interference: + +```pseudo +channel_name = "test-RTL4-attach-${base64(random_bytes(6))}" +``` + +### Spec Point References + +Each test section references the spec points it covers, just like unit tests: + +```pseudo +## RTN4b - Successful connection establishment + +| Spec | Requirement | +|------|-------------| +| RTN4b | Connection transitions INITIALIZED → CONNECTING → CONNECTED | +``` + +### Timeout Strategy + +Integration tests interact with real services over real networks, so timeouts need more thought than unit tests. Apply two levels of timeout: + +**Suite timeout** — the mocha `this.timeout()` on the `describe` block. This must accommodate the sum of all tests in the suite plus setup and teardown. For suites with many tests or slow sandbox operations, 120 seconds is a reasonable default. Suites with only 1–3 fast tests can use 30–60 seconds. + +**Operation timeout** — individual operations that may hang (HTTP requests, WebSocket state waits, sandbox provisioning/teardown) should each have their own timeout, shorter than the suite timeout. This ensures a single stuck operation produces a clear error message rather than silently consuming the suite budget until mocha kills the entire suite with a generic "timeout exceeded." + +Guidelines: + +- Sandbox provisioning and teardown HTTP requests: 30 seconds (via `AbortSignal.timeout()` or equivalent). Sandbox teardown (app deletion) should be best-effort — catch and ignore timeout errors, since sandbox apps auto-expire. +- `connectAndWait`, `closeAndWait`, channel attach waits: 10–15 seconds. +- Proxy tests with `realtimeRequestTimeout` set low (e.g. 3 seconds for timeout tests): give the suite timeout at least `realtimeRequestTimeout + 12 seconds` headroom per such test. +- `pollUntil` calls: explicit timeout parameter, typically 10–30 seconds. + +The goal is: every await in the test is bounded, and the suite timeout is generous enough that it only fires if something truly unexpected happens. When a test fails, the error should say *what* timed out, not just "suite timeout exceeded." + +### Avoiding Flaky Tests + +- Use polling with timeouts instead of fixed waits (see `README.md` polling conventions) +- For token expiry tests, use short TTLs and poll for rejection +- For state transition assertions, wait for the target state event rather than asserting after a delay +- Proxy tests should use proxy event logs for verification rather than timing-dependent assertions +- When tests pass in isolation but fail in the full suite, suspect sandbox rate limiting or connection exhaustion — increase the suite timeout rather than adding retries + +## Protocol Variants + +The Ably client library spec (G1) requires that tests run with all supported protocols. Integration tests that exercise the data encoding/decoding path must run with both JSON and msgpack to verify data integrity through the full encode-transmit-decode pipeline. + +### Which tests need both protocols? + +Only tests on the **data path** need both protocols. These are tests where messages, presence data, or other payloads pass through the SDK's encoding layer, through the server, and back. Examples include publish/subscribe round-trips, history retrieval, presence data, delta decoding, and mutable message operations. + +Tests for **connection lifecycle**, **authentication**, **channel attach/detach**, and other protocol-agnostic behaviours do not need protocol variants. These tests exercise control-plane operations whose correctness does not depend on the wire encoding of message payloads. + +**Proxy tests always use JSON.** The proxy only supports text WebSocket frames, so proxy-based tests cannot use msgpack. + +### Spec file convention + +A spec file that requires protocol variant testing includes a `## Protocol Variants` section immediately after `## Test Type`: + +```markdown +## Test Type +Integration test against Ably sandbox + +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. +``` + +The `PROTOCOL` variable is available in pseudocode and is set to `"json"` or `"msgpack"` for the current run. Client options use the standard pattern: + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" +)) +``` + +### Default behaviour + +Spec files **without** a `## Protocol Variants` section default to JSON only. No special handling is required in derived test implementations for these specs. + +### Annotated specs + +The following integration test specs are annotated with `## Protocol Variants`: + +**REST:** +- `rest/integration/publish.md` +- `rest/integration/history.md` +- `rest/integration/presence.md` +- `rest/integration/batch_presence.md` +- `rest/integration/mutable_messages.md` + +**Realtime:** +- `realtime/integration/channels/channel_publish_test.md` +- `realtime/integration/channel_history_test.md` +- `realtime/integration/presence_lifecycle_test.md` +- `realtime/integration/mutable_messages_test.md` +- `realtime/integration/delta_decoding_test.md` + +## Writing Proxy Tests + +The proxy mediates between the SDK and the real Ably server. It is not a mock server. Tests should be written to rely on actual server responses as much as possible, with the proxy intervening only where necessary to create the specific fault or error condition under test. + +The more a proxy constructs or replaces server responses, the more likely it is that the test exercises a scenario that diverges from real server behaviour. This undermines the value of integration testing over unit testing. + +### Prefer Late Fault Injection + +Wherever possible, structure tests so that the fault injected by the proxy occurs as the **final interaction** between client and server, with the test verifying the client's behaviour in response. All preceding interactions should pass through to the real server unmodified, establishing genuine client and server state. + +For example, to test that the SDK handles a connection-level ERROR correctly: +1. Let the real connection handshake complete through the proxy (real CONNECTED from server). +2. After the SDK is connected, use the proxy to inject or trigger the error condition. +3. Assert that the SDK transitions to the correct state. + +This maximises the proportion of the test that exercises real client-server interaction. + +### When Earlier Fault Injection Is Needed + +Sometimes the fault must occur at an earlier point — for example, replacing the server's response to the first CONNECTED, or suppressing an ATTACH before it reaches the server. When this is unavoidable, there are two approaches, each with a trade-off: + +**Approach A: Modify the server's response.** The proxy forwards the request to the server, receives the real response, but modifies it before forwarding to the client. The server believes the operation succeeded; the client sees an error. + +**Approach B: Handle the request without forwarding.** The proxy intercepts the request, generates a response itself, and never forwards to the server. Client and server state remain consistent (both believe the operation did not happen), but the response is entirely synthetic. + +**Prefer Approach A** (modify real server responses) when the resulting client-server state drift does not affect the validity of subsequent actions or assertions in the test. This preserves the integration testing value: the response structure, timing, and ancillary fields come from the real server, with only the specific fault injected. + +Use Approach B only when the state drift from Approach A would invalidate later parts of the test — for example, if the server's belief that a channel is attached would cause it to send unsolicited messages that interfere with subsequent assertions. + +### Example: Simulating a Rejected Attach + +To test that the SDK handles a channel attach rejection correctly, after a successful real connection: + +**Approach A (preferred):** The proxy forwards the ATTACH to the server, receives the real ATTACHED response, but replaces it with an ERROR before forwarding to the client. The server now believes the channel is attached, but the client sees FAILED. This is acceptable when the test ends here — the state drift doesn't matter because there are no subsequent server interactions that depend on consistent channel state. + +**Approach B:** The proxy intercepts the ATTACH, does not forward it, and generates an ERROR response. Client and server agree the channel is not attached. But the error response is entirely synthetic — we might as well have written a unit test. + +### Implications for Test Design + +This principle influences test structure: + +- **Keep proxy tests focused.** Each test should verify one fault condition. Avoid multi-phase tests where an early proxy intervention creates state drift that compounds through later phases. +- **Use imperative actions for late injection.** The proxy's imperative action API (`trigger_action`) is ideal for injecting faults after the SDK has reached a stable state through real server interaction. +- **Use rules for response modification.** When a rule must fire during the protocol handshake (e.g., replacing the CONNECTED response), use `times: 1` so the proxy returns to passthrough for subsequent interactions. +- **Verify via proxy event logs.** Assert against the proxy's event log to confirm that the expected real server interactions occurred, rather than relying solely on SDK state. + +## Coverage Tracking + +Integration test coverage is tracked in `completion-status.md` alongside unit test coverage. Each spec point entry indicates which tiers have coverage: + +``` +RTN4b unit:✓ integration:✓ +RTN15a unit:✓ proxy:✓ +RTL4 unit:✓ integration:✓ +``` + +## Adding New Integration Tests + +1. **Check whether an integration test adds value** — apply the selection criteria above. If the unit test already provides high confidence, skip the integration test. +2. **Choose the right tier** — if the test needs fault injection (dropped connections, delayed frames, modified responses), it goes in `integration/proxy/`. Otherwise, `integration/`. +3. **Mirror the unit test structure** — use the same category directory and a similar file name. +4. **Write the UTS spec first** — just like unit tests, the portable test spec comes before the language-specific implementation. +5. **Reference spec points** — every test section must cite the spec points it covers. diff --git a/uts/docs/writing-derived-tests.md b/uts/docs/writing-derived-tests.md new file mode 100644 index 000000000..68a22b93c --- /dev/null +++ b/uts/docs/writing-derived-tests.md @@ -0,0 +1,275 @@ +# Writing Derived Tests from UTS Specs + +This guide covers the process of translating UTS (Universal Test Specification) portable test specs into working tests for a specific language and SDK. It also covers the optional evaluation step when an existing implementation is available to run the tests against. + +## Overview + +UTS specs are the source of truth for *what* to test. They define test structure, setup, assertions, and mock patterns in language-neutral pseudocode. A derived test translates that spec into a concrete, runnable test for a specific SDK. + +The process has two phases: + +1. **Translation** — always required. Produce a test file that faithfully implements the UTS spec. +2. **Evaluation** — optional. When an existing implementation is available, run the tests and diagnose any failures. + +Not every situation has an existing implementation. Tests may be written ahead of the implementation (test-first development), or for a new SDK that doesn't yet exist. In those cases, only the translation phase applies. + +--- + +## Phase 1: Translation + +### 1. Translate the UTS spec faithfully + +Write the test as closely as possible to the UTS spec. The UTS spec defines what to test — don't second-guess it, optimise it, or skip steps on a first pass. + +- **Match the spec's structure**: one test per spec point, same assertions, same setup +- **Use the spec's naming**: test names must include the spec point (e.g. `RSL1a - publish sends POST to correct path`) +- **Include the test ID**: add a `// UTS: ` comment immediately above each test function, using the test ID from the UTS spec (see `docs/writing-test-specs.md` § Test IDs for the format) +- **Preserve the spec's intent**: if the spec says "assert X", assert X, even if it seems redundant + +### 2. Map pseudocode to language idioms + +UTS specs use generic pseudocode. You need to map this onto the SDK's actual API and the language's test framework. Common mappings: + +| UTS pseudocode | What to figure out | +|---|---| +| `Rest(options: ...)` | SDK constructor syntax | +| `ASSERT x == y` | Test framework assertion style | +| `mock_http = MockHttpClient(...)` | SDK's mock infrastructure | +| `install_mock(mock_http)` | How mocks are injected (DI, platform patching, etc.) | +| `enable_fake_timers()` | Timer control mechanism | +| `ADVANCE_TIME(ms)` | Fake timer tick method | +| `AWAIT_STATE(connection, "connected")` | State waiting helper | + +Check the SDK's existing test infrastructure and conventions before writing anything. Reuse existing helpers, mock classes, and patterns. + +### 3. Flag ambiguity + +If the UTS spec is ambiguous — unclear what value to assert, unclear what "should" means in context, unclear whether a step is required or illustrative — add a comment in the test and continue with your best interpretation. Don't block on it; flag it for review. + +``` +// NOTE: UTS spec says "assert the response contains the field" but doesn't +// specify the value. Interpreting as: field must be present and non-null. +``` + +### 4. Verify the test compiles/parses + +Before moving to evaluation (or declaring the test done in a test-first scenario), make sure the test at least compiles, parses, or passes linting. Syntax errors in the translation are not interesting failures. + +--- + +## Phase 2: Evaluation (optional) + +This phase applies when you have an existing SDK implementation to run the tests against. If you're writing tests before the implementation exists, skip to [Test-first considerations](#test-first-considerations). + +### 1. Run the test + +Run the translated test against the current SDK build. + +If it passes, you're done with that test. + +### 2. If it fails, diagnose + +A test failure has exactly three possible causes. Work through them in order: + +#### 2a. Is the UTS spec wrong? + +Compare the UTS spec's claim against the Ably features spec (`specification/specifications/features.md`). The features spec is the ultimate authority. If the UTS spec contradicts it: + +- Fix the test to match the features spec +- Add a comment explaining the UTS spec error +- Record the error in the **UTS Spec Errors** section of the deviations file + +Examples: +- UTS spec claimed RSA4b means "clientId triggers token auth" — actual RSA4b is about token renewal on error +- UTS spec claimed expired tokens must not make HTTP requests — actual spec says local expiry detection is optional + +#### 2b. Is the test translation wrong? + +Re-read the UTS spec and your test side by side. Common translation errors: + +- Wrong assertion (e.g. strict equality vs deep equality, null vs undefined/nil) +- Missing setup step (e.g. protocol format options, TLS settings) +- Wrong API mapping (SDK method name differs from spec pseudocode) +- Mock response doesn't match what the SDK expects + +If the translation is wrong, fix the test. No deviation entry needed. + +#### 2c. Is the SDK non-compliant? + +If the UTS spec is correct per the features spec, and the test accurately translates it, then the SDK has a deviation. In this case: + +- Keep the test, but adapt it to pass against the SDK's current behaviour +- Document exactly what the spec requires vs what the SDK does +- Record it in the deviations file + +### 3. Deviation test patterns + +There are two patterns for deviation tests. Both should write the **spec-correct assertions** in the test body — the test should fail when run, proving the deviation exists. + +**Env-gated skip** (preferred) — the test contains the correct spec assertion but is skipped by default. An environment variable enables it on demand: +``` +it("RSA7b - clientId from TokenDetails", function() { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + + // ... spec-correct setup and assertions ... + assert client.auth.clientId == "token-client-id" +}) +``` +This has three advantages: +- Normal test runs stay green (deviations are skipped) +- Each deviation is individually reproducible: `RUN_DEVIATIONS=1 --grep "RSA7b"` +- Issues filed against the SDK can link to a concrete reproduction command +- When the SDK is fixed, removing the skip guard is the only change needed + +Use a consistent env var name across all deviation tests in the suite (e.g. `RUN_DEVIATIONS`). + +**Adapted assertion** — when the deviation changes observable behaviour but the test can still validate something useful, assert the SDK's actual behaviour and comment the spec expectation: +``` +it("RSC1b - no credentials raises error", function() { + // DEVIATION: see deviations.md + // Spec says error code 40106, ably-js uses 40160 + assert error.code == 40160 +}) +``` +Use this pattern when the SDK does *something* (just not the right thing) and you want to assert on the actual behaviour to prevent regressions. These tests pass in normal runs. + +**Avoid the accommodate-both pattern.** Tests that accept either the spec behaviour or the SDK behaviour (e.g. try/catch that passes regardless of which path is taken) provide no signal — they pass whether the SDK is compliant or not. Every test should either assert spec behaviour (and fail if non-compliant) or assert the SDK's actual behaviour (and document the deviation). Never both. + +### 4. Decision tree + +``` +Test fails + | + +-- Does UTS spec match features spec? + | | + | NO --> Fix test, record UTS spec error in deviations file + | | + | YES + | | + | +-- Does test accurately translate UTS spec? + | | + | NO --> Fix the test + | | + | YES --> SDK deviation. Adapt test, record in deviations file +``` + +--- + +## Recording deviations + +When evaluating against an existing implementation, maintain a deviations file (e.g. `deviations.md`) as the single record of all known issues. Each entry must include: + +1. **The spec point** (e.g. RSA4b4) +2. **What the spec says** — quote or paraphrase the features spec +3. **What the SDK does** — concrete observable behaviour +4. **Root cause** (if known) — file, function, mechanism +5. **Test impact** — which test(s) are affected and how they were adapted + +Deviations are grouped into three sections: +- **Failing Tests** — SDK non-compliance where the spec-correct test is present but skipped (env-gated). These are the primary output — each maps to a potential issue to file. +- **Adapted Tests** — SDK non-compliance where the test was adapted to assert actual behaviour. The test passes but documents a genuine deviation. +- **Mock Infrastructure Limitations** — tests that can't be implemented due to missing mock capabilities (e.g. msgpack support). These are skipped stubs, not SDK deviations. + +This file is valuable output. It gives the SDK team a precise catalogue of spec gaps, each with a failing test that can be turned on once the fix lands. + +### Filing issues from deviations + +Once the test suite is complete, classify the deviations into distinct issues grouped by root cause or theme — not one issue per test. For example, five tests that all fail because `auth.clientId` isn't derived from token details are one issue, not five. + +Each issue should include: +- The spec point(s) affected +- What the spec says vs what the SDK does +- A reproduction command: `RUN_DEVIATIONS=1 --grep "" ` +- A link to the PR containing the test suite + +This makes the issues actionable: a developer can check out the branch, run the command, see the failure, and know exactly what to fix. + +--- + +## Test-first considerations + +When writing tests before an implementation exists: + +- **Write the test to match the spec exactly.** Don't preemptively accommodate likely implementation gaps — you don't know what they are yet. +- **Use the skip/pending mechanism** of your test framework liberally. Tests that can't run yet should be marked as pending, not commented out. +- **Mock infrastructure may not exist yet.** You may need to build it. Follow the mock patterns defined in the UTS spec (`rest/unit/helpers/mock_http.md`, `realtime/unit/helpers/mock_websocket.md`). +- **The deviations file is created during evaluation**, not during translation. If there's no implementation to evaluate against, there are no deviations to record yet. + +--- + +## Practical notes + +### Check the SDK's API surface + +Not everything in the UTS pseudocode maps 1:1 to every SDK. Before writing tests, verify that the API exists. If an API is missing or named differently, note it and adapt the test. + +### Required options vary by SDK + +Some SDKs have defaults that conflict with mock infrastructure. For example, an SDK may default to binary protocol (msgpack) while mocks return JSON. Check what options are needed to make mocks work. + +### Wire values vs decoded values + +SDKs often convert between wire format and developer-facing types. For example, presence actions may be integers on the wire but strings or enums in the SDK's public API. Tests asserting on decoded objects must use the SDK's representation. Tests asserting on outgoing request bodies must use the wire format. + +### Pagination and Link headers + +If the SDK parses pagination `Link` headers, check the expected URL format. Some SDKs expect relative URLs with specific prefixes (e.g. `./messages?...`). + +### Idempotent ID format + +ID generation (base64 encoding, URL-safe variants, batch behaviour) varies between SDKs. Check the SDK's implementation before asserting on generated ID formats. + +### Build pipeline and CI checks + +Run the full build pipeline, not just the tests. Many SDKs have: +- **Type checking** (e.g. `tsc`, `mypy`) — catches type errors the test runner ignores +- **Linting** (e.g. `eslint`, `prettier`) — catches formatting issues +- **Bundling** (e.g. webpack, rollup) — may use stricter settings than the test runner + +In TypeScript projects, the test runner (e.g. mocha with `tsx`) often **strips types without checking them**. The bundler (e.g. webpack with `ts-loader`) does full type checking. Both must pass. Run the CI checks locally before pushing. + +Common type errors to watch for in test files: +- `let captured = []` needs `let captured: SomeType[] = []` (noImplicitAny) +- Callback parameters need type annotations: `(req) =>` -> `(req: any) =>` +- `catch (error)` needs `catch (error: any)` for property access +- Partial mock objects need `as any` casts when passed to typed constructors +- Optional method parameters may need explicit `null` or `{}` arguments + +### Timer and platform type mismatches + +SDKs that abstract platform APIs (timers, HTTP, WebSocket) behind an interface often have type mismatches between the interface definition and the concrete platform types. For example, `setTimeout` returns `number` in browsers but `NodeJS.Timeout` in Node. When installing mock timers, you may need explicit casts: + +``` +Platform.Config.setTimeout = mockSetTimeout as unknown as typeof Platform.Config.setTimeout; +``` + +These casts are an SDK wart, not a test problem — apply them as needed and move on. + +### No real timers in unit tests + +Unit tests must not use real timers (`setTimeout`, `setInterval`, `sleep`, `delay`) to wait for asynchronous events. Real timers make tests slow, flaky, and prevent the process from exiting cleanly. + +- **For time-dependent SDK behaviour** (timeouts, retries, heartbeats): use fake timers that replace the SDK's timer API and can be advanced deterministically. +- **For waiting on async event delivery** (mock message propagation, promise settlement): yield to the event loop with a zero-delay mechanism like `setImmediate`, `process.nextTick`, or equivalent. Define a `flushAsync()` helper and use it everywhere instead of `setTimeout(resolve, N)`. +- **For "prove a negative" assertions** (confirming something did NOT happen): a single event-loop yield is sufficient — if the event hasn't fired after one pass through the macrotask queue, it won't fire from the current stimulus. + +The only acceptable use of a real timer is a **safety timeout on test execution** — a long deadline (e.g. 5 seconds) that fails the test if an expected event never arrives, preventing the test from hanging indefinitely. This is a test-level safeguard, not a delay mechanism. + +``` +// BAD: real timer delay +await new Promise(resolve => setTimeout(resolve, 50)); + +// GOOD: event-loop flush +await flushAsync(); + +// OK: safety timeout to prevent hanging +const timer = setTimeout(() => reject(new Error('Timed out')), 5000); +connection.once('connected', () => { clearTimeout(timer); resolve(); }); +``` + +### Cleanup with afterEach + +Always restore mocks in `afterEach`, not just at the end of each test. If a test throws before its cleanup code, the next test inherits dirty state. Use the SDK's mock restoration mechanism (e.g. `restoreAll()`) in an `afterEach` hook. + +The cleanup mechanism should cancel all SDK-internal timers, not just those reachable via the SDK's public API. Some SDKs have bugs where internal timers are orphaned (e.g. timer handles overwritten without cancelling the previous one). The test infrastructure should track all timer allocations and cancel any that survive `client.close()`. diff --git a/uts/.claude/skills/write-test-spec.md b/uts/docs/writing-test-specs.md similarity index 76% rename from uts/.claude/skills/write-test-spec.md rename to uts/docs/writing-test-specs.md index d5a05404d..1102181a8 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/docs/writing-test-specs.md @@ -1,12 +1,6 @@ ---- -skill: write-test-spec -description: Guidelines for writing Ably SDK test specifications with modern mock infrastructure patterns -tags: [testing, specifications, ably] ---- - # Writing Ably SDK Test Specifications -This skill provides comprehensive guidance for writing portable test specifications for Ably SDK implementations. +This guide provides comprehensive guidance for writing portable test specifications for Ably SDK implementations. ## Test Types @@ -20,13 +14,78 @@ This skill provides comprehensive guidance for writing portable test specificati ### Integration Tests (Ably Sandbox) - Run against `https://sandbox.realtime.ably-nonprod.net` - Provision apps via `POST /apps` with body from `ably-common/test-resources/test-app-setup.json` -- Use `endpoint: "sandbox"` in ClientOptions +- Use `endpoint: "nonprod:sandbox"` in ClientOptions +- **Protocol variants:** Data-path tests (publish, history, presence, etc.) must run with both JSON and msgpack. Add a `## Protocol Variants` section after `## Test Type` and use `useBinaryProtocol: PROTOCOL == "msgpack"` in ClientOptions. See `docs/integration-testing.md` for the full convention. Specs without this header default to JSON only. ### Proxy Integration Tests (Ably Sandbox via Proxy) -- Run against Ably Sandbox through a programmable proxy (`uts/test/proxy/`) +- Run against Ably Sandbox through a programmable proxy ([ably/uts-proxy](https://github.com/ably/uts-proxy)) - Proxy transparently forwards traffic but can inject faults via rules - Use for testing fault behaviour: connection failures, token renewal under errors, heartbeat starvation, channel error injection -- See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure spec +- See `realtime/integration/helpers/proxy.md` for the full proxy infrastructure spec + +## Test IDs + +Every test in the UTS suite has a unique identifier. The ID appears explicitly in the spec markdown and must be included as a comment in every derived (language-specific) implementation. + +### Format + +``` +//- +``` + +| Field | Description | +|-------|-------------| +| `category` | One of: `rest/unit`, `rest/integration`, `rest/proxy`, `realtime/unit`, `realtime/integration`, `realtime/proxy` | +| `spec-point` | The primary spec point being tested (e.g. `RSC15l2`, `RTN14a`) | +| `descriptive-name` | 2–4 hyphenated words describing the specific behaviour (e.g. `timeout-fallback`, `cloudfront-header`) | +| `n` | 0-based index disambiguating multiple tests for the same spec point within the same file | + +### Examples + +| Test ID | Meaning | +|---------|---------| +| `rest/unit/RSC15l/timeout-fallback-0` | REST unit test: first test for RSC15l covering timeout-triggered fallback | +| `rest/proxy/RSC15l4/cloudfront-fallback-0` | REST proxy test: CloudFront header triggers fallback | +| `realtime/unit/RTN14a/fatal-connect-error-0` | Realtime unit test: fatal error during connection | +| `realtime/proxy/RTN15a/disconnect-resume-0` | Realtime proxy test: unexpected disconnect triggers resume | +| `rest/integration/RSA8/request-token-0` | REST integration test: first requestToken test | + +### Placement in spec markdown + +Add a `**Test ID**` line immediately after the test heading: + +```markdown +## RSC15l2 - Request timeout triggers fallback via proxy + +**Test ID**: `rest/proxy/RSC15l2/timeout-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l2 | Request timeout triggers fallback | +``` + +### Placement in derived tests + +Add a `// UTS: ` comment immediately above the test function: + +```typescript +// UTS: rest/proxy/RSC15l2/timeout-fallback-0 +it('RSC15l2 - request timeout triggers fallback', async function () { +``` + +```dart +// UTS: rest/proxy/RSC15l2/timeout-fallback-0 +test('RSC15l2 - request timeout triggers fallback', () async { +``` + +### Naming guidelines + +- The descriptive name should make the test's purpose clear without needing to look up the spec point +- Use the most specific spec point when a test covers multiple (e.g. `RSC15l2` not `RSC15l`) +- For tests not tied to a specific spec point, use the closest applicable one +- Keep names consistent within a file — similar tests should have parallel names + +--- ## Mock Infrastructure Patterns @@ -36,7 +95,7 @@ This skill provides comprehensive guidance for writing portable test specificati ```markdown ## Mock HTTP Infrastructure -See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. +See `rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. ``` **Key interfaces:** @@ -166,7 +225,7 @@ For Realtime tests, reference the WebSocket mock: ```markdown ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. ``` **Key interfaces:** @@ -237,7 +296,7 @@ mock_ws.active_connection.simulate_disconnect() ## Proxy Integration Tests -For detailed proxy infrastructure documentation, see `uts/test/realtime/integration/helpers/proxy.md`. +For detailed proxy infrastructure documentation, see `realtime/integration/helpers/proxy.md`. ### When to Use Proxy Tests @@ -258,10 +317,10 @@ Spec points: `RTN14a`, `RTN14b`, ... Proxy integration test against Ably Sandbox endpoint ## Proxy Infrastructure -See `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure specification. +See `realtime/integration/helpers/proxy.md` for proxy infrastructure specification. ## Corresponding Unit Tests -- `uts/test/realtime/unit/connection/connection_failures_test.md` — RTN15a, RTN15b +- `realtime/unit/connection/connection_failures_test.md` — RTN15a, RTN15b ## Sandbox Setup [standard app provisioning — same as direct sandbox tests] @@ -270,6 +329,8 @@ See `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure sp ## RTN14a - Test name +**Test ID**: `realtime/proxy/RTN14a/fatal-connect-error-0` + | Spec | Requirement | |------|-------------| | RTN14a | ... | @@ -282,7 +343,7 @@ Tests that [behaviour] when the proxy injects [fault]. ```pseudo session = create_proxy_session( - target: TargetConfig(realtimeHost: "sandbox-realtime.ably.io", restHost: "sandbox-rest.ably.io"), + target: TargetConfig(realtimeHost: "sandbox.realtime.ably-nonprod.net", restHost: "sandbox.realtime.ably-nonprod.net"), port: allocated_port, rules: [{ "match": { ... }, @@ -450,7 +511,7 @@ Tests that all REST requests include the `Ably-Agent` header with correct format ### URI Path Component Encoding -Use `encode_uri_component()` for any variable path segment or query parameter in URL assertions. This is defined in `uts/test/README.md`. Always use exact equality (`==`) for path assertions, not `CONTAINS`. +Use `encode_uri_component()` for any variable path segment or query parameter in URL assertions. This is defined in the UTS README. Always use exact equality (`==`) for path assertions, not `CONTAINS`. ```pseudo # Correct — exact path with encoded variable @@ -523,7 +584,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connecting ``` This means implementations should: -- Check if condition is already true → proceed +- Check if condition is already true -> proceed - Otherwise wait for state change events with timeout - Fail if timeout expires @@ -695,7 +756,7 @@ AWAIT_STATE state == disconnected **The pattern:** 1. Start recording state changes before triggering the behavior -2. Let the full cycle play out (disconnect → reconnect) +2. Let the full cycle play out (disconnect -> reconnect) 3. Assert the recorded sequence at the end with `CONTAINS_IN_ORDER` ```pseudo @@ -916,73 +977,70 @@ The error object in `FAILS WITH error` represents the ErrorInfo associated with ## File Organization ``` -uts/test/ -├── rest/ -│ ├── unit/ -│ │ ├── helpers/ -│ │ │ └── mock_http.md # Mock HTTP infrastructure spec -│ │ ├── auth/ -│ │ │ ├── auth_callback.md # RSA8c, RSA8d -│ │ │ ├── auth_scheme.md # RSA1-4, RSA4b -│ │ │ ├── authorize.md # RSA10 -│ │ │ ├── token_renewal.md # RSA4b4, RSA14 -│ │ │ └── client_id.md # RSA7, RSC17 -│ │ ├── channel/ -│ │ │ ├── publish.md # RSL1 -│ │ │ ├── history.md # RSL2 -│ │ │ └── idempotency.md # RSL1k -│ │ ├── rest_client.md # RSC7, RSC8, RSC13, RSC18 -│ │ ├── fallback.md # RSC15, REC1, REC2 -│ │ ├── time.md # RSC16 -│ │ ├── stats.md # RSC6 -│ │ ├── request.md # RSC19 -│ │ ├── batch_publish.md # RSC22, BSP, BPR, BPF -│ │ ├── presence/ -│ │ │ └── rest_presence.md # RSP1, RSP3, RSP4 -│ │ ├── encoding/ -│ │ │ └── message_encoding.md # RSL4, RSL5, RSL6 -│ │ └── types/ -│ │ ├── message_types.md # TM2, TM3, TM4 -│ │ ├── error_types.md # TI1-5 -│ │ ├── token_types.md # TD1-5, TK1-6, TE1-6 -│ │ ├── options_types.md # TO3, AO2 -│ │ └── paginated_result.md # TG1-5 -│ └── integration/ -│ ├── auth.md -│ ├── publish.md -│ ├── history.md -│ ├── presence.md -│ ├── pagination.md -│ └── time_stats.md -├── realtime/ -│ ├── unit/ -│ │ ├── helpers/ -│ │ │ └── mock_websocket.md # Mock WebSocket infrastructure spec -│ │ ├── client/ -│ │ │ ├── realtime_client.md # RTC1, RTC2, RTC15, RTC16 -│ │ │ └── client_options.md # TO3 (Realtime-specific) -│ │ └── connection/ -│ │ ├── connection_failures_test.md -│ │ ├── connection_open_failures_test.md -│ │ └── ... -│ └── integration/ -│ ├── helpers/ -│ │ └── proxy.md # Proxy infrastructure spec -│ ├── proxy/ -│ │ ├── connection_open_failures.md # RTN14 tests via proxy -│ │ ├── connection_resume.md # RTN15 tests via proxy -│ │ ├── heartbeat.md # RTN23 tests via proxy -│ │ ├── channel_faults.md # RTL4, RTL5, RTL13, RTL14 via proxy -│ │ ├── rest_faults.md # RSC10, RSC15 via proxy -│ │ └── end_to_end.md # RTL6 publish + history via proxy -│ ├── connection_lifecycle_test.md # Direct sandbox tests -│ └── ... -└── README.md +rest/ + unit/ + helpers/ + mock_http.md # Mock HTTP infrastructure spec + auth/ + auth_callback.md # RSA8c, RSA8d + auth_scheme.md # RSA1-4, RSA4b + authorize.md # RSA10 + token_renewal.md # RSA4b4, RSA14 + client_id.md # RSA7, RSC17 + channel/ + publish.md # RSL1 + history.md # RSL2 + idempotency.md # RSL1k + rest_client.md # RSC7, RSC8, RSC13, RSC18 + fallback.md # RSC15, REC1, REC2 + time.md # RSC16 + stats.md # RSC6 + request.md # RSC19 + batch_publish.md # RSC22, BSP, BPR, BPF + presence/ + rest_presence.md # RSP1, RSP3, RSP4 + encoding/ + message_encoding.md # RSL4, RSL5, RSL6 + types/ + message_types.md # TM2, TM3, TM4 + error_types.md # TI1-5 + token_types.md # TD1-5, TK1-6, TE1-6 + options_types.md # TO3, AO2 + paginated_result.md # TG1-5 + integration/ + auth.md + publish.md + history.md + presence.md + pagination.md + time_stats.md +realtime/ + unit/ + helpers/ + mock_websocket.md # Mock WebSocket infrastructure spec + client/ + realtime_client.md # RTC1, RTC2, RTC15, RTC16 + client_options.md # TO3 (Realtime-specific) + connection/ + connection_failures_test.md + connection_open_failures_test.md + ... + integration/ + helpers/ + proxy.md # Proxy infrastructure spec + proxy/ + connection_open_failures.md # RTN14 tests via proxy + connection_resume.md # RTN15 tests via proxy + heartbeat.md # RTN23 tests via proxy + channel_faults.md # RTL4, RTL5, RTL13, RTL14 via proxy + rest_faults.md # RSC10, RSC15 via proxy + connection_lifecycle_test.md # Direct sandbox tests + ... ``` ## Completion Status Matrix -When adding a new test spec, update the completion status matrix at `uts/test/completion-status.md` to reflect the newly covered spec items. This matrix tracks which spec items have UTS test specs and which do not. +When adding a new test spec, update the completion status matrix at `docs/completion-status.md` to reflect the newly covered spec items. This matrix tracks which spec items have UTS test specs and which do not. ## Writing Tips @@ -998,7 +1056,7 @@ When adding a new test spec, update the completion status matrix at `uts/test/co 10. **Use handler pattern for simple tests**, await pattern for complex coordination 11. **Distinguish connection-level vs request-level failures** 12. **Use unique channel names** to avoid test interference -13. **Update `uts/test/completion-status.md`** when adding new test specs +13. **Update `docs/completion-status.md`** when adding new test specs ## Example Test Spec (Modern Pattern) @@ -1012,12 +1070,14 @@ 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. +See `rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. --- ## RSA4 - Descriptive test name +**Test ID**: `rest/unit/RSA4/auth-selection-0` + **Spec requirement:** Brief description of what the spec requires. Tests that [specific behavior being tested]. @@ -1066,66 +1126,29 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null ## Common Mistakes to Avoid -1. ❌ Using `mock_http.queue_response()` (old pattern) - ✅ Use `onRequest: (req) => req.respond_with(...)` - -2. ❌ Referencing `mock_http.captured_requests` - ✅ Use local `captured_requests` array - -3. ❌ Referencing `mock_http.request_count` - ✅ Use local `request_count` variable - -4. ❌ Not installing mock: Missing `install_mock(mock_http)` - ✅ Always call `install_mock(mock_http)` after creating mock - -5. ❌ Passing mock to client: `Rest(..., httpClient: mock_http)` - ✅ Mock is installed globally via `install_mock()` - -6. ❌ Missing spec requirement summary - ✅ Every test must have `**Spec requirement:**` or table - -7. ❌ Using fixed WAITs for async operations - ✅ Use polling with timeout or `AWAIT_STATE` - -8. ❌ Not using unique channel names - ✅ Generate unique names with random component - -9. ❌ Synchronous state assertions: `ASSERT state == connecting` - ✅ Use `AWAIT_STATE state == connecting` - -10. ❌ Missing connection handler: Only defining `onRequest` - ✅ Always include `onConnectionAttempt: (conn) => conn.respond_with_success()` - -11. ❌ Using `send_to_client()` for DISCONNECTED or connection-level ERROR - ✅ Use `send_to_client_and_close()` - server closes connection after these messages - -12. ❌ Using `send_to_client_and_close()` for channel-level ERROR - ✅ Use `send_to_client()` - ERROR with channel doesn't close connection - -13. ❌ Using `time()` to test authentication behavior - ✅ Use `channel.status()` - time() doesn't require or send auth - -14. ❌ Creating client without credentials for time() tests: `ClientOptions(tls: false)` - ✅ Constructor requires credentials - use `ClientOptions(key: "...", tls: false, useTokenAuth: true)` - -15. ❌ Using intermediate `AWAIT_STATE disconnected` to observe transient states mid-test - ✅ Record all state changes and use `CONTAINS_IN_ORDER` to verify the sequence at the end - -16. ❌ Using exact `ADVANCE_TIME` calculations for multi-retry scenarios: `ADVANCE_TIME(6000); ADVANCE_TIME(1000)` - ✅ Use a time-advancement loop: `LOOP up to N times: ADVANCE_TIME(increment)` - -17. ❌ Loose path assertions: `ASSERT request.url.path CONTAINS "/channels/"` - ✅ Exact path with encoding: `ASSERT request.url.path == "/channels/" + encode_uri_component(name) + "/messages"` - -18. ❌ Mock echo missing fields that the test later asserts on (e.g. omitting `data` from a PRESENCE echo, then asserting `member.data`) - ✅ Include all fields in the mock echo that the test assertions depend on - -19. ❌ Using language-specific serialization names: `toMap()`, `fromMap()`, `to_dict()` - ✅ Use portable `toJson()` / `fromJson()` for wire format serialization - -### Keeping UTS and Dart Tests in Sync - -When a Dart test reveals a bug or gap in a UTS spec (or vice versa), **always update both**. Common cases: -- Mock missing a field (e.g. `data: p.data` in a PRESENCE echo) — fix in both -- Loop index bugs (e.g. hardcoded `:0` instead of `:${idx}`) — fix in both -- Dart-specific patterns (e.g. `authCallback` to avoid real HTTP for clientId) don't need UTS changes, but note the reason if the approaches differ significantly +1. Using `mock_http.queue_response()` (old pattern) -- Use `onRequest: (req) => req.respond_with(...)` instead +2. Referencing `mock_http.captured_requests` -- Use local `captured_requests` array +3. Referencing `mock_http.request_count` -- Use local `request_count` variable +4. Not installing mock: Missing `install_mock(mock_http)` -- Always call `install_mock(mock_http)` after creating mock +5. Passing mock to client: `Rest(..., httpClient: mock_http)` -- Mock is installed globally via `install_mock()` +6. Missing spec requirement summary -- Every test must have `**Spec requirement:**` or table +7. Using fixed WAITs for async operations -- Use polling with timeout or `AWAIT_STATE` +8. Not using unique channel names -- Generate unique names with random component +9. Synchronous state assertions: `ASSERT state == connecting` -- Use `AWAIT_STATE state == connecting` +10. Missing connection handler: Only defining `onRequest` -- Always include `onConnectionAttempt: (conn) => conn.respond_with_success()` +11. Using `send_to_client()` for DISCONNECTED or connection-level ERROR -- Use `send_to_client_and_close()` - server closes connection after these messages +12. Using `send_to_client_and_close()` for channel-level ERROR -- Use `send_to_client()` - ERROR with channel doesn't close connection +13. Using `time()` to test authentication behavior -- Use `channel.status()` - time() doesn't require or send auth +14. Creating client without credentials for time() tests: `ClientOptions(tls: false)` -- Constructor requires credentials +15. Using intermediate `AWAIT_STATE disconnected` to observe transient states mid-test -- Record all state changes and use `CONTAINS_IN_ORDER` to verify the sequence at the end +16. Using exact `ADVANCE_TIME` calculations for multi-retry scenarios -- Use a time-advancement loop +17. Loose path assertions: `ASSERT request.url.path CONTAINS "/channels/"` -- Use exact path with encoding +18. Mock echo missing fields that the test later asserts on -- Include all fields in the mock echo that the test assertions depend on +19. Using language-specific serialization names: `toMap()`, `fromMap()`, `to_dict()` -- Use portable `toJson()` / `fromJson()` + +### Keeping UTS and Derived Tests in Sync + +When a derived test reveals a bug or gap in a UTS spec (or vice versa), **always update both**. Common cases: +- Mock missing a field (e.g. `data: p.data` in a PRESENCE echo) -- fix in both +- Loop index bugs (e.g. hardcoded `:0` instead of `:${idx}`) -- fix in both +- Language-specific patterns (e.g. `authCallback` to avoid real HTTP for clientId) don't need UTS changes, but note the reason if the approaches differ significantly diff --git a/uts/integration-testing.md b/uts/integration-testing.md deleted file mode 100644 index 3df872333..000000000 --- a/uts/integration-testing.md +++ /dev/null @@ -1,235 +0,0 @@ -# Integration Testing Policy - -This document defines the policy for integration tests in the UTS test suite. It covers what to test, how tests are organised, and the distinction between direct sandbox tests and proxy-based tests. - -## Relationship to Unit Tests - -Unit tests use mocked transports (MockWebSocket, MockHttpClient) to verify client-side logic: state machines, request formation, response parsing, timer behaviour, error handling. They are fast and deterministic. - -Integration tests verify that the SDK interoperates correctly with the real Ably service. They run against the Ably sandbox and exercise the actual network path. - -**Integration tests do not replace unit tests.** Every spec point that has an integration test should also have a unit test. The integration test adds confidence that the mocked behaviour in the unit test matches reality. - -## What to Test - -Integration tests should cover spec points where correctness depends on agreement between client and server. Not every spec point needs an integration test — only those where a unit test alone leaves meaningful doubt. - -### Selection Criteria - -Choose spec points for integration testing when they fall into one or more of these categories: - -#### 1. Request/Response Shape Interop - -The SDK constructs a request (HTTP or protocol message) and the server must accept it, or the server sends a response and the SDK must parse it correctly. - -Examples: -- Auth token obtained via `createTokenRequest` is accepted by the server (RSA9) -- WebSocket connection URL parameters are accepted (RTN2) -- Channel attach/detach protocol messages round-trip correctly (RTL4, RTL13) -- Publish with various data types round-trips through the server (RSL1, RTL6) - -#### 2. Error Response Interop - -The server rejects invalid requests with specific error codes, and the SDK must surface those errors correctly. - -Examples: -- Invalid API key produces the correct error code and state transition (RTN14b) -- Token expiry triggers renewal flow (RSA4b) -- Insufficient capability produces channel FAILED (RTL4e) - -#### 3. Data Encoding Round-Trips - -Data passes through the SDK's encoding layer, through the server, and back. The round-trip must preserve data integrity. - -Examples: -- String, binary, and JSON data types are preserved through publish/subscribe (RSL4, RSL6) -- Presence data encoding round-trips (RTP8) -- Message extras survive the round-trip - -#### 4. Stateful Protocol Sequences - -Multi-step interactions where the server's state machine and the client's state machine must agree. - -Examples: -- Connection resume after disconnect (RTN15) — proxy required -- Presence SYNC protocol (RTP2) — server-initiated, can't be mocked faithfully -- Channel reattach after server-initiated detach (RTL13) — proxy required -- Heartbeat timeout detection (RTN23) — proxy required to starve heartbeats - -### What NOT to Test - -Do not write integration tests for: -- Pure client-side logic (option parsing, state machine transitions that don't depend on server responses) -- Behaviour that is fully exercised by unit tests with high confidence (e.g. event emitter semantics, channel name validation) -- Timing-sensitive retry logic where the integration test would be flaky without the proxy -- Features that require server-side configuration not available in the sandbox - -## Directory Structure - -Integration test specs are organised to mirror the unit test structure: - -``` -realtime/ - unit/ # Unit tests (mock transport) - auth/ - connection_auth_test.md - realtime_authorize_test.md - channels/ - channel_attach_test.md - channel_publish_test.md - ... - connection/ - auto_connect_test.md - connection_failures_test.md - ... - presence/ - realtime_presence_enter_test.md - ... - integration/ # Direct sandbox tests (no proxy) - auth/ - connection_auth_test.md - realtime_authorize_test.md - channels/ - channel_attach_test.md - channel_publish_test.md - ... - connection/ - connection_lifecycle_test.md - ... - presence/ - presence_lifecycle_test.md - ... - integration-proxy/ # Proxy-based tests (sandbox + proxy) - connection/ - connection_resume_test.md - connection_failures_test.md - heartbeat_test.md - channels/ - channel_faults_test.md - ... -``` - -### Segregation Rationale - -Tests that require the proxy are segregated into `integration-proxy/` because: - -1. **Different infrastructure requirements** — proxy tests need the proxy binary running, port allocation, and proxy session lifecycle management. Direct sandbox tests need only network access to the sandbox. -2. **Different CI configuration** — proxy tests can run on a different schedule or be gated on proxy availability, without affecting direct integration tests. -3. **Different failure modes** — proxy test failures may indicate proxy bugs, port conflicts, or proxy/SDK version mismatches, not just SDK issues. -4. **Clear authoring signal** — when writing a test, the file location encodes whether the proxy is needed. No conditional skip logic inside test files. - -### Shared Spec Points - -A single spec point may have tests in multiple tiers. For example, RTN15 (connection resume): - -- `unit/connection/connection_failures_test.md` — mock transport verifies client-side state transitions and retry logic -- `integration-proxy/connection/connection_resume_test.md` — proxy verifies the resume protocol works against the real server - -This is expected and correct. The unit test verifies client logic; the integration test verifies client-server agreement. - -## Test Structure Conventions - -### Sandbox Setup - -Every integration test file includes the standard sandbox provisioning: - -```pseudo -BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps - WITH body from ably-common/test-resources/test-app-setup.json - - app_config = parse_json(response.body) - api_key = app_config.keys[0].key_str - app_id = app_config.app_id - -AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} - WITH Authorization: Basic {api_key} -``` - -### Proxy Setup (integration-proxy only) - -Proxy tests additionally set up a proxy session per test or group of tests. See `realtime/integration/helpers/proxy.md` for the proxy infrastructure API. - -```pseudo -BEFORE EACH TEST: - session = create_proxy_session( - endpoint: "sandbox", - port: allocated_port, - rules: [ ...initial rules... ] - ) - -AFTER EACH TEST: - session.close() -``` - -### Client Options - -Integration test clients use: - -```pseudo -client = Realtime(options: ClientOptions( - key: api_key, - endpoint: "sandbox", # Direct sandbox tests - useBinaryProtocol: false, - autoConnect: false -)) -``` - -Proxy test clients use: - -```pseudo -client = Realtime(options: ClientOptions( - key: api_key, - endpoint: "localhost", - port: session.proxy_port, - tls: false, - useBinaryProtocol: false, - autoConnect: false -)) -``` - -### Channel Names - -Channel names must be unique per test to avoid cross-test interference: - -```pseudo -channel_name = "test-RTL4-attach-${base64(random_bytes(6))}" -``` - -### Spec Point References - -Each test section references the spec points it covers, just like unit tests: - -```pseudo -## RTN4b - Successful connection establishment - -| Spec | Requirement | -|------|-------------| -| RTN4b | Connection transitions INITIALIZED → CONNECTING → CONNECTED | -``` - -### Avoiding Flaky Tests - -- Use polling with timeouts instead of fixed waits (see `README.md` polling conventions) -- For token expiry tests, use short TTLs and poll for rejection -- For state transition assertions, wait for the target state event rather than asserting after a delay -- Proxy tests should use proxy event logs for verification rather than timing-dependent assertions - -## Coverage Tracking - -Integration test coverage is tracked in `completion-status.md` alongside unit test coverage. Each spec point entry indicates which tiers have coverage: - -``` -RTN4b unit:✓ integration:✓ -RTN15a unit:✓ integration-proxy:✓ -RTL4 unit:✓ integration:✓ -``` - -## Adding New Integration Tests - -1. **Check whether an integration test adds value** — apply the selection criteria above. If the unit test already provides high confidence, skip the integration test. -2. **Choose the right tier** — if the test needs fault injection (dropped connections, delayed frames, modified responses), it goes in `integration-proxy/`. Otherwise, `integration/`. -3. **Mirror the unit test structure** — use the same category directory and a similar file name. -4. **Write the UTS spec first** — just like unit tests, the portable test spec comes before the language-specific implementation. -5. **Reference spec points** — every test section must cite the spec points it covers. diff --git a/uts/proxy/IMPLEMENTATION.md b/uts/proxy/IMPLEMENTATION.md deleted file mode 100644 index 02a266181..000000000 --- a/uts/proxy/IMPLEMENTATION.md +++ /dev/null @@ -1,710 +0,0 @@ -# Ably Test Proxy — Go Implementation Plan - -## Project structure - -``` -uts/test/proxy/ -├── PROPOSAL.md # API and design proposal -├── IMPLEMENTATION.md # This file -├── go.mod # module: ably.io/test-proxy -├── go.sum -├── main.go # Entry point, flag parsing, server startup -├── server.go # Control API HTTP server, routing -├── session.go # Session lifecycle (create, get, delete, timeout) -├── session_store.go # Thread-safe session storage -├── rule.go # Rule types, matching logic, action types -├── ws_proxy.go # WebSocket proxy (client↔server frame relay) -├── http_proxy.go # HTTP reverse proxy with rule interception -├── protocol.go # Ably protocol message parsing (JSON + msgpack) -├── log.go # Event log (per-session traffic capture) -├── action.go # Imperative action dispatch -├── listener.go # Per-session TCP listener management -└── proxy_test.go # Tests -``` - -## Design decisions - -1. **Port-per-session routing.** The SDK constructs URLs with standard paths (`/channels/...`, `/?key=...`). It cannot prepend a session path prefix. Therefore, each session gets its own TCP listener on a dedicated port. The test process assigns a port from a pool (e.g., 10000–11023, 1024 ports) and passes it in the session creation request. The proxy binds that port and maps all traffic on it to the session. The SDK connects to `ws://localhost:{sessionPort}/...` and `http://localhost:{sessionPort}/...` with normal paths. - -2. **No TLS between client and proxy.** The proxy serves plain HTTP/WS to the SDK. Upstream connections to the Ably sandbox use TLS (`wss://`, `https://`). The SDK is configured with `tls: false`. - -3. **Msgpack support.** The proxy decodes both JSON (text frames) and msgpack (binary frames) for rule matching. Go has good msgpack libraries. Both formats are decoded into the same `ProtocolMessage` struct for matching, then the original raw bytes are forwarded unchanged (the proxy never re-encodes). - -## Dependencies - -| Package | Purpose | -|---------|---------| -| `github.com/gorilla/websocket` | WebSocket client and server | -| `github.com/vmihailenco/msgpack/v5` | Msgpack decoding for binary protocol frames | -| `net/http` | Control API server | -| `net/http/httputil` | `ReverseProxy` for HTTP passthrough | -| `encoding/json` | JSON protocol message parsing, API request/response | -| `sync` | Mutex for session store and rule list | -| `net` | TCP listener management for per-session ports | - -## Phases - -### Phase 1: Skeleton and control API - -Build the control API HTTP server, session CRUD, per-session listener management, and health check. No proxying yet. - -**Files:** `main.go`, `server.go`, `session.go`, `session_store.go`, `rule.go`, `log.go`, `listener.go` - -#### `main.go` - -- Parse `--control-port` flag (default 9100) -- Start the control API HTTP server on the control port -- Handle SIGINT/SIGTERM for graceful shutdown (close all session listeners) - -#### `server.go` - -Control API mux routing: - -| Method | Path | Handler | -|--------|------|---------| -| `GET` | `/health` | Return `{"ok": true}` | -| `POST` | `/sessions` | Create session | -| `GET` | `/sessions/{id}` | Get session metadata | -| `POST` | `/sessions/{id}/rules` | Add rules | -| `POST` | `/sessions/{id}/actions` | Trigger imperative action | -| `GET` | `/sessions/{id}/log` | Get event log | -| `DELETE` | `/sessions/{id}` | Teardown session | - -Use `net/http.ServeMux` (Go 1.22 has method-aware routing with `{id}` wildcards). No framework needed. - -#### `listener.go` - -Per-session TCP listener management: - -```go -// StartSessionListener binds to the given port, starts an HTTP server -// that routes all WS and HTTP traffic to the session's handlers. -// Returns an error immediately if the port cannot be bound. -func StartSessionListener(session *Session, port int) error - -// StopSessionListener closes the listener and shuts down the HTTP server. -func StopSessionListener(session *Session) -``` - -On session creation: -1. Caller provides `port` in the request body -2. Proxy calls `net.Listen("tcp", fmt.Sprintf(":%d", port))` -3. If listen fails (port in use, permission denied), return HTTP 409 with error message -4. Start `http.Serve` on the listener in a goroutine -5. The per-session HTTP server routes: - - WebSocket upgrade requests → `ws_proxy` handler - - All other HTTP requests → `http_proxy` handler - -On session deletion: -1. Close the TCP listener (stops accepting new connections) -2. Close all active WS connections -3. Shut down the per-session HTTP server - -#### `session.go` - -```go -type CreateSessionRequest struct { - Target TargetConfig `json:"target"` - Rules []Rule `json:"rules,omitempty"` - TimeoutMs int `json:"timeoutMs,omitempty"` // default 30000 - Port int `json:"port"` // required — caller-assigned -} - -type CreateSessionResponse struct { - SessionID string `json:"sessionId"` - Proxy ProxyConfig `json:"proxy"` -} - -type ProxyConfig struct { - Host string `json:"host"` // "localhost:{port}" - Port int `json:"port"` -} -``` - -Session creation flow: -1. Validate request (port is required, target has at least one host) -2. Generate session ID (random 8-char hex) -3. Create `Session` struct with rules, empty event log -4. Attempt to bind the requested port — fail fast with 409 if it can't -5. Start the per-session HTTP server -6. Start timeout timer (`time.AfterFunc`) -7. Store session in `SessionStore` -8. Return session ID and proxy host/port - -#### `session_store.go` - -Thread-safe `map[string]*Session` with `sync.RWMutex`. - -```go -type SessionStore struct { - sessions map[string]*Session - mu sync.RWMutex -} - -func (s *SessionStore) Create(session *Session) error -func (s *SessionStore) Get(id string) (*Session, bool) -func (s *SessionStore) Delete(id string) (*Session, bool) -func (s *SessionStore) All() []*Session -``` - -#### `rule.go` - -Rule, MatchConfig, ActionConfig structs with JSON tags. Matching logic is a method on Session: - -```go -// FindMatchingRule iterates rules in order and returns the first match. -// Returns nil if no rule matches (passthrough). -func (s *Session) FindMatchingRule(event MatchEvent) *Rule -``` - -`MatchEvent` is a tagged union representing the thing being matched: - -```go -type MatchEvent struct { - Type string // "ws_connect", "ws_frame_to_server", "ws_frame_to_client", "http_request" - Action string // protocol message action name (for frame matches) - Channel string // protocol message channel (for frame matches) - Method string // HTTP method - Path string // HTTP request path - QueryParams map[string]string // WS connection query params -} -``` - -#### `log.go` - -Append-only event log with mutex. - -```go -type EventLog struct { - events []Event - mu sync.Mutex -} - -func (l *EventLog) Append(event Event) -func (l *EventLog) Events() []Event // returns a copy -``` - -### Phase 2: WebSocket proxy — passthrough - -Implement transparent WebSocket proxying with no rules applied. - -**Files:** `ws_proxy.go`, `protocol.go` - -#### WebSocket proxy flow - -1. Client connects to `ws://localhost:{sessionPort}/?key=...&heartbeats=true&...` -2. Per-session HTTP server detects WebSocket upgrade, hands off to `WsProxyHandler` -3. Increment `session.WsConnectCount` -4. Log `ws_connect` event with URL and query params -5. Build upstream URL: `wss://{target.realtimeHost}/?key=...&heartbeats=true&...` - - Copy all query params from client request - - Scheme is always `wss` (TLS to upstream) -6. Dial upstream WebSocket -7. If dial fails, return error to client (502) -8. Accept the client WebSocket upgrade -9. Start two goroutines: - - **client→server relay**: `readFromClient()` → log → `writeToServer()` - - **server→client relay**: `readFromServer()` → log → `writeToClient()` -10. When either side closes or errors, close the other side -11. Log `ws_disconnect` event - -#### `protocol.go` - -Parse protocol messages for rule matching. Support both JSON and msgpack. - -```go -// ParseProtocolMessage attempts to decode a WebSocket frame into a ProtocolMessage. -// For text frames, parses as JSON. -// For binary frames, parses as msgpack. -// Returns the parsed message and nil error on success. -// On parse failure, returns a zero ProtocolMessage and error (frame is still forwarded). -func ParseProtocolMessage(data []byte, messageType int) (ProtocolMessage, error) -``` - -The `ProtocolMessage` struct: - -```go -type ProtocolMessage struct { - Action int // numeric action code (always normalized to int) - Channel string - Error *ErrorInfo -} -``` - -Action name↔number mapping (subset needed for matching): - -| Name | Number | -|------|--------| -| HEARTBEAT | 0 | -| ACK | 1 | -| NACK | 2 | -| CONNECT | 3 | -| CONNECTED | 4 | -| DISCONNECT | 5 | -| DISCONNECTED | 6 | -| CLOSE | 7 | -| CLOSED | 8 | -| ERROR | 9 | -| ATTACH | 10 | -| ATTACHED | 11 | -| DETACH | 12 | -| DETACHED | 13 | -| PRESENCE | 14 | -| MESSAGE | 15 | -| SYNC | 16 | -| AUTH | 17 | - -Rule matching accepts either name (`"ATTACH"`) or number (`10`) in the match config. Internally normalized to int. - -**Msgpack decoding:** - -Ably msgpack protocol messages are arrays where the first element is the action number. Use `github.com/vmihailenco/msgpack/v5` to decode into a `[]interface{}` and extract the action and channel fields by position. The field positions follow the Ably protocol: - -| Index | Field | -|-------|-------| -| 0 | action | -| 1 | channel | -| ... | (other fields — not needed for matching) | - -Alternatively, decode as a map if the server uses map encoding. Try map first, fall back to array. Log a warning if neither works but still forward the raw frame. - -#### Connection tracking - -```go -type WsConnection struct { - ClientConn *websocket.Conn - ServerConn *websocket.Conn - ConnNumber int // which connection attempt this is (1-based) - timers []*time.Timer // for delay_after_ws_connect cleanup - mu sync.Mutex -} -``` - -Session tracks `activeWsConn *WsConnection` (most recent). When a new WS connection arrives, any previous one should already be closed (the SDK doesn't multiplex WS connections). But track it as a list for safety. - -### Phase 3: WebSocket proxy — rule matching - -Apply rules to WebSocket frames and connection events. - -**Files:** `ws_proxy.go`, `rule.go` - -#### Rule evaluation points - -**On WS connection attempt** (before dialing upstream): -1. Build `MatchEvent{Type: "ws_connect", QueryParams: ...}` -2. Find matching rule -3. If rule action is `refuse_connection`: return HTTP 502 to client, don't dial upstream -4. If rule action is `accept_and_close`: accept WS upgrade, send close frame, don't dial upstream -5. Otherwise: proceed to dial upstream - -**On frame from client** (before forwarding to server): -1. Parse protocol message -2. Build `MatchEvent{Type: "ws_frame_to_server", Action: ..., Channel: ...}` -3. Check `session.suppressClientToServer` flag — if set, drop frame -4. Find matching rule -5. Execute action (suppress, delay, replace, etc.) -6. If no rule matched: forward frame - -**On frame from server** (before forwarding to client): -1. Parse protocol message -2. Build `MatchEvent{Type: "ws_frame_to_client", Action: ..., Channel: ...}` -3. Check `session.suppressServerToClient` flag — if set, drop frame -4. Find matching rule -5. Execute action -6. If no rule matched: forward frame - -#### Count tracking - -The `count` match field means "only match the Nth occurrence of this event type." Counters are per-session: - -- `session.wsConnectCount` — incremented on each WS connection attempt -- `session.wsFrameToServerCount` — incremented on each frame from client -- `session.wsFrameToClientCount` — incremented on each frame from server - -A rule with `count: 2` matches when the counter equals 2 at evaluation time. - -Optionally, counters can be scoped per-action (e.g., "the 2nd ATTACH frame"). Implementation: the rule's `fired` counter tracks how many times the rule's match condition has been checked against a matching event. If `count` is set, the rule only fires when `fired + 1 == count`. - -**Simpler approach (recommended):** `count` is a per-rule occurrence counter. The rule tracks how many times its match condition (type + action + channel) has been satisfied. It only fires when that count equals the specified value. This is more intuitive: `{ "type": "ws_connect", "count": 2 }` means "the 2nd connection attempt that would otherwise match this rule." - -#### `times` handling - -```go -func (s *Session) FireRule(rule *Rule) { - rule.fired++ - if rule.Times > 0 && rule.fired >= rule.Times { - s.removeRule(rule) - } -} -``` - -### Phase 4: HTTP proxy — passthrough and rules - -Implement HTTP reverse proxying for REST API calls. - -**Files:** `http_proxy.go` - -#### HTTP proxy flow - -1. Client sends HTTP request to `http://localhost:{sessionPort}/channels/test/messages` -2. Per-session HTTP server routes non-WebSocket requests to `HttpProxyHandler` -3. Increment `session.HttpReqCount` -4. Log `http_request` event -5. Build `MatchEvent{Type: "http_request", Method: ..., Path: ...}` -6. Find matching rule -7. Execute action: - - `passthrough` (or no match): forward to upstream - - `http_respond`: return specified response immediately - - `http_delay`: sleep then forward - - `http_drop`: hijack connection and close - - `http_replace_response`: forward, discard response, return specified response -8. If forwarding: use `httputil.ReverseProxy` with upstream `https://{target.restHost}` -9. Log `http_response` event - -#### Forwarding details - -- Copy all request headers, body, query params -- Set `Host` header to target host -- Scheme is `https` (TLS to upstream) -- Response headers and body are copied back to client -- Content-Type, status code, etc. are preserved - -#### HTTP count tracking - -`session.httpReqCount` increments on each request. Per-rule `count` matching works the same as for WS: per-rule occurrence counter. - -### Phase 5: Imperative actions - -Implement `POST /sessions/{id}/actions`. - -**Files:** `action.go` - -```go -type ActionRequest struct { - Type string `json:"type"` - Message json.RawMessage `json:"message,omitempty"` - CloseCode int `json:"closeCode,omitempty"` -} -``` - -Handler: -1. Parse request body -2. Find session -3. Find active WS connection(s) -4. Execute action on the connection: - - `disconnect`: `conn.ClientConn.UnderlyingConn().Close()` (raw TCP close) - - `close`: `conn.ClientConn.WriteMessage(websocket.CloseMessage, ...)` - - `inject_to_client`: `conn.ClientConn.WriteMessage(websocket.TextMessage, message)` - - `inject_to_client_and_close`: write message then close -5. Log the action as an event -6. Return 200 OK (or 404/409 on errors) - -### Phase 6: Temporal triggers - -Implement `delay_after_ws_connect` match type. - -**Files:** `ws_proxy.go` - -After upstream WS connection is established: - -1. Lock session mutex -2. Iterate rules looking for `delay_after_ws_connect` type -3. For each matching rule, schedule `time.AfterFunc`: - ```go - timer := time.AfterFunc(time.Duration(rule.Match.DelayMs)*time.Millisecond, func() { - executeAction(session, wsConn, rule.Action) - session.FireRule(rule) - }) - wsConn.timers = append(wsConn.timers, timer) - ``` -4. On WS connection close, cancel all pending timers: - ```go - for _, t := range wsConn.timers { - t.Stop() - } - ``` -5. On session delete, cancel all timers on all connections - -### Phase 7: Tests - -**Files:** `proxy_test.go` - -Test infrastructure: each test starts a local mock upstream server (HTTP + WS echo/scripted), creates the proxy, creates a session pointing at the mock upstream, and connects a client through the proxy. - -```go -// Helper: start a mock upstream WS server that sends CONNECTED then echoes frames -func startMockUpstream(t *testing.T) (wsURL string, httpURL string, cleanup func()) - -// Helper: start the proxy control API -func startProxy(t *testing.T) (controlURL string, cleanup func()) - -// Helper: create a session and return proxy host:port -func createSession(t *testing.T, controlURL string, req CreateSessionRequest) CreateSessionResponse -``` - -#### Test cases - -**Control API:** -1. Health check returns 200 -2. Create session succeeds, returns valid port -3. Create session with port already in use returns 409 -4. Get session returns metadata -5. Delete session returns event log, frees port -6. Session auto-deleted after timeout -7. Add rules dynamically - -**WebSocket proxy:** -8. WS passthrough — frames forwarded both directions -9. WS connection refusal — first connection refused, second passes through -10. WS disconnect action — abrupt close mid-stream -11. WS frame suppression — client ATTACH suppressed, server never sees it -12. WS inject_to_client — proxy injects a frame, original also forwarded -13. WS inject_to_client_and_close — proxy injects then closes -14. WS frame replacement — original frame replaced with different one -15. WS suppress_onwards — all subsequent server frames dropped -16. WS count matching — rule only fires on Nth connection/frame -17. WS one-shot rule (times=1) — fires once then removed - -**HTTP proxy:** -18. HTTP passthrough — request forwarded, response returned -19. HTTP respond — fake 401 returned for first request, second passes through -20. HTTP delay — response delayed by specified duration -21. HTTP drop — connection dropped, no response -22. HTTP replace_response — upstream response discarded, fake one returned -23. HTTP count matching - -**Imperative actions:** -24. Disconnect via actions API -25. Inject message via actions API -26. Action on session with no active WS returns error - -**Temporal triggers:** -27. delay_after_ws_connect fires and disconnects -28. delay_after_ws_connect cancelled if connection closes first -29. delay_after_ws_connect cancelled on session delete - -**Event log:** -30. Log captures WS connect, frames, disconnect events -31. Log captures HTTP request/response events -32. Log records which rule matched (or null for passthrough) - -**Concurrent sessions:** -33. Two sessions on different ports with different rules don't interfere - -**Msgpack:** -34. Binary (msgpack) frames parsed and matched by action -35. Binary frames forwarded unchanged (no re-encoding) - -## Data types - -### Session - -```go -type Session struct { - ID string - Target TargetConfig - Port int - Rules []*Rule - EventLog *EventLog - TimeoutTimer *time.Timer - Listener net.Listener - Server *http.Server - - activeWsConns []*WsConnection - wsConnectCount int - httpReqCount int - - suppressServerToClient bool - suppressClientToServer bool - - mu sync.Mutex -} - -type TargetConfig struct { - RealtimeHost string `json:"realtimeHost"` - RestHost string `json:"restHost"` -} -``` - -### Rule - -```go -type Rule struct { - Match MatchConfig `json:"match"` - Action ActionConfig `json:"action"` - Times int `json:"times,omitempty"` // 0 = unlimited - Comment string `json:"comment,omitempty"` - - matchCount int // how many times the match condition was satisfied -} - -type MatchConfig struct { - Type string `json:"type"` - Count int `json:"count,omitempty"` - Action string `json:"action,omitempty"` - Channel string `json:"channel,omitempty"` - Method string `json:"method,omitempty"` - PathContains string `json:"pathContains,omitempty"` - QueryContains map[string]string `json:"queryContains,omitempty"` - DelayMs int `json:"delayMs,omitempty"` -} - -type ActionConfig struct { - Type string `json:"type"` - CloseCode int `json:"closeCode,omitempty"` - DelayMs int `json:"delayMs,omitempty"` - Message json.RawMessage `json:"message,omitempty"` - Status int `json:"status,omitempty"` - Body json.RawMessage `json:"body,omitempty"` - Headers map[string]string `json:"headers,omitempty"` -} -``` - -### Event log - -```go -type Event struct { - Timestamp time.Time `json:"timestamp"` - Type string `json:"type"` - Direction string `json:"direction,omitempty"` - URL string `json:"url,omitempty"` - QueryParams map[string]string `json:"queryParams,omitempty"` - Message json.RawMessage `json:"message,omitempty"` - Method string `json:"method,omitempty"` - Path string `json:"path,omitempty"` - Status int `json:"status,omitempty"` - Initiator string `json:"initiator,omitempty"` - CloseCode int `json:"closeCode,omitempty"` - RuleMatched *string `json:"ruleMatched"` - Headers map[string]string `json:"headers,omitempty"` -} - -type EventLog struct { - events []Event - mu sync.Mutex -} -``` - -### Protocol message (minimal parsing) - -```go -type ProtocolMessage struct { - Action int - Channel string - Error *ErrorInfo -} - -type ErrorInfo struct { - Code int `json:"code"` - StatusCode int `json:"statusCode"` - Message string `json:"message"` -} - -// Action name constants -const ( - ActionHeartbeat = 0 - ActionAck = 1 - ActionNack = 2 - ActionConnect = 3 - ActionConnected = 4 - ActionDisconnect = 5 - ActionDisconnected = 6 - ActionClose = 7 - ActionClosed = 8 - ActionError = 9 - ActionAttach = 10 - ActionAttached = 11 - ActionDetach = 12 - ActionDetached = 13 - ActionPresence = 14 - ActionMessage = 15 - ActionSync = 16 - ActionAuth = 17 -) - -// actionNames maps name strings to int for rule matching -var actionNames = map[string]int{ - "HEARTBEAT": 0, - "ACK": 1, - // ... -} -``` - -### WsConnection - -```go -type WsConnection struct { - ClientConn *websocket.Conn - ServerConn *websocket.Conn - ConnNumber int - timers []*time.Timer - closed bool - mu sync.Mutex -} -``` - -## Build and run - -```bash -cd uts/test/proxy -go mod init ably.io/test-proxy -go get github.com/gorilla/websocket -go get github.com/vmihailenco/msgpack/v5 -go build -o test-proxy . - -# Run (control API on port 9100) -./test-proxy --port 9100 - -# Run tests -go test ./... -v -``` - -## Integration with Dart test runner - -The Dart test harness will: - -1. Spawn the proxy process: `Process.start('test-proxy', ['--port', '9100'])` -2. Wait for `GET http://localhost:9100/health` to return 200 -3. Maintain a port pool (e.g., 10000–11023) -4. For each test (or test group): - a. Allocate a port from the pool - b. Create a session: `POST http://localhost:9100/sessions` with `{"port": 10042, "target": {...}, "rules": [...]}` - c. If 409 (port conflict), try another port - d. Configure the SDK: - ```dart - ClientOptions( - realtimeHost: 'localhost:10042', - restHost: 'localhost:10042', - tls: false, - key: sandboxKey, - ) - ``` - e. Run the test - f. Delete the session: `DELETE http://localhost:9100/sessions/{id}` - g. Return port to pool -5. After all tests, kill the proxy process - -## Port pool design (Dart side) - -```dart -class PortPool { - final Set _available; - final Set _inUse = {}; - - PortPool({int start = 10000, int count = 1024}) - : _available = Set.from(List.generate(count, (i) => start + i)); - - int allocate() { - if (_available.isEmpty) throw StateError('No ports available'); - final port = _available.first; - _available.remove(port); - _inUse.add(port); - return port; - } - - void release(int port) { - _inUse.remove(port); - _available.add(port); - } -} -``` diff --git a/uts/proxy/PROPOSAL.md b/uts/proxy/PROPOSAL.md deleted file mode 100644 index 29f49ba17..000000000 --- a/uts/proxy/PROPOSAL.md +++ /dev/null @@ -1,461 +0,0 @@ -# Ably Test Proxy — Proposal - -## Overview - -A programmable HTTP/WebSocket proxy that sits between an Ably SDK under test and the real Ably sandbox backend. The proxy transparently forwards traffic by default, but can be configured with **rules** to inject faults — dropped connections, modified responses, injected protocol messages, delayed frames, etc. - -This enables **integration tests for fault behaviour** that would otherwise require mocking. The proxy gives tests the realism of talking to the actual Ably sandbox while retaining the ability to simulate network and protocol faults. - -## Motivation - -The existing UTS unit tests use mock HTTP/WebSocket clients to test fault handling (connection failures, token expiry, heartbeat starvation, channel errors, etc.). These are valuable but have limitations: - -- They test against synthetic responses, not the real server protocol -- They cannot verify that resume actually works end-to-end with a real server -- They require the test to script every server response, including the "happy path" ones - -A proxy-based approach lets tests rely on the real sandbox for normal behaviour and only inject specific faults. This increases confidence that the SDK handles real-world failure modes correctly. - -## Architecture - -``` - ┌────────────────────────────────────────────┐ - │ Ably Test Proxy (single process) │ - │ │ -┌──────────┐ │ ┌──────────────────┐ │ ┌───────────────┐ -│ SDK │────WS──▶│ │ :10042 (session1)│───wss──────────────▶│──────│ Ably Sandbox │ -│ under │◀───────▶│ │ :10043 (session2)│◀──────────────────▶│ │◀────│ (real backend) │ -│ test │──HTTP──▶│ │ ... │───https─────────────│──────│ │ -└──────────┘ │ └──────────────────┘ │ └───────────────┘ - │ │ - │ ┌──────────────────┐ │ - │ │ :9100 control API │ │ - │ └──────────────────┘ │ - └────────────────────────────────────────────┘ - ▲ - │ HTTP control API - ┌────────┴────────────┐ - │ Test process │ - │ (creates sessions, │ - │ assigns ports, │ - │ adds rules, │ - │ triggers actions) │ - └─────────────────────┘ -``` - -- **Single proxy process** serves multiple concurrent test sessions -- **Control API** (HTTP on a dedicated port, e.g. `:9100`) manages sessions and rules -- **Per-session ports** (assigned by the test process from a port pool) handle proxied WS and HTTP traffic. Each session binds its own TCP listener so the SDK can connect with standard URL paths. -- **No TLS between client and proxy.** The proxy serves plain HTTP/WS to the SDK. Upstream connections to the Ably sandbox use TLS (`wss://`, `https://`). -- **Default behaviour** is transparent passthrough to the real Ably sandbox -- **Protocol-aware for both JSON and msgpack.** The proxy decodes frames in both formats for rule matching. Raw bytes are forwarded unchanged (no re-encoding). - -## Control API - -Base URL: `http://localhost:{CONTROL_PORT}` - -### Create session - -The test process assigns a port from its port pool and passes it in the request. The proxy binds that port immediately — if the bind fails, the request fails with 409. - -``` -POST /sessions -Content-Type: application/json - -{ - "target": { - "realtimeHost": "sandbox-realtime.ably.io", - "restHost": "sandbox-rest.ably.io" - }, - "rules": [ ...rules... ], - "timeoutMs": 30000, - "port": 10042 -} - -Response 201: -{ - "sessionId": "abc123", - "proxy": { - "host": "localhost:10042", - "port": 10042 - } -} - -Response 409 (port unavailable): -{ - "error": "failed to bind port 10042: address already in use" -} -``` - -The SDK under test connects to the proxy port with standard URLs: -- WebSocket: `ws://localhost:10042/?key=...&heartbeats=true` -- REST: `http://localhost:10042/channels/test/messages` - -### Add rules dynamically - -``` -POST /sessions/{sessionId}/rules -Content-Type: application/json - -{ - "rules": [ ...additional rules... ], - "position": "append" // or "prepend" -} - -Response 200: -{ - "ruleCount": 5 -} -``` - -### Trigger an imperative action - -For cases where timed rules are awkward (e.g., "drop the connection NOW"): - -``` -POST /sessions/{sessionId}/actions -Content-Type: application/json - -{ "type": "disconnect" } - -Response 200: -{ "ok": true } -``` - -### Get captured traffic log - -``` -GET /sessions/{sessionId}/log -Response 200: -{ - "events": [ ...see event format below... ] -} -``` - -### Teardown session - -``` -DELETE /sessions/{sessionId} -Response 200: -{ - "events": [ ...final captured traffic log... ] -} -``` - -Teardown closes all active connections, stops the per-session listener, and frees the port. - -### Health check - -``` -GET /health -Response 200: { "ok": true } -``` - -## Rule format - -Each rule has a **match** condition, an **action** to perform, and an optional **times** limit: - -```jsonc -{ - "match": { ... }, - "action": { ... }, - "times": 1, // optional: remove rule after N matches (default: unlimited) - "comment": "..." // optional: for readability -} -``` - -Rules are evaluated in order. The first matching rule wins. Unmatched traffic is passed through unchanged. - -### Match conditions - -#### WebSocket connection attempt - -```jsonc -{ "type": "ws_connect" } -{ "type": "ws_connect", "count": 2 } // only the 2nd connection attempt -{ "type": "ws_connect", "queryContains": { "resume": "*" } } // has resume param -``` - -#### WebSocket frame from client → server - -```jsonc -{ "type": "ws_frame_to_server" } -{ "type": "ws_frame_to_server", "action": "ATTACH" } -{ "type": "ws_frame_to_server", "action": "ATTACH", "channel": "my-channel" } -{ "type": "ws_frame_to_server", "action": "MESSAGE" } -``` - -#### WebSocket frame from server → client - -```jsonc -{ "type": "ws_frame_to_client" } -{ "type": "ws_frame_to_client", "action": "CONNECTED" } -{ "type": "ws_frame_to_client", "action": "ATTACHED", "channel": "my-channel" } -{ "type": "ws_frame_to_client", "action": "HEARTBEAT" } -``` - -#### HTTP request - -```jsonc -{ "type": "http_request" } -{ "type": "http_request", "method": "POST" } -{ "type": "http_request", "pathContains": "/channels/" } -{ "type": "http_request", "pathContains": "/keys/" } -``` - -#### Temporal trigger - -```jsonc -{ "type": "delay_after_ws_connect", "delayMs": 5000 } -``` - -Fires once, `delayMs` after the WebSocket connection is established. Used for timed fault injection (e.g., heartbeat starvation, timed disconnection). - -### Actions - -#### Passthrough (default) - -```jsonc -{ "type": "passthrough" } -``` - -Forward unchanged. - -#### Connection-level faults - -```jsonc -// Refuse the WebSocket connection at TCP level -{ "type": "refuse_connection" } - -// Accept WebSocket handshake but immediately close -{ "type": "accept_and_close", "closeCode": 1011 } - -// Disconnect abruptly (no close frame) -{ "type": "disconnect" } - -// Close cleanly with code -{ "type": "close", "closeCode": 1000 } -``` - -#### Frame manipulation - -```jsonc -// Suppress (swallow) the frame — don't forward it -{ "type": "suppress" } - -// Delay before forwarding -{ "type": "delay", "delayMs": 2000 } - -// Inject a frame to the client (as if from server), in addition to the matched frame -{ "type": "inject_to_client", "message": { "action": 6, ... } } - -// Inject a frame to the client then close the WebSocket -{ "type": "inject_to_client_and_close", "message": { "action": 6, ... }, "closeCode": 1000 } - -// Replace the frame with a different one -{ "type": "replace", "message": { "action": 4, ... } } - -// Suppress all subsequent frames in the same direction (for heartbeat starvation) -{ "type": "suppress_onwards" } -``` - -#### HTTP faults - -```jsonc -// Return a specific HTTP response instead of forwarding -{ "type": "http_respond", "status": 503, "body": { ... }, "headers": { ... } } - -// Delay the HTTP response -{ "type": "http_delay", "delayMs": 5000 } - -// Drop the HTTP connection (no response) -{ "type": "http_drop" } - -// Forward but replace the response -{ "type": "http_replace_response", "status": 401, "body": { ... } } -``` - -## Event log format - -All traffic through a session is recorded: - -```jsonc -{ - "events": [ - { - "timestamp": "2026-02-23T10:00:00.123Z", - "type": "ws_connect", - "url": "ws://...", - "queryParams": { "key": "...", "heartbeats": "true" } - }, - { - "timestamp": "2026-02-23T10:00:00.200Z", - "type": "ws_frame", - "direction": "server_to_client", - "message": { "action": 4, "connectionId": "...", ... }, - "ruleMatched": null - }, - { - "timestamp": "2026-02-23T10:00:01.500Z", - "type": "ws_frame", - "direction": "client_to_server", - "message": { "action": 15, "channel": "test", ... }, - "ruleMatched": "rule-2" - }, - { - "timestamp": "2026-02-23T10:00:02.000Z", - "type": "ws_disconnect", - "initiator": "proxy", - "closeCode": 1006 - }, - { - "timestamp": "2026-02-23T10:00:02.100Z", - "type": "http_request", - "direction": "client_to_server", - "method": "GET", - "path": "/channels/test/messages", - "headers": { ... } - }, - { - "timestamp": "2026-02-23T10:00:02.300Z", - "type": "http_response", - "direction": "server_to_client", - "status": 200, - "ruleMatched": null - } - ] -} -``` - -## Usage patterns - -### Pattern 1: Imperative disconnect (RTN15a equivalent) - -``` -# Create passthrough session on port 10042 -POST /sessions {"port": 10042, "target": SANDBOX} - -# Connect SDK: Realtime(realtimeHost: "localhost:10042", tls: false) -# Wait for CONNECTED - -# Trigger disconnect -POST /sessions/{id}/actions {"type": "disconnect"} - -# SDK reconnects through proxy (passthrough), resumes -# Wait for CONNECTED again - -# Verify from log -GET /sessions/{id}/log -→ expect two ws_connect events -→ expect second ws_connect has queryParams.resume -``` - -### Pattern 2: One-shot connection refusal (RTN14d equivalent) - -```json -{ - "port": 10042, - "target": {"realtimeHost": "sandbox-realtime.ably.io"}, - "rules": [{ - "match": {"type": "ws_connect", "count": 1}, - "action": {"type": "refuse_connection"}, - "times": 1 - }] -} -``` - -First connection attempt is refused. SDK retries. Second passes through to sandbox. - -### Pattern 3: Injected DISCONNECTED with token error (RTN15h1 equivalent) - -```json -{ - "port": 10042, - "target": {"realtimeHost": "sandbox-realtime.ably.io"}, - "rules": [{ - "match": {"type": "delay_after_ws_connect", "delayMs": 1000}, - "action": { - "type": "inject_to_client_and_close", - "message": { - "action": 6, - "error": {"code": 40142, "statusCode": 401, "message": "Token expired"} - } - }, - "times": 1 - }] -} -``` - -### Pattern 4: REST 401 for token renewal (RSA4b4 equivalent) - -```json -{ - "port": 10042, - "target": {"restHost": "sandbox-rest.ably.io"}, - "rules": [{ - "match": {"type": "http_request", "pathContains": "/channels/"}, - "action": { - "type": "http_respond", - "status": 401, - "body": {"error": {"code": 40142, "statusCode": 401, "message": "Token expired"}} - }, - "times": 1 - }] -} -``` - -First channel request gets fake 401. Client renews token, retries. Second request passes through to real sandbox. - -### Pattern 5: Heartbeat starvation (RTN23 equivalent) - -```json -{ - "port": 10042, - "target": {"realtimeHost": "sandbox-realtime.ably.io"}, - "rules": [{ - "match": {"type": "delay_after_ws_connect", "delayMs": 2000}, - "action": {"type": "suppress_onwards"}, - "times": 1 - }] -} -``` - -SDK connects, gets CONNECTED from real server. After 2s, proxy starts swallowing all server→client frames. Client heartbeat timer expires. Client disconnects and reconnects. - -### Pattern 6: Channel attach suppression (RTL4f timeout equivalent) - -```json -{ - "port": 10042, - "target": {"realtimeHost": "sandbox-realtime.ably.io"}, - "rules": [{ - "match": {"type": "ws_frame_to_server", "action": "ATTACH", "channel": "test"}, - "action": {"type": "suppress"}, - "times": 1 - }] -} -``` - -Client sends ATTACH, proxy swallows it. Server never sees it, never responds. Client's attach timeout fires. - -## Scope and non-goals - -### In scope - -- WebSocket proxying with Ably protocol message awareness (JSON and msgpack) -- HTTP proxying for REST API calls -- Rule-based fault injection (connection, frame, and HTTP levels) -- Imperative actions (disconnect, close) -- Traffic capture and logging -- Concurrent sessions on separate ports for parallel tests - -### Not in scope - -- Fake timers / time advancement (integration tests use real time with short configured timeouts) -- Mock authUrl server (tests can spin up their own if needed) -- TLS between client and proxy (proxy serves plain HTTP/WS; TLS is used only upstream to sandbox) -- Modifying the SDK's internal state - -## Implementation - -The proxy is implemented in Go. See `IMPLEMENTATION.md` for the implementation plan. diff --git a/uts/proxy/action.go b/uts/proxy/action.go deleted file mode 100644 index d66972bda..000000000 --- a/uts/proxy/action.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - - "github.com/gorilla/websocket" -) - -// ExecuteImperativeAction executes an immediate action on the session's active WS connection(s). -func ExecuteImperativeAction(session *Session, req ActionRequest) error { - session.EventLog.Append(Event{ - Type: "action", - Initiator: "proxy", - Message: mustMarshal(req), - }) - - switch req.Type { - case "disconnect": - return imperativeDisconnect(session) - case "close": - return imperativeClose(session, req.CloseCode) - case "inject_to_client": - return imperativeInjectToClient(session, req.Message, false) - case "inject_to_client_and_close": - return imperativeInjectToClient(session, req.Message, true) - default: - return fmt.Errorf("unknown action type: %s", req.Type) - } -} - -func imperativeDisconnect(session *Session) error { - wc := session.GetActiveWsConn() - if wc == nil { - return fmt.Errorf("no active WebSocket connection") - } - - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - }) - - // Abrupt close — close the underlying TCP connection - if conn, ok := wc.ClientConn.(*websocket.Conn); ok { - conn.UnderlyingConn().Close() - } - if conn, ok := wc.ServerConn.(*websocket.Conn); ok { - conn.UnderlyingConn().Close() - } - - wc.MarkClosed() - return nil -} - -func imperativeClose(session *Session, closeCode int) error { - wc := session.GetActiveWsConn() - if wc == nil { - return fmt.Errorf("no active WebSocket connection") - } - - if closeCode <= 0 { - closeCode = websocket.CloseNormalClosure - } - - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - CloseCode: closeCode, - }) - - if conn, ok := wc.ClientConn.(*websocket.Conn); ok { - msg := websocket.FormatCloseMessage(closeCode, "") - conn.WriteMessage(websocket.CloseMessage, msg) - conn.UnderlyingConn().Close() - } - if conn, ok := wc.ServerConn.(*websocket.Conn); ok { - conn.UnderlyingConn().Close() - } - - wc.MarkClosed() - return nil -} - -func imperativeInjectToClient(session *Session, message json.RawMessage, andClose bool) error { - wc := session.GetActiveWsConn() - if wc == nil { - return fmt.Errorf("no active WebSocket connection") - } - - if conn, ok := wc.ClientConn.(*websocket.Conn); ok { - if err := conn.WriteMessage(websocket.TextMessage, message); err != nil { - return fmt.Errorf("failed to inject message: %w", err) - } - - session.EventLog.Append(Event{ - Type: "ws_frame", - Direction: "server_to_client", - Message: message, - Initiator: "proxy", - }) - } - - if andClose { - if conn, ok := wc.ClientConn.(*websocket.Conn); ok { - msg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") - conn.WriteMessage(websocket.CloseMessage, msg) - conn.UnderlyingConn().Close() - } - if conn, ok := wc.ServerConn.(*websocket.Conn); ok { - conn.UnderlyingConn().Close() - } - wc.MarkClosed() - - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - }) - } - - return nil -} - -func mustMarshal(v interface{}) json.RawMessage { - b, err := json.Marshal(v) - if err != nil { - return json.RawMessage(`{}`) - } - return b -} diff --git a/uts/proxy/go.mod b/uts/proxy/go.mod deleted file mode 100644 index 536c3d0e3..000000000 --- a/uts/proxy/go.mod +++ /dev/null @@ -1,9 +0,0 @@ -module ably.io/test-proxy - -go 1.22.3 - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect - github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect -) diff --git a/uts/proxy/go.sum b/uts/proxy/go.sum deleted file mode 100644 index ec57b9bf5..000000000 --- a/uts/proxy/go.sum +++ /dev/null @@ -1,6 +0,0 @@ -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= -github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= -github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= -github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= diff --git a/uts/proxy/http_proxy.go b/uts/proxy/http_proxy.go deleted file mode 100644 index f85db1c16..000000000 --- a/uts/proxy/http_proxy.go +++ /dev/null @@ -1,205 +0,0 @@ -package main - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "time" -) - -// HandleHttpProxy handles an HTTP request from the SDK client, -// proxying it to the upstream Ably REST host. -func HandleHttpProxy(session *Session, w http.ResponseWriter, r *http.Request) { - reqCount := session.IncrementHttpReqCount() - _ = reqCount - - // Log request headers (subset for readability) - headers := make(map[string]string) - for _, key := range []string{"Authorization", "Content-Type", "Accept", "X-Ably-Version", "X-Ably-Lib"} { - if v := r.Header.Get(key); v != "" { - headers[key] = v - } - } - - // Log http_request event - session.EventLog.Append(Event{ - Type: "http_request", - Direction: "client_to_server", - Method: r.Method, - Path: r.URL.Path, - Headers: headers, - }) - - // Build match event - matchEvent := MatchEvent{ - Type: "http_request", - Action: -1, - Method: r.Method, - Path: r.URL.Path, - } - - rule, ruleIdx := session.FindMatchingRule(matchEvent) - - if rule != nil { - session.FireRule(rule) - - switch rule.Action.Type { - case "http_respond": - ruleLabel := LogRuleMatch(rule, ruleIdx) - respondWithRule(w, session, rule, ruleLabel) - return - - case "http_delay": - time.Sleep(time.Duration(rule.Action.DelayMs) * time.Millisecond) - // Fall through to proxy - - case "http_drop": - ruleLabel := LogRuleMatch(rule, ruleIdx) - session.EventLog.Append(Event{ - Type: "http_response", - Direction: "server_to_client", - Status: 0, - RuleMatched: ruleLabel, - }) - // Hijack the connection and close it without responding - hj, ok := w.(http.Hijacker) - if ok { - conn, _, err := hj.Hijack() - if err == nil { - conn.Close() - } - } - return - - case "http_replace_response": - // Forward to upstream, discard response, return specified response - proxyToUpstreamAndDiscard(session, r) - ruleLabel := LogRuleMatch(rule, ruleIdx) - respondWithRule(w, session, rule, ruleLabel) - return - - case "passthrough": - // Fall through to proxy - } - } - - // Proxy to upstream - if session.Target.RestHost == "" { - writeError(w, http.StatusBadGateway, "no REST host configured") - return - } - - scheme := "https" - if session.Target.Insecure { - scheme = "http" - } - upstreamURL := &url.URL{ - Scheme: scheme, - Host: session.Target.RestHost, - } - - transport := &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, - } - if !session.Target.Insecure { - transport.TLSClientConfig = &tls.Config{} - } - - proxy := &httputil.ReverseProxy{ - Director: func(req *http.Request) { - req.URL.Scheme = upstreamURL.Scheme - req.URL.Host = upstreamURL.Host - req.Host = upstreamURL.Host - }, - Transport: transport, - ModifyResponse: func(resp *http.Response) error { - ruleLabel := LogRuleMatch(rule, ruleIdx) - session.EventLog.Append(Event{ - Type: "http_response", - Direction: "server_to_client", - Status: resp.StatusCode, - RuleMatched: ruleLabel, - }) - return nil - }, - ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - log.Printf("session %s: HTTP proxy error: %v", session.ID, err) - writeError(w, http.StatusBadGateway, fmt.Sprintf("upstream error: %v", err)) - }, - } - - proxy.ServeHTTP(w, r) -} - -func respondWithRule(w http.ResponseWriter, session *Session, rule *Rule, ruleLabel *string) { - status := rule.Action.Status - if status <= 0 { - status = 200 - } - - // Set headers - for k, v := range rule.Action.Headers { - w.Header().Set(k, v) - } - - // Default content type - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - - w.WriteHeader(status) - - if len(rule.Action.Body) > 0 { - w.Write(rule.Action.Body) - } - - session.EventLog.Append(Event{ - Type: "http_response", - Direction: "server_to_client", - Status: status, - RuleMatched: ruleLabel, - }) -} - -func proxyToUpstreamAndDiscard(session *Session, r *http.Request) { - if session.Target.RestHost == "" { - return - } - - scheme := "https" - if session.Target.Insecure { - scheme = "http" - } - upstreamURL := fmt.Sprintf("%s://%s%s", scheme, session.Target.RestHost, r.URL.RequestURI()) - req, err := http.NewRequest(r.Method, upstreamURL, r.Body) - if err != nil { - return - } - req.Header = r.Header.Clone() - - client := &http.Client{Timeout: 10 * time.Second} - if !session.Target.Insecure { - client.Transport = &http.Transport{TLSClientConfig: &tls.Config{}} - } - resp, err := client.Do(req) - if err != nil { - return - } - io.ReadAll(resp.Body) - resp.Body.Close() -} - -// WriteJSONResponse writes a JSON response body with the given status and data. -func WriteJSONResponse(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} diff --git a/uts/proxy/listener.go b/uts/proxy/listener.go deleted file mode 100644 index fca7f843e..000000000 --- a/uts/proxy/listener.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net" - "net/http" -) - -// StartSessionListener binds the given port and starts an HTTP server -// that routes WebSocket upgrades to WsProxyHandler and other HTTP requests -// to HttpProxyHandler. Returns an error if the port cannot be bound. -func StartSessionListener(session *Session, port int) error { - addr := fmt.Sprintf(":%d", port) - listener, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("failed to bind port %d: %w", port, err) - } - - session.mu.Lock() - session.listener = listener - session.mu.Unlock() - - mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Check if this is a WebSocket upgrade request - if isWebSocketUpgrade(r) { - HandleWsProxy(session, w, r) - return - } - // Otherwise treat as HTTP proxy - HandleHttpProxy(session, w, r) - }) - - server := &http.Server{Handler: mux} - - go func() { - if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { - log.Printf("session %s listener on port %d closed: %v", session.ID, port, err) - } - }() - - // Store server reference so we can shut it down later - session.mu.Lock() - session.Server = server - session.mu.Unlock() - - return nil -} - -// StopSessionListener gracefully shuts down the per-session HTTP server and closes the listener. -func StopSessionListener(session *Session) { - session.mu.Lock() - server := session.Server - session.Server = nil - session.mu.Unlock() - - if server != nil { - server.Shutdown(context.Background()) - } -} - -func isWebSocketUpgrade(r *http.Request) bool { - for _, v := range r.Header["Upgrade"] { - if v == "websocket" { - return true - } - } - return false -} diff --git a/uts/proxy/log.go b/uts/proxy/log.go deleted file mode 100644 index 111cb2064..000000000 --- a/uts/proxy/log.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "encoding/json" - "sync" - "time" -) - -// Event represents a single logged event in a session's traffic log. -type Event struct { - Timestamp time.Time `json:"timestamp"` - Type string `json:"type"` // ws_connect, ws_frame, ws_disconnect, http_request, http_response, action - Direction string `json:"direction,omitempty"` // client_to_server, server_to_client - URL string `json:"url,omitempty"` - QueryParams map[string]string `json:"queryParams,omitempty"` - Message json.RawMessage `json:"message,omitempty"` - Method string `json:"method,omitempty"` - Path string `json:"path,omitempty"` - Status int `json:"status,omitempty"` - Initiator string `json:"initiator,omitempty"` // client, server, proxy - CloseCode int `json:"closeCode,omitempty"` - RuleMatched *string `json:"ruleMatched"` - Headers map[string]string `json:"headers,omitempty"` -} - -// EventLog is an append-only, thread-safe event log. -type EventLog struct { - events []Event - mu sync.Mutex -} - -// NewEventLog creates a new empty event log. -func NewEventLog() *EventLog { - return &EventLog{} -} - -// Append adds an event to the log. -func (l *EventLog) Append(event Event) { - l.mu.Lock() - defer l.mu.Unlock() - if event.Timestamp.IsZero() { - event.Timestamp = time.Now().UTC() - } - l.events = append(l.events, event) -} - -// Events returns a copy of all events. -func (l *EventLog) Events() []Event { - l.mu.Lock() - defer l.mu.Unlock() - out := make([]Event, len(l.events)) - copy(out, l.events) - return out -} diff --git a/uts/proxy/main.go b/uts/proxy/main.go deleted file mode 100644 index 9899bbd6c..000000000 --- a/uts/proxy/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "syscall" -) - -func main() { - port := flag.Int("port", 9100, "control API port") - flag.Parse() - - store := NewSessionStore() - server := NewServer(store) - - addr := fmt.Sprintf(":%d", *port) - httpServer := &http.Server{ - Addr: addr, - Handler: server, - } - - // Graceful shutdown on signal - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigCh - log.Println("shutting down...") - - // Clean up all sessions - for _, session := range store.All() { - StopSessionListener(session) - session.Close() - } - - httpServer.Close() - }() - - log.Printf("Ably test proxy control API listening on %s", addr) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("failed to start server: %v", err) - } -} diff --git a/uts/proxy/protocol.go b/uts/proxy/protocol.go deleted file mode 100644 index 24eaac388..000000000 --- a/uts/proxy/protocol.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/gorilla/websocket" - "github.com/vmihailenco/msgpack/v5" -) - -// Ably protocol message action constants. -const ( - ActionHeartbeat = 0 - ActionAck = 1 - ActionNack = 2 - ActionConnect = 3 - ActionConnected = 4 - ActionDisconnect = 5 - ActionDisconnected = 6 - ActionClose = 7 - ActionClosed = 8 - ActionError = 9 - ActionAttach = 10 - ActionAttached = 11 - ActionDetach = 12 - ActionDetached = 13 - ActionPresence = 14 - ActionMessage = 15 - ActionSync = 16 - ActionAuth = 17 -) - -var actionNames = map[string]int{ - "HEARTBEAT": ActionHeartbeat, - "ACK": ActionAck, - "NACK": ActionNack, - "CONNECT": ActionConnect, - "CONNECTED": ActionConnected, - "DISCONNECT": ActionDisconnect, - "DISCONNECTED": ActionDisconnected, - "CLOSE": ActionClose, - "CLOSED": ActionClosed, - "ERROR": ActionError, - "ATTACH": ActionAttach, - "ATTACHED": ActionAttached, - "DETACH": ActionDetach, - "DETACHED": ActionDetached, - "PRESENCE": ActionPresence, - "MESSAGE": ActionMessage, - "SYNC": ActionSync, - "AUTH": ActionAuth, -} - -var actionNumbers = map[int]string{} - -func init() { - for name, num := range actionNames { - actionNumbers[num] = name - } -} - -// ActionFromString converts an action name (e.g. "ATTACH") or numeric string (e.g. "10") to an int. -// Returns -1 if the string is not recognized. -func ActionFromString(s string) int { - // Try as name first - if n, ok := actionNames[strings.ToUpper(s)]; ok { - return n - } - // Try as number - if n, err := strconv.Atoi(s); err == nil { - return n - } - return -1 -} - -// ActionName returns the name for an action number, or the number as a string. -func ActionName(action int) string { - if name, ok := actionNumbers[action]; ok { - return name - } - return strconv.Itoa(action) -} - -// ProtocolMessage is a minimal representation of an Ably protocol message, -// containing only the fields needed for rule matching. -type ProtocolMessage struct { - Action int - Channel string - Error *ErrorInfo -} - -// ErrorInfo is a minimal representation of an Ably error. -type ErrorInfo struct { - Code int `json:"code"` - StatusCode int `json:"statusCode"` - Message string `json:"message"` -} - -// ParseProtocolMessage decodes a WebSocket frame into a ProtocolMessage. -// For text frames (JSON) and binary frames (msgpack). -// Returns the parsed message. On failure, returns a message with Action=-1. -func ParseProtocolMessage(data []byte, messageType int) ProtocolMessage { - if messageType == websocket.TextMessage { - return parseJSON(data) - } - if messageType == websocket.BinaryMessage { - return parseMsgpack(data) - } - return ProtocolMessage{Action: -1} -} - -func parseJSON(data []byte) ProtocolMessage { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return ProtocolMessage{Action: -1} - } - - pm := ProtocolMessage{Action: -1} - - if actionRaw, ok := raw["action"]; ok { - // Action can be int or string - var actionInt int - if err := json.Unmarshal(actionRaw, &actionInt); err == nil { - pm.Action = actionInt - } else { - var actionStr string - if err := json.Unmarshal(actionRaw, &actionStr); err == nil { - pm.Action = ActionFromString(actionStr) - } - } - } - - if channelRaw, ok := raw["channel"]; ok { - json.Unmarshal(channelRaw, &pm.Channel) - } - - if errorRaw, ok := raw["error"]; ok { - var ei ErrorInfo - if err := json.Unmarshal(errorRaw, &ei); err == nil { - pm.Error = &ei - } - } - - return pm -} - -func parseMsgpack(data []byte) ProtocolMessage { - // Ably msgpack can be either a map or an array. - // Try map first (the common wire format). - var rawMap map[string]interface{} - if err := msgpack.Unmarshal(data, &rawMap); err == nil { - return parseMsgpackMap(rawMap) - } - - // Fall back to array format. - var rawArray []interface{} - if err := msgpack.Unmarshal(data, &rawArray); err == nil { - return parseMsgpackArray(rawArray) - } - - return ProtocolMessage{Action: -1} -} - -func parseMsgpackMap(m map[string]interface{}) ProtocolMessage { - pm := ProtocolMessage{Action: -1} - - if action, ok := m["action"]; ok { - pm.Action = toInt(action) - } - if channel, ok := m["channel"]; ok { - if s, ok := channel.(string); ok { - pm.Channel = s - } - } - if errObj, ok := m["error"]; ok { - if errMap, ok := errObj.(map[string]interface{}); ok { - pm.Error = &ErrorInfo{ - Code: toInt(errMap["code"]), - StatusCode: toInt(errMap["statusCode"]), - Message: fmt.Sprintf("%v", errMap["message"]), - } - } - } - - return pm -} - -func parseMsgpackArray(a []interface{}) ProtocolMessage { - pm := ProtocolMessage{Action: -1} - - if len(a) > 0 { - pm.Action = toInt(a[0]) - } - if len(a) > 1 { - if s, ok := a[1].(string); ok { - pm.Channel = s - } - } - - return pm -} - -func toInt(v interface{}) int { - switch n := v.(type) { - case int: - return n - case int8: - return int(n) - case int16: - return int(n) - case int32: - return int(n) - case int64: - return int(n) - case uint: - return int(n) - case uint8: - return int(n) - case uint16: - return int(n) - case uint32: - return int(n) - case uint64: - return int(n) - case float32: - return int(n) - case float64: - return int(n) - default: - return -1 - } -} diff --git a/uts/proxy/proxy_test.go b/uts/proxy/proxy_test.go deleted file mode 100644 index 385dd44a6..000000000 --- a/uts/proxy/proxy_test.go +++ /dev/null @@ -1,1118 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/vmihailenco/msgpack/v5" -) - -// -- Test helpers -- - -// startControlServer starts the control API server on a random port, returns its URL and cleanup func. -func startControlServer(t *testing.T) (string, *SessionStore, func()) { - t.Helper() - store := NewSessionStore() - server := NewServer(store) - ts := httptest.NewServer(server) - return ts.URL, store, ts.Close -} - -// freePort returns an available TCP port. -func freePort(t *testing.T) int { - t.Helper() - l, err := net.Listen("tcp", ":0") - if err != nil { - t.Fatalf("failed to get free port: %v", err) - } - port := l.Addr().(*net.TCPAddr).Port - l.Close() - return port -} - -// createSession creates a session via the control API, returns the response. -func createSession(t *testing.T, controlURL string, req CreateSessionRequest) CreateSessionResponse { - t.Helper() - body, _ := json.Marshal(req) - resp, err := http.Post(controlURL+"/sessions", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("failed to create session: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - b, _ := io.ReadAll(resp.Body) - t.Fatalf("create session returned %d: %s", resp.StatusCode, string(b)) - } - - var result CreateSessionResponse - json.NewDecoder(resp.Body).Decode(&result) - return result -} - -// deleteSession deletes a session via the control API. -func deleteSession(t *testing.T, controlURL, sessionID string) map[string]interface{} { - t.Helper() - req, _ := http.NewRequest("DELETE", controlURL+"/sessions/"+sessionID, nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("failed to delete session: %v", err) - } - defer resp.Body.Close() - var result map[string]interface{} - json.NewDecoder(resp.Body).Decode(&result) - return result -} - -// getLog fetches the event log for a session. -func getLog(t *testing.T, controlURL, sessionID string) []Event { - t.Helper() - resp, err := http.Get(controlURL + "/sessions/" + sessionID + "/log") - if err != nil { - t.Fatalf("failed to get log: %v", err) - } - defer resp.Body.Close() - var result struct { - Events []Event `json:"events"` - } - json.NewDecoder(resp.Body).Decode(&result) - return result.Events -} - -// triggerAction sends an imperative action. -func triggerAction(t *testing.T, controlURL, sessionID string, action ActionRequest) { - t.Helper() - body, _ := json.Marshal(action) - resp, err := http.Post(controlURL+"/sessions/"+sessionID+"/actions", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("failed to trigger action: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - t.Fatalf("trigger action returned %d: %s", resp.StatusCode, string(b)) - } -} - -// addRules adds rules to a session dynamically. -func addRules(t *testing.T, controlURL, sessionID string, rules []json.RawMessage, position string) { - t.Helper() - rulesJSON, _ := json.Marshal(rules) - reqBody, _ := json.Marshal(map[string]interface{}{ - "rules": json.RawMessage(rulesJSON), - "position": position, - }) - resp, err := http.Post(controlURL+"/sessions/"+sessionID+"/rules", "application/json", bytes.NewReader(reqBody)) - if err != nil { - t.Fatalf("failed to add rules: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - t.Fatalf("add rules returned %d: %s", resp.StatusCode, string(b)) - } -} - -// startMockWsServer starts a simple WS server that sends a CONNECTED message then echoes frames. -func startMockWsServer(t *testing.T) (string, func()) { - t.Helper() - upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - // Send CONNECTED - connected := map[string]interface{}{ - "action": ActionConnected, - "connectionId": "mock-conn-1", - "connectionDetails": map[string]interface{}{ - "connectionKey": "mock-key-1", - "maxIdleInterval": 15000, - "connectionStateTtl": 120000, - }, - } - connJSON, _ := json.Marshal(connected) - conn.WriteMessage(websocket.TextMessage, connJSON) - - // Echo loop - for { - msgType, data, err := conn.ReadMessage() - if err != nil { - return - } - conn.WriteMessage(msgType, data) - } - }) - - server := httptest.NewServer(handler) - - // Convert http URL to ws host - host := strings.TrimPrefix(server.URL, "http://") - return host, server.Close -} - -// startMockHttpServer starts a simple HTTP server that returns 200 with a JSON body. -func startMockHttpServer(t *testing.T) (string, func()) { - t.Helper() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "ok", "path": r.URL.Path}) - }) - server := httptest.NewServer(handler) - host := strings.TrimPrefix(server.URL, "http://") - return host, server.Close -} - -// connectWs connects a WebSocket client to the given host. -func connectWs(t *testing.T, host string, queryParams ...string) *websocket.Conn { - t.Helper() - u := fmt.Sprintf("ws://%s/", host) - if len(queryParams) > 0 { - u += "?" + strings.Join(queryParams, "&") - } - conn, _, err := websocket.DefaultDialer.Dial(u, nil) - if err != nil { - t.Fatalf("failed to connect WS to %s: %v", host, err) - } - return conn -} - -// readWsMessage reads a text message from a WS connection with timeout. -func readWsMessage(t *testing.T, conn *websocket.Conn, timeout time.Duration) map[string]interface{} { - t.Helper() - conn.SetReadDeadline(time.Now().Add(timeout)) - _, data, err := conn.ReadMessage() - if err != nil { - t.Fatalf("failed to read WS message: %v", err) - } - conn.SetReadDeadline(time.Time{}) - var msg map[string]interface{} - json.Unmarshal(data, &msg) - return msg -} - -// -- Tests -- - -func TestHealthCheck(t *testing.T) { - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - resp, err := http.Get(controlURL + "/health") - if err != nil { - t.Fatalf("health check failed: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - - var result map[string]bool - json.NewDecoder(resp.Body).Decode(&result) - if !result["ok"] { - t.Fatal("expected ok: true") - } -} - -func TestSessionLifecycle(t *testing.T) { - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:1234"}, - Port: port, - }) - - if session.SessionID == "" { - t.Fatal("expected session ID") - } - if session.Proxy.Port != port { - t.Fatalf("expected port %d, got %d", port, session.Proxy.Port) - } - - // Get session - resp, err := http.Get(controlURL + "/sessions/" + session.SessionID) - if err != nil { - t.Fatalf("get session failed: %v", err) - } - resp.Body.Close() - if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - - // Delete session - result := deleteSession(t, controlURL, session.SessionID) - if result["events"] == nil { - t.Fatal("expected events in delete response") - } - - // Verify port is freed - l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - t.Fatalf("port %d should be free after session delete: %v", port, err) - } - l.Close() -} - -func TestSessionPortConflict(t *testing.T) { - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - - // Create first session on the port - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:1234"}, - Port: port, - }) - - // Try to create second session on same port — should fail with 409 - body, _ := json.Marshal(CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:1234"}, - Port: port, - }) - resp, err := http.Post(controlURL+"/sessions", "application/json", bytes.NewReader(body)) - if err != nil { - t.Fatalf("failed to make request: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusConflict { - t.Fatalf("expected 409, got %d", resp.StatusCode) - } - - deleteSession(t, controlURL, session.SessionID) -} - -func TestWsPassthrough(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - }) - defer deleteSession(t, controlURL, session.SessionID) - - // Connect through proxy - conn := connectWs(t, fmt.Sprintf("localhost:%d", port), "key=test.key:secret") - defer conn.Close() - - // Should receive CONNECTED from mock upstream - msg := readWsMessage(t, conn, 2*time.Second) - action, _ := msg["action"].(float64) - if int(action) != ActionConnected { - t.Fatalf("expected CONNECTED (4), got %v", msg["action"]) - } - - // Send a frame — should be echoed back - testMsg := map[string]interface{}{"action": float64(ActionMessage), "channel": "test"} - testJSON, _ := json.Marshal(testMsg) - conn.WriteMessage(websocket.TextMessage, testJSON) - - echo := readWsMessage(t, conn, 2*time.Second) - echoAction, _ := echo["action"].(float64) - if int(echoAction) != ActionMessage { - t.Fatalf("expected echo of MESSAGE, got %v", echo["action"]) - } - - // Check event log - events := getLog(t, controlURL, session.SessionID) - hasConnect := false - frameCount := 0 - for _, e := range events { - if e.Type == "ws_connect" { - hasConnect = true - } - if e.Type == "ws_frame" { - frameCount++ - } - } - if !hasConnect { - t.Fatal("expected ws_connect event in log") - } - if frameCount < 2 { - t.Fatalf("expected at least 2 ws_frame events, got %d", frameCount) - } -} - -func TestHttpPassthrough(t *testing.T) { - upstreamHost, upstreamCleanup := startMockHttpServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RestHost: upstreamHost, Insecure: true}, - Port: port, - }) - defer deleteSession(t, controlURL, session.SessionID) - - // Make HTTP request through proxy - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/channels/test/messages", port)) - if err != nil { - t.Fatalf("HTTP request failed: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) - } - - var body map[string]string - json.NewDecoder(resp.Body).Decode(&body) - if body["path"] != "/channels/test/messages" { - t.Fatalf("expected path /channels/test/messages, got %s", body["path"]) - } - - // Check event log - events := getLog(t, controlURL, session.SessionID) - hasRequest := false - hasResponse := false - for _, e := range events { - if e.Type == "http_request" { - hasRequest = true - } - if e.Type == "http_response" { - hasResponse = true - } - } - if !hasRequest { - t.Fatal("expected http_request event in log") - } - if !hasResponse { - t.Fatal("expected http_response event in log") - } -} - -func TestWsConnectionRefusal(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - rulesJSON, _ := json.Marshal([]Rule{{ - Match: MatchConfig{Type: "ws_connect"}, - Action: ActionConfig{Type: "refuse_connection"}, - Times: 1, - }}) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - Rules: rulesJSON, - }) - defer deleteSession(t, controlURL, session.SessionID) - - // First connection should be refused - u := fmt.Sprintf("ws://localhost:%d/", port) - _, resp, err := websocket.DefaultDialer.Dial(u, nil) - if err == nil { - t.Fatal("expected connection to be refused") - } - if resp != nil && resp.StatusCode != http.StatusBadGateway { - t.Fatalf("expected 502, got %d", resp.StatusCode) - } - - // Second connection should succeed (rule was one-shot) - conn := connectWs(t, fmt.Sprintf("localhost:%d", port)) - defer conn.Close() - msg := readWsMessage(t, conn, 2*time.Second) - action, _ := msg["action"].(float64) - if int(action) != ActionConnected { - t.Fatalf("expected CONNECTED on second attempt, got %v", msg["action"]) - } -} - -func TestWsFrameSuppression(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - rulesJSON, _ := json.Marshal([]Rule{{ - Match: MatchConfig{Type: "ws_frame_to_server", Action: "MESSAGE"}, - Action: ActionConfig{Type: "suppress"}, - Times: 1, - }}) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - Rules: rulesJSON, - }) - defer deleteSession(t, controlURL, session.SessionID) - - conn := connectWs(t, fmt.Sprintf("localhost:%d", port)) - defer conn.Close() - - // Read CONNECTED - readWsMessage(t, conn, 2*time.Second) - - // Send MESSAGE — should be suppressed (not echoed) - msg1 := map[string]interface{}{"action": float64(ActionMessage), "channel": "test"} - msg1JSON, _ := json.Marshal(msg1) - conn.WriteMessage(websocket.TextMessage, msg1JSON) - - // Send another MESSAGE — should pass through (rule was one-shot) - msg2 := map[string]interface{}{"action": float64(ActionMessage), "channel": "test2"} - msg2JSON, _ := json.Marshal(msg2) - conn.WriteMessage(websocket.TextMessage, msg2JSON) - - // Should get echo of second message only - echo := readWsMessage(t, conn, 2*time.Second) - echoChannel, _ := echo["channel"].(string) - if echoChannel != "test2" { - t.Fatalf("expected echo of test2, got channel %s", echoChannel) - } -} - -func TestWsInjectAndClose(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - injectedMsg, _ := json.Marshal(map[string]interface{}{ - "action": ActionDisconnected, - "error": map[string]interface{}{"code": 40142, "statusCode": 401, "message": "Token expired"}, - }) - rulesJSON, _ := json.Marshal([]Rule{{ - Match: MatchConfig{Type: "ws_frame_to_client", Action: "CONNECTED"}, - Action: ActionConfig{Type: "inject_to_client_and_close", Message: injectedMsg}, - Times: 1, - }}) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - Rules: rulesJSON, - }) - defer deleteSession(t, controlURL, session.SessionID) - - conn := connectWs(t, fmt.Sprintf("localhost:%d", port)) - defer conn.Close() - - // Should receive the injected DISCONNECTED message - msg := readWsMessage(t, conn, 2*time.Second) - action, _ := msg["action"].(float64) - if int(action) != ActionDisconnected { - t.Fatalf("expected DISCONNECTED (6), got %v", msg["action"]) - } - - // Connection should be closed - conn.SetReadDeadline(time.Now().Add(1 * time.Second)) - _, _, err := conn.ReadMessage() - if err == nil { - t.Fatal("expected connection to be closed after inject_to_client_and_close") - } -} - -func TestWsImperativeDisconnect(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - }) - defer deleteSession(t, controlURL, session.SessionID) - - conn := connectWs(t, fmt.Sprintf("localhost:%d", port)) - defer conn.Close() - - // Read CONNECTED - readWsMessage(t, conn, 2*time.Second) - - // Trigger disconnect via control API - triggerAction(t, controlURL, session.SessionID, ActionRequest{Type: "disconnect"}) - - // Connection should be closed - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, _, err := conn.ReadMessage() - if err == nil { - t.Fatal("expected connection to be closed after imperative disconnect") - } -} - -func TestHttpRespond(t *testing.T) { - upstreamHost, upstreamCleanup := startMockHttpServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - rulesJSON, _ := json.Marshal([]Rule{{ - Match: MatchConfig{Type: "http_request", PathContains: "/channels/"}, - Action: ActionConfig{Type: "http_respond", Status: 401, Body: json.RawMessage(`{"error":{"code":40142,"message":"Token expired"}}`)}, - Times: 1, - }}) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RestHost: upstreamHost, Insecure: true}, - Port: port, - Rules: rulesJSON, - }) - defer deleteSession(t, controlURL, session.SessionID) - - // First request to /channels/ should get fake 401 - resp, err := http.Get(fmt.Sprintf("http://localhost:%d/channels/test/messages", port)) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.Body.Close() - if resp.StatusCode != 401 { - t.Fatalf("expected 401, got %d", resp.StatusCode) - } - - // Second request should pass through (rule was one-shot) - resp2, err := http.Get(fmt.Sprintf("http://localhost:%d/channels/test/messages", port)) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp2.Body.Close() - if resp2.StatusCode != 200 { - t.Fatalf("expected 200 on second request, got %d", resp2.StatusCode) - } -} - -func TestWsTemporalTrigger(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - rulesJSON, _ := json.Marshal([]Rule{{ - Match: MatchConfig{Type: "delay_after_ws_connect", DelayMs: 200}, - Action: ActionConfig{Type: "disconnect"}, - Times: 1, - }}) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - Rules: rulesJSON, - }) - defer deleteSession(t, controlURL, session.SessionID) - - conn := connectWs(t, fmt.Sprintf("localhost:%d", port)) - defer conn.Close() - - // Read CONNECTED - readWsMessage(t, conn, 2*time.Second) - - // Wait for temporal trigger to fire (200ms + margin) - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) - _, _, err := conn.ReadMessage() - if err == nil { - t.Fatal("expected connection to be closed by temporal trigger") - } - - // Verify disconnect logged - time.Sleep(100 * time.Millisecond) - events := getLog(t, controlURL, session.SessionID) - hasDisconnect := false - for _, e := range events { - if e.Type == "ws_disconnect" && e.Initiator == "proxy" { - hasDisconnect = true - } - } - if !hasDisconnect { - t.Fatal("expected ws_disconnect event from proxy in log") - } -} - -func TestWsSuppressOnwards(t *testing.T) { - upstreamHost, upstreamCleanup := startMockWsServer(t) - defer upstreamCleanup() - - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - rulesJSON, _ := json.Marshal([]Rule{{ - Match: MatchConfig{Type: "delay_after_ws_connect", DelayMs: 200}, - Action: ActionConfig{Type: "suppress_onwards"}, - Times: 1, - }}) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: upstreamHost, Insecure: true}, - Port: port, - Rules: rulesJSON, - }) - defer deleteSession(t, controlURL, session.SessionID) - - conn := connectWs(t, fmt.Sprintf("localhost:%d", port)) - defer conn.Close() - - // Read CONNECTED (arrives before suppress_onwards fires) - readWsMessage(t, conn, 2*time.Second) - - // Wait for suppress_onwards to take effect - time.Sleep(400 * time.Millisecond) - - // Send a MESSAGE — the echo from server should be suppressed - testMsg := map[string]interface{}{"action": float64(ActionMessage), "channel": "test"} - testJSON, _ := json.Marshal(testMsg) - conn.WriteMessage(websocket.TextMessage, testJSON) - - // Should NOT receive a response (server echoes but proxy suppresses) - conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) - _, _, err := conn.ReadMessage() - if err == nil { - t.Fatal("expected no message after suppress_onwards") - } -} - -// Tests that exercise the rule matching logic without needing a real upstream. - -func TestRuleMatching(t *testing.T) { - rule := &Rule{ - Match: MatchConfig{ - Type: "ws_frame_to_server", - Action: "ATTACH", - }, - Action: ActionConfig{Type: "suppress"}, - } - - // Should match - event := MatchEvent{Type: "ws_frame_to_server", Action: ActionAttach} - if !rule.Matches(event) { - t.Fatal("expected rule to match ATTACH frame") - } - - // Should not match different action - event2 := MatchEvent{Type: "ws_frame_to_server", Action: ActionDetach} - if rule.Matches(event2) { - t.Fatal("expected rule to NOT match DETACH frame") - } - - // Should not match different type - event3 := MatchEvent{Type: "ws_frame_to_client", Action: ActionAttach} - if rule.Matches(event3) { - t.Fatal("expected rule to NOT match client-direction frame") - } -} - -func TestRuleMatchingWithChannel(t *testing.T) { - rule := &Rule{ - Match: MatchConfig{ - Type: "ws_frame_to_server", - Action: "ATTACH", - Channel: "my-channel", - }, - Action: ActionConfig{Type: "suppress"}, - } - - event := MatchEvent{Type: "ws_frame_to_server", Action: ActionAttach, Channel: "my-channel"} - if !rule.Matches(event) { - t.Fatal("expected rule to match") - } - - event2 := MatchEvent{Type: "ws_frame_to_server", Action: ActionAttach, Channel: "other-channel"} - if rule.Matches(event2) { - t.Fatal("expected rule to NOT match different channel") - } -} - -func TestRuleMatchingWsConnect(t *testing.T) { - rule := &Rule{ - Match: MatchConfig{ - Type: "ws_connect", - QueryContains: map[string]string{"resume": "*"}, - }, - Action: ActionConfig{Type: "refuse_connection"}, - } - - // Should match when resume is present - event := MatchEvent{Type: "ws_connect", Action: -1, QueryParams: map[string]string{"resume": "key-1", "key": "abc"}} - if !rule.Matches(event) { - t.Fatal("expected rule to match when resume param present") - } - - // Should not match when resume is absent - event2 := MatchEvent{Type: "ws_connect", Action: -1, QueryParams: map[string]string{"key": "abc"}} - if rule.Matches(event2) { - t.Fatal("expected rule to NOT match when resume param absent") - } -} - -func TestRuleMatchingHttpRequest(t *testing.T) { - rule := &Rule{ - Match: MatchConfig{ - Type: "http_request", - Method: "POST", - PathContains: "/channels/", - }, - Action: ActionConfig{Type: "http_respond", Status: 401}, - } - - event := MatchEvent{Type: "http_request", Action: -1, Method: "POST", Path: "/channels/test/messages"} - if !rule.Matches(event) { - t.Fatal("expected rule to match") - } - - event2 := MatchEvent{Type: "http_request", Action: -1, Method: "GET", Path: "/channels/test/messages"} - if rule.Matches(event2) { - t.Fatal("expected rule to NOT match GET") - } - - event3 := MatchEvent{Type: "http_request", Action: -1, Method: "POST", Path: "/time"} - if rule.Matches(event3) { - t.Fatal("expected rule to NOT match /time") - } -} - -func TestSessionFindMatchingRuleWithCount(t *testing.T) { - session := &Session{ - Rules: []*Rule{ - { - Match: MatchConfig{Type: "ws_connect", Count: 2}, - Action: ActionConfig{Type: "refuse_connection"}, - }, - }, - EventLog: NewEventLog(), - } - - event := MatchEvent{Type: "ws_connect", Action: -1} - - // First attempt — count=1, rule wants count=2, should not fire - rule1, _ := session.FindMatchingRule(event) - if rule1 != nil { - t.Fatal("expected no match on first ws_connect") - } - - // Second attempt — count=2, should fire - rule2, _ := session.FindMatchingRule(event) - if rule2 == nil { - t.Fatal("expected match on second ws_connect") - } -} - -func TestRuleTimesLimit(t *testing.T) { - session := &Session{ - Rules: []*Rule{ - { - Match: MatchConfig{Type: "ws_frame_to_server", Action: "ATTACH"}, - Action: ActionConfig{Type: "suppress"}, - Times: 1, - }, - }, - EventLog: NewEventLog(), - } - - event := MatchEvent{Type: "ws_frame_to_server", Action: ActionAttach} - - // First match — should fire - rule, _ := session.FindMatchingRule(event) - if rule == nil { - t.Fatal("expected match") - } - session.FireRule(rule) - - // Rule should be removed (times=1, fired once) - if len(session.Rules) != 0 { - t.Fatalf("expected 0 rules, got %d", len(session.Rules)) - } - - // Second match — no rule - rule2, _ := session.FindMatchingRule(event) - if rule2 != nil { - t.Fatal("expected no match after rule exhausted") - } -} - -func TestProtocolParseJSON(t *testing.T) { - msg := `{"action":10,"channel":"test-channel"}` - pm := ParseProtocolMessage([]byte(msg), websocket.TextMessage) - if pm.Action != ActionAttach { - t.Fatalf("expected action %d, got %d", ActionAttach, pm.Action) - } - if pm.Channel != "test-channel" { - t.Fatalf("expected channel test-channel, got %s", pm.Channel) - } -} - -func TestProtocolParseJSONWithError(t *testing.T) { - msg := `{"action":9,"error":{"code":40142,"statusCode":401,"message":"Token expired"}}` - pm := ParseProtocolMessage([]byte(msg), websocket.TextMessage) - if pm.Action != ActionError { - t.Fatalf("expected action %d, got %d", ActionError, pm.Action) - } - if pm.Error == nil { - t.Fatal("expected error to be parsed") - } - if pm.Error.Code != 40142 { - t.Fatalf("expected error code 40142, got %d", pm.Error.Code) - } -} - -func TestProtocolParseMsgpack(t *testing.T) { - // Build a msgpack map: {"action": 10, "channel": "test"} - // Using raw msgpack encoding - raw := map[string]interface{}{ - "action": 10, - "channel": "test", - } - data := mustMarshalMsgpack(t, raw) - pm := ParseProtocolMessage(data, websocket.BinaryMessage) - if pm.Action != ActionAttach { - t.Fatalf("expected action %d, got %d", ActionAttach, pm.Action) - } - if pm.Channel != "test" { - t.Fatalf("expected channel test, got %s", pm.Channel) - } -} - -func TestActionFromString(t *testing.T) { - tests := []struct { - input string - want int - }{ - {"ATTACH", ActionAttach}, - {"attach", ActionAttach}, - {"CONNECTED", ActionConnected}, - {"10", ActionAttach}, - {"4", ActionConnected}, - {"unknown", -1}, - } - for _, tt := range tests { - got := ActionFromString(tt.input) - if got != tt.want { - t.Errorf("ActionFromString(%q) = %d, want %d", tt.input, got, tt.want) - } - } -} - -func TestEventLog(t *testing.T) { - log := NewEventLog() - log.Append(Event{Type: "ws_connect"}) - log.Append(Event{Type: "ws_frame", Direction: "client_to_server"}) - - events := log.Events() - if len(events) != 2 { - t.Fatalf("expected 2 events, got %d", len(events)) - } - if events[0].Type != "ws_connect" { - t.Fatalf("expected ws_connect, got %s", events[0].Type) - } - if events[1].Direction != "client_to_server" { - t.Fatalf("expected client_to_server, got %s", events[1].Direction) - } -} - -func TestEventLogConcurrency(t *testing.T) { - el := NewEventLog() - var wg sync.WaitGroup - for i := 0; i < 100; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - el.Append(Event{Type: fmt.Sprintf("event-%d", i)}) - }(i) - } - wg.Wait() - - events := el.Events() - if len(events) != 100 { - t.Fatalf("expected 100 events, got %d", len(events)) - } -} - -func TestSessionTimeout(t *testing.T) { - controlURL, store, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:1234"}, - Port: port, - TimeoutMs: 200, // 200ms timeout - }) - - // Session should exist - _, ok := store.Get(session.SessionID) - if !ok { - t.Fatal("expected session to exist") - } - - // Wait for timeout - time.Sleep(500 * time.Millisecond) - - // Session should be cleaned up - _, ok = store.Get(session.SessionID) - if ok { - t.Fatal("expected session to be cleaned up after timeout") - } - - // Port should be free - l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - t.Fatalf("port should be free after timeout: %v", err) - } - l.Close() -} - -func TestAddRulesDynamically(t *testing.T) { - controlURL, store, cleanup := startControlServer(t) - defer cleanup() - - port := freePort(t) - session := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:1234"}, - Port: port, - }) - defer deleteSession(t, controlURL, session.SessionID) - - // Add a rule - ruleJSON, _ := json.Marshal(Rule{ - Match: MatchConfig{Type: "ws_connect"}, - Action: ActionConfig{Type: "refuse_connection"}, - Times: 1, - }) - addRules(t, controlURL, session.SessionID, []json.RawMessage{ruleJSON}, "append") - - // Verify rule count - sess, _ := store.Get(session.SessionID) - if sess.RuleCount() != 1 { - t.Fatalf("expected 1 rule, got %d", sess.RuleCount()) - } -} - -func TestMultipleRulesOrder(t *testing.T) { - session := &Session{ - Rules: []*Rule{ - { - Match: MatchConfig{Type: "ws_frame_to_server", Action: "ATTACH"}, - Action: ActionConfig{Type: "suppress"}, - Comment: "first", - }, - { - Match: MatchConfig{Type: "ws_frame_to_server"}, - Action: ActionConfig{Type: "passthrough"}, - Comment: "second", - }, - }, - EventLog: NewEventLog(), - } - - // ATTACH should match the first rule (more specific) - event := MatchEvent{Type: "ws_frame_to_server", Action: ActionAttach} - rule, _ := session.FindMatchingRule(event) - if rule == nil || rule.Comment != "first" { - t.Fatal("expected first rule to match") - } - - // MESSAGE should match the second rule (catch-all) - event2 := MatchEvent{Type: "ws_frame_to_server", Action: ActionMessage} - rule2, _ := session.FindMatchingRule(event2) - if rule2 == nil || rule2.Comment != "second" { - t.Fatal("expected second rule to match") - } -} - -func TestConcurrentSessions(t *testing.T) { - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - port1 := freePort(t) - port2 := freePort(t) - - session1 := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:1111"}, - Port: port1, - }) - defer deleteSession(t, controlURL, session1.SessionID) - - session2 := createSession(t, controlURL, CreateSessionRequest{ - Target: TargetConfig{RealtimeHost: "localhost:2222"}, - Port: port2, - }) - defer deleteSession(t, controlURL, session2.SessionID) - - if session1.SessionID == session2.SessionID { - t.Fatal("expected different session IDs") - } - if session1.Proxy.Port == session2.Proxy.Port { - t.Fatal("expected different ports") - } -} - -func TestSessionNotFound(t *testing.T) { - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - resp, _ := http.Get(controlURL + "/sessions/nonexistent") - if resp.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404, got %d", resp.StatusCode) - } - resp.Body.Close() - - resp2, _ := http.Get(controlURL + "/sessions/nonexistent/log") - if resp2.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404, got %d", resp2.StatusCode) - } - resp2.Body.Close() -} - -func TestCreateSessionValidation(t *testing.T) { - controlURL, _, cleanup := startControlServer(t) - defer cleanup() - - // Missing port - body, _ := json.Marshal(CreateSessionRequest{Target: TargetConfig{RealtimeHost: "localhost:1234"}}) - resp, _ := http.Post(controlURL+"/sessions", "application/json", bytes.NewReader(body)) - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for missing port, got %d", resp.StatusCode) - } - resp.Body.Close() - - // Missing target - body2, _ := json.Marshal(CreateSessionRequest{Port: 9999}) - resp2, _ := http.Post(controlURL+"/sessions", "application/json", bytes.NewReader(body2)) - if resp2.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for missing target, got %d", resp2.StatusCode) - } - resp2.Body.Close() -} - -// -- Msgpack test helper -- - -func mustMarshalMsgpack(t *testing.T, v interface{}) []byte { - t.Helper() - data, err := msgpack.Marshal(v) - if err != nil { - t.Fatalf("failed to marshal msgpack: %v", err) - } - return data -} diff --git a/uts/proxy/rule.go b/uts/proxy/rule.go deleted file mode 100644 index 53768ed74..000000000 --- a/uts/proxy/rule.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -import ( - "encoding/json" - "strings" -) - -// Rule represents a single proxy rule with match condition, action, and optional firing limit. -type Rule struct { - Match MatchConfig `json:"match"` - Action ActionConfig `json:"action"` - Times int `json:"times,omitempty"` // 0 = unlimited - Comment string `json:"comment,omitempty"` - - matchCount int // how many times the match condition was satisfied (for count matching) -} - -// MatchConfig describes when a rule fires. -type MatchConfig struct { - Type string `json:"type"` // ws_connect, ws_frame_to_server, ws_frame_to_client, http_request, delay_after_ws_connect - Count int `json:"count,omitempty"` // only match the Nth occurrence (1-based) - Action string `json:"action,omitempty"` // protocol message action name or number - Channel string `json:"channel,omitempty"` // channel name must equal this - Method string `json:"method,omitempty"` // HTTP method - PathContains string `json:"pathContains,omitempty"` // request path must contain this - QueryContains map[string]string `json:"queryContains,omitempty"` // query params that must be present ("*" = any value) - DelayMs int `json:"delayMs,omitempty"` // for delay_after_ws_connect -} - -// ActionConfig describes what happens when a rule fires. -type ActionConfig struct { - Type string `json:"type"` // passthrough, refuse_connection, accept_and_close, disconnect, close, suppress, delay, inject_to_client, inject_to_client_and_close, replace, suppress_onwards, http_respond, http_delay, http_drop, http_replace_response - CloseCode int `json:"closeCode,omitempty"` - DelayMs int `json:"delayMs,omitempty"` - Message json.RawMessage `json:"message,omitempty"` - Status int `json:"status,omitempty"` - Body json.RawMessage `json:"body,omitempty"` - Headers map[string]string `json:"headers,omitempty"` -} - -// MatchEvent is the context passed to rule matching. -type MatchEvent struct { - Type string // ws_connect, ws_frame_to_server, ws_frame_to_client, http_request - Action int // protocol message action (normalized to int), -1 if not applicable - ActionStr string // original action string for logging - Channel string // protocol message channel - Method string // HTTP method - Path string // HTTP request path - QueryParams map[string]string // WS connection query params -} - -// Matches checks whether this rule's match config matches the given event. -// It does NOT check the count — that is handled by FindMatchingRule. -func (r *Rule) Matches(event MatchEvent) bool { - m := r.Match - - if m.Type != event.Type { - return false - } - - // Action filter (for frame matches) - if m.Action != "" && event.Action >= 0 { - // Try matching by name or number - wantAction := ActionFromString(m.Action) - if wantAction < 0 { - return false // unknown action name - } - if wantAction != event.Action { - return false - } - } - - // Channel filter - if m.Channel != "" && m.Channel != event.Channel { - return false - } - - // HTTP method filter - if m.Method != "" && !strings.EqualFold(m.Method, event.Method) { - return false - } - - // HTTP path filter - if m.PathContains != "" && !strings.Contains(event.Path, m.PathContains) { - return false - } - - // Query param filter (for ws_connect) - if len(m.QueryContains) > 0 { - for k, v := range m.QueryContains { - actual, ok := event.QueryParams[k] - if !ok { - return false - } - if v != "*" && v != actual { - return false - } - } - } - - return true -} - -// ruleLabel returns a human-readable label for logging which rule matched. -func ruleLabel(rule *Rule, index int) string { - if rule.Comment != "" { - return rule.Comment - } - return "rule-" + strings.Repeat("0", 1) + string(rune('0'+index)) -} diff --git a/uts/proxy/server.go b/uts/proxy/server.go deleted file mode 100644 index 0849f6d24..000000000 --- a/uts/proxy/server.go +++ /dev/null @@ -1,245 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "strings" - "time" -) - -// Server is the control API server. -type Server struct { - store *SessionStore - mux *http.ServeMux -} - -// NewServer creates a new control API server. -func NewServer(store *SessionStore) *Server { - s := &Server{store: store} - s.mux = http.NewServeMux() - s.mux.HandleFunc("GET /health", s.handleHealth) - s.mux.HandleFunc("POST /sessions", s.handleCreateSession) - s.mux.HandleFunc("GET /sessions/{id}", s.handleGetSession) - s.mux.HandleFunc("POST /sessions/{id}/rules", s.handleAddRules) - s.mux.HandleFunc("POST /sessions/{id}/actions", s.handleAction) - s.mux.HandleFunc("GET /sessions/{id}/log", s.handleGetLog) - s.mux.HandleFunc("DELETE /sessions/{id}", s.handleDeleteSession) - return s -} - -// ServeHTTP implements http.Handler. -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - s.mux.ServeHTTP(w, r) -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} - -func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - - var req CreateSessionRequest - if err := json.Unmarshal(body, &req); err != nil { - writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) - return - } - - if req.Port <= 0 { - writeError(w, http.StatusBadRequest, "port is required and must be positive") - return - } - - if req.Target.RealtimeHost == "" && req.Target.RestHost == "" { - writeError(w, http.StatusBadRequest, "target must have at least one of realtimeHost or restHost") - return - } - - timeoutMs := req.TimeoutMs - if timeoutMs <= 0 { - timeoutMs = 30000 - } - - // Parse rules - var rules []*Rule - if len(req.Rules) > 0 { - if err := json.Unmarshal(req.Rules, &rules); err != nil { - writeError(w, http.StatusBadRequest, "invalid rules: "+err.Error()) - return - } - } - - session := &Session{ - ID: GenerateID(), - Target: req.Target, - Port: req.Port, - Rules: rules, - EventLog: NewEventLog(), - timeoutMs: timeoutMs, - } - - // Attempt to bind the port - if err := StartSessionListener(session, req.Port); err != nil { - writeError(w, http.StatusConflict, err.Error()) - return - } - - // Set up auto-cleanup timer - session.timeoutTimer = time.AfterFunc(time.Duration(timeoutMs)*time.Millisecond, func() { - log.Printf("session %s timed out after %dms, cleaning up", session.ID, timeoutMs) - s.cleanupSession(session.ID) - }) - - s.store.Create(session) - - resp := CreateSessionResponse{ - SessionID: session.ID, - Proxy: ProxyConfig{ - Host: fmt.Sprintf("localhost:%d", req.Port), - Port: req.Port, - }, - } - - log.Printf("created session %s on port %d (timeout %dms, %d rules)", - session.ID, req.Port, timeoutMs, len(rules)) - - writeJSON(w, http.StatusCreated, resp) -} - -func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - session, ok := s.store.Get(id) - if !ok { - writeError(w, http.StatusNotFound, "session not found") - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "sessionId": session.ID, - "port": session.Port, - "target": session.Target, - "ruleCount": session.RuleCount(), - }) -} - -func (s *Server) handleAddRules(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - session, ok := s.store.Get(id) - if !ok { - writeError(w, http.StatusNotFound, "session not found") - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - - var req AddRulesRequest - if err := json.Unmarshal(body, &req); err != nil { - writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) - return - } - - var rules []*Rule - if err := json.Unmarshal(req.Rules, &rules); err != nil { - writeError(w, http.StatusBadRequest, "invalid rules: "+err.Error()) - return - } - - prepend := strings.EqualFold(req.Position, "prepend") - session.AddRules(rules, prepend) - session.ResetTimeout() - - writeJSON(w, http.StatusOK, map[string]int{"ruleCount": session.RuleCount()}) -} - -func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - session, ok := s.store.Get(id) - if !ok { - writeError(w, http.StatusNotFound, "session not found") - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - - var req ActionRequest - if err := json.Unmarshal(body, &req); err != nil { - writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) - return - } - - session.ResetTimeout() - - if err := ExecuteImperativeAction(session, req); err != nil { - writeError(w, http.StatusConflict, err.Error()) - return - } - - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} - -func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - session, ok := s.store.Get(id) - if !ok { - writeError(w, http.StatusNotFound, "session not found") - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "events": session.EventLog.Events(), - }) -} - -func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - events := s.cleanupSession(id) - if events == nil { - writeError(w, http.StatusNotFound, "session not found") - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "events": events, - }) -} - -func (s *Server) cleanupSession(id string) []Event { - session, ok := s.store.Delete(id) - if !ok { - return nil - } - - log.Printf("cleaning up session %s on port %d", session.ID, session.Port) - - StopSessionListener(session) - session.Close() - - return session.EventLog.Events() -} - -// -- helpers -- - -func writeJSON(w http.ResponseWriter, status int, v interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, map[string]string{"error": msg}) -} diff --git a/uts/proxy/session.go b/uts/proxy/session.go deleted file mode 100644 index 8d554610b..000000000 --- a/uts/proxy/session.go +++ /dev/null @@ -1,286 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net" - "net/http" - "sync" - "time" -) - -// TargetConfig specifies the upstream Ably hosts. -type TargetConfig struct { - RealtimeHost string `json:"realtimeHost,omitempty"` - RestHost string `json:"restHost,omitempty"` - Insecure bool `json:"insecure,omitempty"` // if true, use ws:// and http:// upstream (for testing) -} - -// Session represents a single test session with its own port, rules, and state. -type Session struct { - ID string `json:"id"` - Target TargetConfig `json:"target"` - Port int `json:"port"` - Rules []*Rule `json:"-"` - EventLog *EventLog `json:"-"` - - listener net.Listener - Server *http.Server - timeoutTimer *time.Timer - timeoutMs int - - activeWsConns []*WsConnection - wsConnectCount int - httpReqCount int - - suppressServerToClient bool - suppressClientToServer bool - - mu sync.Mutex -} - -// WsConnection tracks an active proxied WebSocket connection. -type WsConnection struct { - ClientConn interface{} // *websocket.Conn — set during ws_proxy - ServerConn interface{} // *websocket.Conn — set during ws_proxy - ConnNumber int - timers []*time.Timer - closed bool - closeCh chan struct{} // closed when connection is torn down - mu sync.Mutex -} - -// NewWsConnection creates a new WsConnection. -func NewWsConnection(connNumber int) *WsConnection { - return &WsConnection{ - ConnNumber: connNumber, - closeCh: make(chan struct{}), - } -} - -// MarkClosed marks this connection as closed and signals the closeCh. -func (wc *WsConnection) MarkClosed() { - wc.mu.Lock() - defer wc.mu.Unlock() - if !wc.closed { - wc.closed = true - close(wc.closeCh) - } -} - -// IsClosed returns whether this connection has been closed. -func (wc *WsConnection) IsClosed() bool { - wc.mu.Lock() - defer wc.mu.Unlock() - return wc.closed -} - -// CancelTimers stops all pending timers for this connection. -func (wc *WsConnection) CancelTimers() { - wc.mu.Lock() - defer wc.mu.Unlock() - for _, t := range wc.timers { - t.Stop() - } - wc.timers = nil -} - -// AddTimer adds a timer to this connection's timer list. -func (wc *WsConnection) AddTimer(t *time.Timer) { - wc.mu.Lock() - defer wc.mu.Unlock() - wc.timers = append(wc.timers, t) -} - -// FindMatchingRule iterates rules in order and returns the first match. -// It handles count tracking: increments the rule's matchCount when the -// base condition matches, and only returns the rule if count is satisfied. -// Returns the rule and its index, or nil/-1 if no rule matches. -func (s *Session) FindMatchingRule(event MatchEvent) (*Rule, int) { - s.mu.Lock() - defer s.mu.Unlock() - - for i, rule := range s.Rules { - if !rule.Matches(event) { - continue - } - - // Base condition matches — increment match count - rule.matchCount++ - - // Check count constraint - if rule.Match.Count > 0 && rule.matchCount != rule.Match.Count { - continue - } - - return rule, i - } - return nil, -1 -} - -// FireRule records that a rule has fired and removes it if times is exhausted. -func (s *Session) FireRule(rule *Rule) { - s.mu.Lock() - defer s.mu.Unlock() - s.fireRuleLocked(rule) -} - -func (s *Session) fireRuleLocked(rule *Rule) { - if rule.Times > 0 { - rule.Times-- - if rule.Times <= 0 { - s.removeRuleLocked(rule) - } - } -} - -func (s *Session) removeRuleLocked(rule *Rule) { - for i, r := range s.Rules { - if r == rule { - s.Rules = append(s.Rules[:i], s.Rules[i+1:]...) - return - } - } -} - -// AddRules appends or prepends rules to the session. -func (s *Session) AddRules(rules []*Rule, prepend bool) { - s.mu.Lock() - defer s.mu.Unlock() - if prepend { - s.Rules = append(rules, s.Rules...) - } else { - s.Rules = append(s.Rules, rules...) - } -} - -// RuleCount returns the current number of rules. -func (s *Session) RuleCount() int { - s.mu.Lock() - defer s.mu.Unlock() - return len(s.Rules) -} - -// GetActiveWsConn returns the most recently added active WS connection, or nil. -func (s *Session) GetActiveWsConn() *WsConnection { - s.mu.Lock() - defer s.mu.Unlock() - for i := len(s.activeWsConns) - 1; i >= 0; i-- { - if !s.activeWsConns[i].IsClosed() { - return s.activeWsConns[i] - } - } - return nil -} - -// AddWsConn registers a new WS connection and increments the connect count. -func (s *Session) AddWsConn(wc *WsConnection) { - s.mu.Lock() - defer s.mu.Unlock() - s.wsConnectCount++ - wc.ConnNumber = s.wsConnectCount - s.activeWsConns = append(s.activeWsConns, wc) -} - -// RemoveWsConn removes a WS connection from the active list. -func (s *Session) RemoveWsConn(wc *WsConnection) { - s.mu.Lock() - defer s.mu.Unlock() - for i, c := range s.activeWsConns { - if c == wc { - s.activeWsConns = append(s.activeWsConns[:i], s.activeWsConns[i+1:]...) - return - } - } -} - -// IncrementHttpReqCount increments and returns the HTTP request count. -func (s *Session) IncrementHttpReqCount() int { - s.mu.Lock() - defer s.mu.Unlock() - s.httpReqCount++ - return s.httpReqCount -} - -// ResetTimeout resets the session's auto-cleanup timer. -func (s *Session) ResetTimeout() { - s.mu.Lock() - defer s.mu.Unlock() - if s.timeoutTimer != nil { - s.timeoutTimer.Stop() - } - if s.timeoutMs > 0 { - s.timeoutTimer = time.AfterFunc(time.Duration(s.timeoutMs)*time.Millisecond, func() { - // Auto-cleanup is handled by the session store - }) - } -} - -// Close shuts down the session: closes all WS connections, cancels timers, closes listener. -func (s *Session) Close() { - s.mu.Lock() - defer s.mu.Unlock() - - if s.timeoutTimer != nil { - s.timeoutTimer.Stop() - s.timeoutTimer = nil - } - - for _, wc := range s.activeWsConns { - wc.CancelTimers() - wc.MarkClosed() - } - s.activeWsConns = nil - - if s.listener != nil { - s.listener.Close() - s.listener = nil - } -} - -// LogRuleMatch returns a string pointer for logging which rule matched, or nil. -func LogRuleMatch(rule *Rule, index int) *string { - if rule == nil { - return nil - } - label := fmt.Sprintf("rule-%d", index) - if rule.Comment != "" { - label = rule.Comment - } - return &label -} - -// -- API request/response types -- - -// CreateSessionRequest is the JSON body for POST /sessions. -type CreateSessionRequest struct { - Target TargetConfig `json:"target"` - Rules json.RawMessage `json:"rules,omitempty"` - TimeoutMs int `json:"timeoutMs,omitempty"` - Port int `json:"port"` -} - -// CreateSessionResponse is the JSON response for POST /sessions. -type CreateSessionResponse struct { - SessionID string `json:"sessionId"` - Proxy ProxyConfig `json:"proxy"` -} - -// ProxyConfig describes how to connect to the session's proxy. -type ProxyConfig struct { - Host string `json:"host"` - Port int `json:"port"` -} - -// AddRulesRequest is the JSON body for POST /sessions/{id}/rules. -type AddRulesRequest struct { - Rules json.RawMessage `json:"rules"` - Position string `json:"position,omitempty"` // "append" (default) or "prepend" -} - -// ActionRequest is the JSON body for POST /sessions/{id}/actions. -type ActionRequest struct { - Type string `json:"type"` - Message json.RawMessage `json:"message,omitempty"` - CloseCode int `json:"closeCode,omitempty"` -} diff --git a/uts/proxy/session_store.go b/uts/proxy/session_store.go deleted file mode 100644 index a2028ff27..000000000 --- a/uts/proxy/session_store.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "sync" -) - -// SessionStore is a thread-safe store for sessions. -type SessionStore struct { - sessions map[string]*Session - mu sync.RWMutex -} - -// NewSessionStore creates a new empty session store. -func NewSessionStore() *SessionStore { - return &SessionStore{ - sessions: make(map[string]*Session), - } -} - -// Create adds a session to the store. -func (s *SessionStore) Create(session *Session) { - s.mu.Lock() - defer s.mu.Unlock() - s.sessions[session.ID] = session -} - -// Get returns a session by ID. -func (s *SessionStore) Get(id string) (*Session, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - session, ok := s.sessions[id] - return session, ok -} - -// Delete removes a session by ID, returning it if found. -func (s *SessionStore) Delete(id string) (*Session, bool) { - s.mu.Lock() - defer s.mu.Unlock() - session, ok := s.sessions[id] - if ok { - delete(s.sessions, id) - } - return session, ok -} - -// All returns all sessions. -func (s *SessionStore) All() []*Session { - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]*Session, 0, len(s.sessions)) - for _, session := range s.sessions { - out = append(out, session) - } - return out -} - -// GenerateID creates a random 8-character hex session ID. -func GenerateID() string { - b := make([]byte, 4) - rand.Read(b) - return hex.EncodeToString(b) -} diff --git a/uts/proxy/test-proxy b/uts/proxy/test-proxy deleted file mode 100755 index a70f75f15..000000000 Binary files a/uts/proxy/test-proxy and /dev/null differ diff --git a/uts/proxy/ws_proxy.go b/uts/proxy/ws_proxy.go deleted file mode 100644 index 344a84898..000000000 --- a/uts/proxy/ws_proxy.go +++ /dev/null @@ -1,500 +0,0 @@ -package main - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "log" - "net/http" - "net/url" - "sync" - "time" - - "github.com/gorilla/websocket" -) - -var wsUpgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, -} - -// HandleWsProxy handles a WebSocket connection from the SDK client, -// proxying it to the upstream Ably realtime host. -func HandleWsProxy(session *Session, w http.ResponseWriter, r *http.Request) { - // Build query params map for logging and matching - queryParams := make(map[string]string) - for k, v := range r.URL.Query() { - if len(v) > 0 { - queryParams[k] = v[0] - } - } - - // Create WsConnection and register it - wc := NewWsConnection(0) - session.AddWsConn(wc) - defer func() { - wc.CancelTimers() - wc.MarkClosed() - session.RemoveWsConn(wc) - }() - - // Log ws_connect event - connectURL := fmt.Sprintf("ws://%s%s", r.Host, r.URL.String()) - session.EventLog.Append(Event{ - Type: "ws_connect", - URL: connectURL, - QueryParams: queryParams, - }) - - // Check rules for ws_connect match - matchEvent := MatchEvent{ - Type: "ws_connect", - Action: -1, - QueryParams: queryParams, - } - - rule, ruleIdx := session.FindMatchingRule(matchEvent) - if rule != nil { - session.FireRule(rule) - - switch rule.Action.Type { - case "refuse_connection": - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - RuleMatched: LogRuleMatch(rule, ruleIdx), - }) - http.Error(w, "connection refused by proxy rule", http.StatusBadGateway) - return - - case "accept_and_close": - clientConn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("session %s: failed to upgrade WS for accept_and_close: %v", session.ID, err) - return - } - closeCode := rule.Action.CloseCode - if closeCode <= 0 { - closeCode = websocket.CloseNormalClosure - } - msg := websocket.FormatCloseMessage(closeCode, "") - clientConn.WriteMessage(websocket.CloseMessage, msg) - clientConn.Close() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - CloseCode: closeCode, - RuleMatched: LogRuleMatch(rule, ruleIdx), - }) - return - } - // For other action types on ws_connect, fall through to normal proxying - } - - // Build upstream URL - if session.Target.RealtimeHost == "" { - http.Error(w, "no realtime host configured", http.StatusBadGateway) - return - } - - scheme := "wss" - if session.Target.Insecure { - scheme = "ws" - } - upstreamURL := url.URL{ - Scheme: scheme, - Host: session.Target.RealtimeHost, - Path: r.URL.Path, - RawQuery: r.URL.RawQuery, - } - - // Dial upstream - dialer := websocket.Dialer{} - if !session.Target.Insecure { - dialer.TLSClientConfig = &tls.Config{} - } - serverConn, _, err := dialer.Dial(upstreamURL.String(), nil) - if err != nil { - log.Printf("session %s: failed to dial upstream %s: %v", session.ID, upstreamURL.String(), err) - http.Error(w, fmt.Sprintf("failed to connect to upstream: %v", err), http.StatusBadGateway) - return - } - defer serverConn.Close() - - // Accept client WebSocket upgrade - clientConn, err := wsUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("session %s: failed to upgrade WS: %v", session.ID, err) - return - } - defer clientConn.Close() - - // Store connections - wc.ClientConn = clientConn - wc.ServerConn = serverConn - - // Schedule temporal triggers - scheduleTemporalTriggers(session, wc) - - // Relay frames between client and server - var wg sync.WaitGroup - wg.Add(2) - - // server → client relay - go func() { - defer wg.Done() - relayFrames(session, wc, serverConn, clientConn, "server_to_client", "ws_frame_to_client") - }() - - // client → server relay - go func() { - defer wg.Done() - relayFrames(session, wc, clientConn, serverConn, "client_to_server", "ws_frame_to_server") - }() - - wg.Wait() - - // Log disconnect if not already logged - if !wc.IsClosed() { - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "client", - }) - } -} - -// relayFrames reads frames from src and writes to dst, applying rules. -func relayFrames(session *Session, wc *WsConnection, src, dst *websocket.Conn, direction, matchType string) { - for { - if wc.IsClosed() { - return - } - - msgType, data, err := src.ReadMessage() - if err != nil { - if !wc.IsClosed() { - initiator := "client" - if direction == "server_to_client" { - initiator = "server" - } - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: initiator, - }) - wc.MarkClosed() - // Close the other side - dst.Close() - } - return - } - - // Check suppress_onwards flag - session.mu.Lock() - suppressed := false - if direction == "server_to_client" && session.suppressServerToClient { - suppressed = true - } else if direction == "client_to_server" && session.suppressClientToServer { - suppressed = true - } - session.mu.Unlock() - - if suppressed { - continue - } - - // Parse protocol message for rule matching and logging - pm := ParseProtocolMessage(data, msgType) - - // Log the frame (as JSON for readability, even if binary) - var logMsg json.RawMessage - if msgType == websocket.TextMessage { - logMsg = json.RawMessage(data) - } else { - // For binary frames, log the parsed summary - logMsg = mustMarshal(map[string]interface{}{ - "action": pm.Action, - "channel": pm.Channel, - "_binary": true, - }) - } - - // Build match event - matchEvent := MatchEvent{ - Type: matchType, - Action: pm.Action, - Channel: pm.Channel, - } - - // Find matching rule - rule, ruleIdx := session.FindMatchingRule(matchEvent) - - ruleLabel := LogRuleMatch(rule, ruleIdx) - - // Log the frame - session.EventLog.Append(Event{ - Type: "ws_frame", - Direction: direction, - Message: logMsg, - RuleMatched: ruleLabel, - }) - - if rule == nil { - // No rule matched — passthrough - if err := dst.WriteMessage(msgType, data); err != nil { - wc.MarkClosed() - return - } - continue - } - - // Execute rule action - session.FireRule(rule) - - switch rule.Action.Type { - case "passthrough": - if err := dst.WriteMessage(msgType, data); err != nil { - wc.MarkClosed() - return - } - - case "suppress": - // Don't forward - - case "delay": - time.Sleep(time.Duration(rule.Action.DelayMs) * time.Millisecond) - if err := dst.WriteMessage(msgType, data); err != nil { - wc.MarkClosed() - return - } - - case "inject_to_client": - // Send the injected message to client - if direction == "server_to_client" || direction == "client_to_server" { - clientConn := wc.ClientConn.(*websocket.Conn) - clientConn.WriteMessage(websocket.TextMessage, rule.Action.Message) - session.EventLog.Append(Event{ - Type: "ws_frame", - Direction: "server_to_client", - Message: rule.Action.Message, - Initiator: "proxy", - }) - } - // Also forward the original - if err := dst.WriteMessage(msgType, data); err != nil { - wc.MarkClosed() - return - } - - case "inject_to_client_and_close": - clientConn := wc.ClientConn.(*websocket.Conn) - clientConn.WriteMessage(websocket.TextMessage, rule.Action.Message) - session.EventLog.Append(Event{ - Type: "ws_frame", - Direction: "server_to_client", - Message: rule.Action.Message, - Initiator: "proxy", - }) - // Close - closeCode := rule.Action.CloseCode - if closeCode <= 0 { - closeCode = websocket.CloseNormalClosure - } - closeMsg := websocket.FormatCloseMessage(closeCode, "") - clientConn.WriteMessage(websocket.CloseMessage, closeMsg) - clientConn.UnderlyingConn().Close() - if serverConn, ok := wc.ServerConn.(*websocket.Conn); ok { - serverConn.UnderlyingConn().Close() - } - wc.MarkClosed() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - CloseCode: closeCode, - }) - return - - case "replace": - // Send replacement message instead of original - if err := dst.WriteMessage(websocket.TextMessage, rule.Action.Message); err != nil { - wc.MarkClosed() - return - } - - case "disconnect": - // Abrupt close - if clientConn, ok := wc.ClientConn.(*websocket.Conn); ok { - clientConn.UnderlyingConn().Close() - } - if serverConn, ok := wc.ServerConn.(*websocket.Conn); ok { - serverConn.UnderlyingConn().Close() - } - wc.MarkClosed() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - }) - return - - case "close": - closeCode := rule.Action.CloseCode - if closeCode <= 0 { - closeCode = websocket.CloseNormalClosure - } - if clientConn, ok := wc.ClientConn.(*websocket.Conn); ok { - closeMsg := websocket.FormatCloseMessage(closeCode, "") - clientConn.WriteMessage(websocket.CloseMessage, closeMsg) - clientConn.UnderlyingConn().Close() - } - if serverConn, ok := wc.ServerConn.(*websocket.Conn); ok { - serverConn.UnderlyingConn().Close() - } - wc.MarkClosed() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - CloseCode: closeCode, - }) - return - - case "suppress_onwards": - session.mu.Lock() - if direction == "server_to_client" { - session.suppressServerToClient = true - } else { - session.suppressClientToServer = true - } - session.mu.Unlock() - // Don't forward this frame either - - default: - // Unknown action — passthrough - if err := dst.WriteMessage(msgType, data); err != nil { - wc.MarkClosed() - return - } - } - } -} - -// scheduleTemporalTriggers sets up delay_after_ws_connect timers. -func scheduleTemporalTriggers(session *Session, wc *WsConnection) { - session.mu.Lock() - defer session.mu.Unlock() - - for _, rule := range session.Rules { - if rule.Match.Type != "delay_after_ws_connect" { - continue - } - - r := rule // capture for closure - delayMs := r.Match.DelayMs - if delayMs <= 0 { - delayMs = 0 - } - - timer := time.AfterFunc(time.Duration(delayMs)*time.Millisecond, func() { - if wc.IsClosed() { - return - } - log.Printf("session %s: temporal trigger fired (delay %dms): %s", session.ID, delayMs, r.Action.Type) - executeTemporalAction(session, wc, r) - session.FireRule(r) - }) - wc.AddTimer(timer) - } -} - -// executeTemporalAction executes an action from a temporal trigger. -func executeTemporalAction(session *Session, wc *WsConnection, rule *Rule) { - ruleLabel := rule.Comment - if ruleLabel == "" { - ruleLabel = "temporal-trigger" - } - - switch rule.Action.Type { - case "disconnect": - if clientConn, ok := wc.ClientConn.(*websocket.Conn); ok { - clientConn.UnderlyingConn().Close() - } - if serverConn, ok := wc.ServerConn.(*websocket.Conn); ok { - serverConn.UnderlyingConn().Close() - } - wc.MarkClosed() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - RuleMatched: &ruleLabel, - }) - - case "close": - closeCode := rule.Action.CloseCode - if closeCode <= 0 { - closeCode = websocket.CloseNormalClosure - } - if clientConn, ok := wc.ClientConn.(*websocket.Conn); ok { - closeMsg := websocket.FormatCloseMessage(closeCode, "") - clientConn.WriteMessage(websocket.CloseMessage, closeMsg) - clientConn.UnderlyingConn().Close() - } - if serverConn, ok := wc.ServerConn.(*websocket.Conn); ok { - serverConn.UnderlyingConn().Close() - } - wc.MarkClosed() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - CloseCode: closeCode, - RuleMatched: &ruleLabel, - }) - - case "inject_to_client": - if clientConn, ok := wc.ClientConn.(*websocket.Conn); ok { - clientConn.WriteMessage(websocket.TextMessage, rule.Action.Message) - session.EventLog.Append(Event{ - Type: "ws_frame", - Direction: "server_to_client", - Message: rule.Action.Message, - Initiator: "proxy", - RuleMatched: &ruleLabel, - }) - } - - case "inject_to_client_and_close": - if clientConn, ok := wc.ClientConn.(*websocket.Conn); ok { - clientConn.WriteMessage(websocket.TextMessage, rule.Action.Message) - session.EventLog.Append(Event{ - Type: "ws_frame", - Direction: "server_to_client", - Message: rule.Action.Message, - Initiator: "proxy", - RuleMatched: &ruleLabel, - }) - closeCode := rule.Action.CloseCode - if closeCode <= 0 { - closeCode = websocket.CloseNormalClosure - } - closeMsg := websocket.FormatCloseMessage(closeCode, "") - clientConn.WriteMessage(websocket.CloseMessage, closeMsg) - clientConn.UnderlyingConn().Close() - } - if serverConn, ok := wc.ServerConn.(*websocket.Conn); ok { - serverConn.UnderlyingConn().Close() - } - wc.MarkClosed() - session.EventLog.Append(Event{ - Type: "ws_disconnect", - Initiator: "proxy", - RuleMatched: &ruleLabel, - }) - - case "suppress_onwards": - session.mu.Lock() - session.suppressServerToClient = true - session.mu.Unlock() - session.EventLog.Append(Event{ - Type: "action", - Initiator: "proxy", - Message: mustMarshal(map[string]string{"type": "suppress_onwards", "direction": "server_to_client"}), - RuleMatched: &ruleLabel, - }) - } -} diff --git a/uts/realtime/integration/auth.md b/uts/realtime/integration/auth.md index 663ef3757..b6e6ce509 100644 --- a/uts/realtime/integration/auth.md +++ b/uts/realtime/integration/auth.md @@ -11,13 +11,13 @@ Tests use JWTs generated using a third-party JWT library, signed with the app ke ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -25,7 +25,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -33,6 +33,8 @@ AFTER ALL TESTS: ## RTC8a - In-band reauthorization on CONNECTED client +**Test ID**: `realtime/integration/RTC8a/in-band-reauth-connected-0` + **Spec requirement:** RTC8a - When `auth.authorize()` is called on a CONNECTED realtime client, it sends an AUTH protocol message with the new token rather than disconnecting/reconnecting. Tests that calling authorize() on a connected client succeeds and the connection remains connected (UPDATE event, not disconnect/reconnect). @@ -48,7 +50,7 @@ auth_callback = FUNCTION(params): client = Realtime(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false )) ``` @@ -94,6 +96,8 @@ AWAIT client.close() ## RTC8c - authorize() from INITIALIZED initiates connection +**Test ID**: `realtime/integration/RTC8c/authorize-initiates-connection-0` + **Spec requirement:** RTC8c - When `auth.authorize()` is called on a client in INITIALIZED state, it should initiate the connection. Tests that calling authorize() on an unconnected client triggers a connection. @@ -109,7 +113,7 @@ auth_callback = FUNCTION(params): client = Realtime(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false )) ``` @@ -139,6 +143,8 @@ AWAIT client.close() ## RSA8 - Token auth on realtime connection +**Test ID**: `realtime/integration/RSA8/token-auth-connect-0` + **Spec requirement:** RSA8 - Realtime client can connect using token authentication via an authCallback that returns JWTs. Tests that a realtime client can connect using JWT-based token auth. @@ -154,7 +160,7 @@ auth_callback = FUNCTION(params): client = Realtime(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false )) ``` @@ -186,6 +192,8 @@ Tests that: ### Test 1: Matching clientId succeeds +**Test ID**: `realtime/integration/RSA7/matching-clientid-succeeds-0` + #### Setup ```pseudo test_client_id = "test-client-" + random_id() @@ -201,7 +209,7 @@ auth_callback = FUNCTION(params): client = Realtime(options: ClientOptions( authCallback: auth_callback, clientId: test_client_id, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false )) ``` @@ -222,6 +230,8 @@ AWAIT client.close() ### Test 2: Mismatched clientId fails +**Test ID**: `realtime/integration/RSA7/mismatched-clientid-fails-1` + #### Setup ```pseudo auth_callback = FUNCTION(params): @@ -241,7 +251,7 @@ auth_callback = FUNCTION(params): EXPECT THROW creating Realtime(options: ClientOptions( authCallback: auth_callback, clientId: "wrong-client-id", - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false )) ``` diff --git a/uts/realtime/integration/auth/token_renewal_test.md b/uts/realtime/integration/auth/token_renewal_test.md index 5fc99f5b9..34e332085 100644 --- a/uts/realtime/integration/auth/token_renewal_test.md +++ b/uts/realtime/integration/auth/token_renewal_test.md @@ -36,6 +36,8 @@ AFTER ALL TESTS: ## RSA4b, RTN14b - Token renewal on expiry +**Test ID**: `realtime/integration/RSA4b/token-renewal-on-expiry-0` + | Spec | Requirement | |------|-------------| | RSA4b | Client with renewable token automatically reissues on token error | @@ -70,7 +72,7 @@ auth_callback = FUNCTION(params): client = Realtime(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) diff --git a/uts/realtime/integration/auth/token_request_test.md b/uts/realtime/integration/auth/token_request_test.md index da40b1a6a..5e5f966bc 100644 --- a/uts/realtime/integration/auth/token_request_test.md +++ b/uts/realtime/integration/auth/token_request_test.md @@ -31,6 +31,8 @@ AFTER ALL TESTS: ## RSA9a, RSA9g - createTokenRequest produces server-accepted token +**Test ID**: `realtime/integration/RSA9a/token-request-server-accepted-0` + | Spec | Requirement | |------|-------------| | RSA9a | Returns a signed TokenRequest that can be used to obtain a token | @@ -46,7 +48,7 @@ server accepted the TokenRequest. # Client A creates TokenRequests using the API key creator = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Client B connects using TokenRequests from client A @@ -54,7 +56,7 @@ client = Realtime(options: ClientOptions( authCallback: FUNCTION(params): token_request = AWAIT creator.auth.createTokenRequest() RETURN token_request, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -80,6 +82,8 @@ CLOSE_CLIENT(client) ## RSA9 - createTokenRequest with clientId +**Test ID**: `realtime/integration/RSA9/token-request-with-clientid-0` + | Spec | Requirement | |------|-------------| | RSA9 | createTokenRequest accepts TokenParams including clientId | @@ -93,7 +97,7 @@ test_client_id = "token-request-client-" + random_id() creator = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) client = Realtime(options: ClientOptions( @@ -103,7 +107,7 @@ client = Realtime(options: ClientOptions( ) RETURN token_request, clientId: test_client_id, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) diff --git a/uts/realtime/integration/channel_history_test.md b/uts/realtime/integration/channel_history_test.md index 6f3eb032d..8b9d76c14 100644 --- a/uts/realtime/integration/channel_history_test.md +++ b/uts/realtime/integration/channel_history_test.md @@ -5,15 +5,22 @@ Spec points: `RTL10d` ## Test Type Integration test against Ably Sandbox endpoint +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -21,7 +28,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -29,6 +36,8 @@ AFTER ALL TESTS: ## RTL10d - History contains messages published by another client +**Test ID**: `realtime/integration/RTL10d/history-cross-client-0` + | Spec | Requirement | |------|-------------| | RTL10d | A test should exist that publishes messages from one client, and upon confirmation of message delivery, a history request should be made on another client to ensure all messages are available | @@ -42,12 +51,12 @@ channel_name = "history-RTL10d-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) publisher.connect() diff --git a/uts/realtime/integration/channels/channel_attach_test.md b/uts/realtime/integration/channels/channel_attach_test.md index 1d7f43975..f897ff39f 100644 --- a/uts/realtime/integration/channels/channel_attach_test.md +++ b/uts/realtime/integration/channels/channel_attach_test.md @@ -35,6 +35,8 @@ AFTER ALL TESTS: ## RTL4c - Attach succeeds +**Test ID**: `realtime/integration/RTL4c/attach-succeeds-0` + | Spec | Requirement | |------|-------------| | RTL4c | An ATTACH ProtocolMessage is sent, state transitions to ATTACHING, then ATTACHED on confirmation | @@ -49,7 +51,7 @@ channel_name = "attach-RTL4c-" + random_id() client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -78,6 +80,8 @@ CLOSE_CLIENT(client) ## RTL5d - Detach succeeds +**Test ID**: `realtime/integration/RTL5d/detach-succeeds-0` + | Spec | Requirement | |------|-------------| | RTL5d | A DETACH ProtocolMessage is sent, state transitions to DETACHING, then DETACHED on confirmation | @@ -92,7 +96,7 @@ channel_name = "detach-RTL5d-" + random_id() client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -121,6 +125,8 @@ CLOSE_CLIENT(client) ## RTL14 - Insufficient capability causes channel FAILED +**Test ID**: `realtime/integration/RTL14/insufficient-capability-failed-0` + | Spec | Requirement | |------|-------------| | RTL14 | A channel-scoped ERROR transitions the channel to FAILED | @@ -137,7 +143,7 @@ channel_name = "publish-not-allowed-" + random_id() # Use key with subscribe-only capability client = Realtime(options: ClientOptions( key: subscribe_only_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) diff --git a/uts/realtime/integration/channels/channel_publish_test.md b/uts/realtime/integration/channels/channel_publish_test.md index 36ad1b971..43be9f0b3 100644 --- a/uts/realtime/integration/channels/channel_publish_test.md +++ b/uts/realtime/integration/channels/channel_publish_test.md @@ -5,6 +5,13 @@ Spec points: `RTL6`, `RTL6f`, `RSL4`, `RSL6`, `RSL6a2` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose End-to-end verification that messages published on one realtime connection are @@ -32,6 +39,8 @@ AFTER ALL TESTS: ## RTL6, RSL4d2 - String data round-trip +**Test ID**: `realtime/integration/RTL6/string-data-roundtrip-0` + | Spec | Requirement | |------|-------------| | RTL6 | RealtimeChannel#publish sends messages to Ably | @@ -46,16 +55,16 @@ channel_name = "publish-string-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) publisher.connect() @@ -98,6 +107,8 @@ CLOSE_CLIENT(subscriber) ## RTL6, RSL4d3 - JSON object data round-trip +**Test ID**: `realtime/integration/RTL6/json-data-roundtrip-1` + | Spec | Requirement | |------|-------------| | RTL6 | RealtimeChannel#publish sends messages to Ably | @@ -112,16 +123,16 @@ channel_name = "publish-json-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) publisher.connect() @@ -166,6 +177,8 @@ CLOSE_CLIENT(subscriber) ## RTL6, RSL4d1 - Binary data round-trip +**Test ID**: `realtime/integration/RTL6/binary-data-roundtrip-2` + | Spec | Requirement | |------|-------------| | RTL6 | RealtimeChannel#publish sends messages to Ably | @@ -182,16 +195,16 @@ channel_name = "publish-binary-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) publisher.connect() @@ -236,6 +249,8 @@ CLOSE_CLIENT(subscriber) ## RTL6f - connectionId matches publisher +**Test ID**: `realtime/integration/RTL6f/connectionid-matches-publisher-0` + | Spec | Requirement | |------|-------------| | RTL6f | Message#connectionId should match the current Connection#id for all published messages | @@ -249,16 +264,16 @@ channel_name = "publish-connid-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) publisher.connect() @@ -300,6 +315,8 @@ CLOSE_CLIENT(subscriber) ## RSL6a2 - Message extras round-trip +**Test ID**: `realtime/integration/RSL6a2/message-extras-roundtrip-0` + | Spec | Requirement | |------|-------------| | RSL6a2 | Tests must exist to ensure interoperability for the extras field | @@ -313,16 +330,16 @@ channel_name = "pushenabled:publish-extras-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) publisher.connect() diff --git a/uts/realtime/integration/channels/channel_subscribe_test.md b/uts/realtime/integration/channels/channel_subscribe_test.md index 71e897613..4ce24dd71 100644 --- a/uts/realtime/integration/channels/channel_subscribe_test.md +++ b/uts/realtime/integration/channels/channel_subscribe_test.md @@ -32,6 +32,8 @@ AFTER ALL TESTS: ## RTL7a - Subscribe with no name filter receives all messages +**Test ID**: `realtime/integration/RTL7a/subscribe-all-messages-0` + | Spec | Requirement | |------|-------------| | RTL7a | Subscribe with a single listener argument subscribes to all messages | @@ -45,14 +47,14 @@ channel_name = "subscribe-all-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -100,6 +102,8 @@ CLOSE_CLIENT(subscriber) ## RTL7b - Subscribe with name filter receives only matching messages +**Test ID**: `realtime/integration/RTL7b/subscribe-filtered-by-name-0` + | Spec | Requirement | |------|-------------| | RTL7b | Subscribe with a name argument subscribes only to messages with that name | @@ -113,14 +117,14 @@ channel_name = "subscribe-filtered-" + random_id() publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -178,6 +182,8 @@ CLOSE_CLIENT(subscriber) ## RTL7 - Bidirectional message flow +**Test ID**: `realtime/integration/RTL7/bidirectional-message-flow-0` + | Spec | Requirement | |------|-------------| | RTL7 | RealtimeChannel#subscribe receives messages from any publisher on the channel | @@ -191,7 +197,7 @@ channel_name = "subscribe-bidir-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false, clientId: "client-a" @@ -199,7 +205,7 @@ client_a = Realtime(options: ClientOptions( client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false, clientId: "client-b" diff --git a/uts/realtime/integration/connection/connection_failures_test.md b/uts/realtime/integration/connection/connection_failures_test.md index d2cc5baaf..5f370f9f7 100644 --- a/uts/realtime/integration/connection/connection_failures_test.md +++ b/uts/realtime/integration/connection/connection_failures_test.md @@ -32,6 +32,8 @@ AFTER ALL TESTS: ## RTN14a - Invalid API key causes FAILED +**Test ID**: `realtime/integration/RTN14a/invalid-key-failed-0` + | Spec | Requirement | |------|-------------| | RTN14a | If an API key is invalid, the connection transitions to FAILED | @@ -44,7 +46,7 @@ the error set on Connection#errorReason. ```pseudo client = Realtime(options: ClientOptions( key: "invalid.key:secret", - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -71,6 +73,8 @@ CLOSE_CLIENT(client) ## RTN14g - Revoked key causes FAILED +**Test ID**: `realtime/integration/RTN14g/revoked-key-failed-0` + | Spec | Requirement | |------|-------------| | RTN14g | An ERROR ProtocolMessage with an empty channel for reasons other than token error causes FAILED | @@ -88,7 +92,7 @@ rejects the connection with a 404 or 401 error, which is not a token error # Use a key with a valid format but non-existent app client = Realtime(options: ClientOptions( key: "nonexistent.keyname:keysecret", - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) diff --git a/uts/realtime/integration/connection_lifecycle_test.md b/uts/realtime/integration/connection_lifecycle_test.md index 11b3b17f8..4cd36949f 100644 --- a/uts/realtime/integration/connection_lifecycle_test.md +++ b/uts/realtime/integration/connection_lifecycle_test.md @@ -7,14 +7,14 @@ Integration test against Ably Sandbox endpoint ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: # Provision test app - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -23,7 +23,7 @@ BEFORE ALL TESTS: AFTER ALL TESTS: # Clean up test app - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -31,6 +31,8 @@ AFTER ALL TESTS: ## RTN4b, RTN21 - Successful connection establishment +**Test ID**: `realtime/integration/RTN4b/successful-connection-0` + | Spec | Requirement | |------|-------------| | RTN4b | When a connection is initiated, it transitions INITIALIZED → CONNECTING → CONNECTED | @@ -43,7 +45,7 @@ Tests that a Realtime client can successfully connect to Ably via WebSocket. ```pseudo client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -90,6 +92,8 @@ CLOSE_CLIENT(client) ## RTN4c, RTN12, RTN12a - Graceful connection close +**Test ID**: `realtime/integration/RTN4c/graceful-close-0` + | Spec | Requirement | |------|-------------| | RTN4c | Normal disconnection: CONNECTED → CLOSING → CLOSED | @@ -103,7 +107,7 @@ Tests that a connected client can gracefully close the connection. ```pseudo client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Establish connection first @@ -145,6 +149,8 @@ ASSERT client.connection.key IS null ## RTN11, RTN4b - Connect and reconnect cycle +**Test ID**: `realtime/integration/RTN11/connect-reconnect-cycle-0` + | Spec | Requirement | |------|-------------| | RTN11 | Connection.connect() explicitly opens connection | @@ -157,7 +163,7 @@ Tests that a client can be closed and reconnected multiple times. ```pseudo client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false # Don't connect automatically )) ``` diff --git a/uts/realtime/integration/delta_decoding_test.md b/uts/realtime/integration/delta_decoding_test.md index 473901922..368851227 100644 --- a/uts/realtime/integration/delta_decoding_test.md +++ b/uts/realtime/integration/delta_decoding_test.md @@ -5,6 +5,13 @@ Spec points: `PC3`, `PC3a`, `RTL18`, `RTL18b`, `RTL18c`, `RTL19b`, `RTL20` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose End-to-end verification of vcdiff delta decoding using real connections against the @@ -28,15 +35,15 @@ order compared to `VD2a`. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. -**Note:** `useBinaryProtocol: false` is required if the SDK does not implement msgpack. +**Note:** `useBinaryProtocol: PROTOCOL == "msgpack"` is used so tests run with both protocols (see Protocol Variants). ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -44,7 +51,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -69,6 +76,8 @@ small vcdiff deltas rather than sending full messages. ## PC3 - Delta plugin decodes messages end-to-end +**Test ID**: `realtime/integration/PC3/delta-decode-end-to-end-0` + **Spec requirement:** A plugin provided with the PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages. @@ -90,8 +99,8 @@ counting_decoder = VCDiffDecoder( client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", plugins: { vcdiff: counting_decoder } )) ``` @@ -144,6 +153,8 @@ client.close() ## RTL19b - Dissimilar payloads received without delta encoding +**Test ID**: `realtime/integration/RTL19b/dissimilar-payloads-no-delta-0` + **Spec requirement:** In the case of a non-delta message, the resulting `data` value is stored as the base payload. @@ -170,8 +181,8 @@ counting_decoder = VCDiffDecoder( client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", plugins: { vcdiff: counting_decoder } )) @@ -231,6 +242,8 @@ client.close() ## PC3 - No deltas without delta channel param +**Test ID**: `realtime/integration/PC3/no-deltas-without-param-1` + **Spec requirement:** The vcdiff plugin is only used when the channel is configured to request delta compression from the server. @@ -250,8 +263,8 @@ counting_decoder = VCDiffDecoder( client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", plugins: { vcdiff: counting_decoder } )) ``` @@ -295,6 +308,8 @@ client.close() ## RTL18, RTL18b, RTL18c, RTL20 - Recovery after last message ID mismatch +**Test ID**: `realtime/integration/RTL18/recovery-message-id-mismatch-0` + | Spec | Requirement | |------|-------------| | RTL18 | Decode failure triggers automatic recovery | @@ -322,8 +337,8 @@ counting_decoder = VCDiffDecoder( client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", plugins: { vcdiff: counting_decoder } )) ``` @@ -398,6 +413,8 @@ client.close() ## RTL18, RTL18c - Recovery after decode failure +**Test ID**: `realtime/integration/RTL18/recovery-decode-failure-1` + | Spec | Requirement | |------|-------------| | RTL18 | Decode failure triggers automatic recovery | @@ -417,8 +434,8 @@ failing_decoder = FailingVCDiffDecoder() client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false, + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack", plugins: { vcdiff: failing_decoder } )) ``` @@ -474,6 +491,8 @@ client.close() ## PC3 - No plugin causes FAILED state +**Test ID**: `realtime/integration/PC3/no-plugin-causes-failed-2` + **Spec requirement:** Without a vcdiff decoder plugin, vcdiff-encoded messages cannot be decoded and the channel should transition to FAILED. @@ -494,15 +513,15 @@ channel_name = "delta-no-plugin-" + random_id() # Subscriber — no vcdiff plugin, but requests delta channel param subscriber = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) # Publisher — separate connection, publishes without delta param publisher = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) ``` diff --git a/uts/realtime/integration/helpers/proxy.md b/uts/realtime/integration/helpers/proxy.md index a157d7196..18274311e 100644 --- a/uts/realtime/integration/helpers/proxy.md +++ b/uts/realtime/integration/helpers/proxy.md @@ -2,7 +2,7 @@ ## Overview -The Ably test proxy is a programmable HTTP/WebSocket proxy (Go, at `uts/test/proxy/`) that sits between the SDK under test and the Ably sandbox. It transparently forwards traffic by default, but can be configured with rules to inject faults — dropped connections, modified responses, injected protocol messages, delayed frames, etc. +The Ably test proxy is a programmable HTTP/WebSocket proxy ([ably/uts-proxy](https://github.com/ably/uts-proxy)) that sits between the SDK under test and the Ably sandbox. It transparently forwards traffic by default, but can be configured with rules to inject faults — dropped connections, modified responses, injected protocol messages, delayed frames, etc. Proxy integration tests use this to verify fault-handling behaviour against the real Ably backend, providing higher confidence than unit tests with mocked transports. @@ -19,7 +19,7 @@ Proxy integration tests use this to verify fault-handling behaviour against the ```pseudo # 1. Create a proxy session with rules session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [ ...rules... ] ) @@ -66,7 +66,7 @@ interface ProxySession: close() function create_proxy_session( - endpoint: String, # e.g. "sandbox" → resolves to sandbox-realtime.ably.io / sandbox-rest.ably.io + endpoint: String, # e.g. "nonprod:sandbox" → resolves to sandbox.realtime.ably-nonprod.net port: Int, rules?: List, timeoutMs?: Int # Session auto-cleanup timeout (default 30000) diff --git a/uts/realtime/integration/mutable_messages_test.md b/uts/realtime/integration/mutable_messages_test.md index 26f2e2ead..78989a478 100644 --- a/uts/realtime/integration/mutable_messages_test.md +++ b/uts/realtime/integration/mutable_messages_test.md @@ -5,6 +5,13 @@ Spec points: `RTL28`, `RTL31`, `RTL32`, `RTAN1`, `RTAN2`, `RTAN4` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose End-to-end verification of mutable messages and annotations over realtime @@ -19,15 +26,15 @@ integration tests (`rest/integration/mutable_messages.md`) by verifying: ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. -**Note:** `useBinaryProtocol: false` is required if the SDK does not implement msgpack. +**Note:** `useBinaryProtocol: PROTOCOL == "msgpack"` is used so tests run with both protocols (see Protocol Variants). ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -35,13 +42,13 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` ### Notes -- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) -- All clients use `endpoint: "sandbox"` +- All clients use `useBinaryProtocol: PROTOCOL == "msgpack"` (see Protocol Variants) +- All clients use `endpoint: "nonprod:sandbox"` - All channel names use the `mutable:` namespace prefix — the test app setup configures the `mutable` namespace with `mutableMessages: true` @@ -49,6 +56,8 @@ AFTER ALL TESTS: ## RTL32 — Update a message via realtime and observe on subscriber +**Test ID**: `realtime/integration/RTL32/update-message-observed-0` + **Spec requirement:** RTL32b1 — `updateMessage()` sends a MESSAGE ProtocolMessage with `MESSAGE_UPDATE` action. RTL32d — returns `UpdateDeleteResult` from ACK. @@ -61,14 +70,14 @@ channel_name = "mutable:rt-update-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_a.connect() @@ -154,6 +163,8 @@ AWAIT client_b.close() ## RTL32 — Delete a message via realtime and observe on subscriber +**Test ID**: `realtime/integration/RTL32/delete-message-observed-1` + **Spec requirement:** RTL32b1 — `deleteMessage()` sends a MESSAGE ProtocolMessage with `MESSAGE_DELETE` action. @@ -166,14 +177,14 @@ channel_name = "mutable:rt-delete-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_a.connect() @@ -243,6 +254,8 @@ AWAIT client_b.close() ## RTL32 — Append to a message via realtime and observe on subscriber +**Test ID**: `realtime/integration/RTL32/append-message-observed-2` + **Spec requirement:** RTL32b1 — `appendMessage()` sends a MESSAGE ProtocolMessage with `MESSAGE_APPEND` action. @@ -252,14 +265,14 @@ channel_name = "mutable:rt-append-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_a.connect() @@ -333,6 +346,8 @@ AWAIT client_b.close() ## RTL32 — Full mutation lifecycle: update, append, delete observed in sequence +**Test ID**: `realtime/integration/RTL32/full-mutation-lifecycle-3` + **Spec requirement:** RTL32b1, RTL32d — all three mutation types delivered in order. Tests that a subscriber receives the complete sequence of mutation events @@ -344,14 +359,14 @@ channel_name = "mutable:rt-lifecycle-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_a.connect() @@ -458,6 +473,8 @@ AWAIT client_b.close() ## RTL28, RTL31 — getMessage and getMessageVersions from realtime channel +**Test ID**: `realtime/integration/RTL28/get-message-and-versions-0` + **Spec requirement:** RTL28 — `RealtimeChannel#getMessage` same as `RestChannel#getMessage`. RTL31 — `RealtimeChannel#getMessageVersions` same as `RestChannel#getMessageVersions`. @@ -470,8 +487,8 @@ channel_name = "mutable:rt-get-versions-" + random_id() client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client.connect() @@ -547,6 +564,8 @@ AWAIT client.close() ## RTAN1, RTAN2, RTAN4 — Annotation publish, subscribe, and delete via realtime +**Test ID**: `realtime/integration/RTAN1/annotation-publish-delete-0` + **Spec requirement:** RTAN1c — publish sends ANNOTATION ProtocolMessage. RTAN2a — delete sends ANNOTATION_DELETE. RTAN4b — annotations delivered to subscribers. @@ -560,14 +579,14 @@ channel_name = "mutable:rt-annotations-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_a.connect() @@ -671,6 +690,8 @@ AWAIT client_b.close() ## RTAN4c — Annotation subscribe with type filtering +**Test ID**: `realtime/integration/RTAN4c/annotation-type-filtering-0` + **Spec requirement:** RTAN4c — subscribe with a `type` filter delivers only annotations whose type matches. @@ -683,14 +704,14 @@ channel_name = "mutable:rt-ann-filter-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_a.connect() @@ -788,6 +809,8 @@ AWAIT client_b.close() ## RTAN4d — Annotation subscribe implicitly attaches channel +**Test ID**: `realtime/integration/RTAN4d/annotation-implicit-attach-0` + **Spec requirement:** RTAN4d — subscribe has the same connection and channel state preconditions as `RealtimeChannel#subscribe`, including implicit attach. @@ -800,8 +823,8 @@ channel_name = "mutable:rt-ann-implicit-attach-" + random_id() client = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client.connect() diff --git a/uts/realtime/integration/presence/presence_sync_test.md b/uts/realtime/integration/presence/presence_sync_test.md index c95649bb2..e7f30ee62 100644 --- a/uts/realtime/integration/presence/presence_sync_test.md +++ b/uts/realtime/integration/presence/presence_sync_test.md @@ -35,6 +35,8 @@ AFTER ALL TESTS: ## RTP2, RTP11a - Presence SYNC delivers existing members +**Test ID**: `realtime/integration/RTP2/sync-delivers-members-0` + | Spec | Requirement | |------|-------------| | RTP2 | A PresenceMap is maintained via SYNC | @@ -50,7 +52,7 @@ channel_name = "presence-sync-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "sync-member-a", autoConnect: false, useBinaryProtocol: false @@ -58,7 +60,7 @@ client_a = Realtime(options: ClientOptions( client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) @@ -100,6 +102,8 @@ CLOSE_CLIENT(client_b) ## RTP2 - Presence SYNC with multiple members +**Test ID**: `realtime/integration/RTP2/sync-multiple-members-1` + | Spec | Requirement | |------|-------------| | RTP2 | PresenceMap maintained via SYNC contains all present members | @@ -114,14 +118,14 @@ member_count = 10 client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", autoConnect: false, useBinaryProtocol: false )) diff --git a/uts/realtime/integration/presence_lifecycle_test.md b/uts/realtime/integration/presence_lifecycle_test.md index b5baa6461..834805cd0 100644 --- a/uts/realtime/integration/presence_lifecycle_test.md +++ b/uts/realtime/integration/presence_lifecycle_test.md @@ -5,6 +5,13 @@ Spec points: `RTP4`, `RTP6`, `RTP8`, `RTP9`, `RTP10`, `RTP11a` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose End-to-end verification of the realtime presence lifecycle using two connections @@ -16,15 +23,15 @@ broadcasts presence events, delivers SYNC data, and maintains presence state. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. -**Note:** `useBinaryProtocol: false` is required if the SDK does not implement msgpack. +**Note:** `useBinaryProtocol: PROTOCOL == "msgpack"` is used so tests run with both protocols (see Protocol Variants). ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -32,7 +39,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -40,6 +47,8 @@ AFTER ALL TESTS: ## RTP4, RTP6, RTP11a - Bulk enterClient observed on different connection +**Test ID**: `realtime/integration/RTP4/bulk-enter-observed-0` + **Spec requirement:** Enter multiple members on connection A, verify they are observed on connection B via subscribe (RTP6) and get() after sync (RTP11a). This is the integration equivalent of the RTP4 unit test. @@ -54,14 +63,14 @@ member_count = 50 client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) ``` @@ -130,6 +139,8 @@ AWAIT client_b.close() ## RTP8, RTP9, RTP10 - Enter, update, leave lifecycle +**Test ID**: `realtime/integration/RTP8/enter-update-leave-lifecycle-0` + **Spec requirement:** Verify the complete presence lifecycle: enter populates the presence set (RTP8), update modifies the data (RTP9), and leave removes the member (RTP10). All transitions are observed on a separate connection. @@ -140,15 +151,15 @@ channel_name = "presence-lifecycle-" + random_id() client_a = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "lifecycle-client", - useBinaryProtocol: false + useBinaryProtocol: PROTOCOL == "msgpack" )) client_b = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) ``` diff --git a/uts/realtime/integration/proxy/auth_reauth.md b/uts/realtime/integration/proxy/auth_reauth.md new file mode 100644 index 000000000..6d704c966 --- /dev/null +++ b/uts/realtime/integration/proxy/auth_reauth.md @@ -0,0 +1,202 @@ +# Auth Re-authorization Proxy Integration Tests + +Spec points: `RTN22`, `RTC8a` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint. + +Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the SDK communicates with the real Ably backend. See `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details. + +Corresponding unit tests: +- `uts/test/realtime/unit/connection/server_initiated_reauth_test.md` (RTN22, RTN22a) +- `uts/test/realtime/unit/auth/realtime_authorize.md` (RTC8a, RTC8a1) +- `uts/test/realtime/unit/auth/connection_auth_test.md` (RSA4c3 covers RTN22 reauth failure while CONNECTED) + +## Sandbox Setup + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +## Port Allocation + +Each test allocates a unique proxy port to avoid conflicts: + +```pseudo +BEFORE ALL TESTS: + port_base = allocate_port_range(count: 1) + # Tests use port_base + 0 +``` + +--- + +## Test 26: RTN22/RTC8a -- Server-initiated re-authentication + +**Test ID**: `realtime/proxy/RTN22/server-initiated-reauth-0` + +| Spec | Requirement | +|------|-------------| +| RTN22 | Ably can request that a connected client re-authenticates by sending an AUTH ProtocolMessage. The client must then immediately start a new authentication process. | +| RTC8a | If the connection is CONNECTED and Ably requests re-authentication, the client must obtain a new token, then send an AUTH ProtocolMessage to Ably with an auth attribute containing an AuthDetails object with the token string. | + +Tests that when the proxy injects a server-initiated AUTH ProtocolMessage (action 17) into an established connection, the SDK re-authenticates via the authCallback and sends an AUTH message back to the server, all while remaining CONNECTED. + +**Unit test counterpart:** `server_initiated_reauth_test.md` > RTN22 + +### Setup + +**Proxy rules:** None (passthrough). The AUTH injection is triggered imperatively after the SDK connects. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 0, + rules: [] +) +``` + +**SDK config:** Use authCallback so re-authentication can be observed. The callback generates a JWT token from the sandbox key parts. + +```pseudo +auth_callback_count = 0 +key_name, key_secret = get_key_parts(api_key) + +auth_callback = FUNCTION(params, callback): + auth_callback_count = auth_callback_count + 1 + # Generate a JWT token signed with the sandbox key + jwt = generate_jwt(key_name: key_name, key_secret: key_secret) + callback(null, jwt) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "localhost", + port: port_base + 0, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Record identity and auth state before injection +original_connection_id = client.connection.id +original_auth_callback_count = auth_callback_count +ASSERT original_connection_id IS NOT null +ASSERT original_auth_callback_count >= 1 + +# Record state changes from this point +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Inject a server-initiated AUTH ProtocolMessage (action 17) +# This simulates Ably requesting re-authentication +AWAIT session.triggerAction({ + type: "inject_to_client", + message: { action: 17 } +}) + +# Wait for the SDK to process the AUTH and send its response +# The authCallback should be invoked, and the SDK should send AUTH back. +# Allow time for the token request round-trip to the sandbox. +AWAIT pollUntil( + CONDITION: auth_callback_count > original_auth_callback_count, + timeout: 15s +) +``` + +### Assertions + +```pseudo +# authCallback was called again (re-authentication triggered) +ASSERT auth_callback_count == original_auth_callback_count + 1 + +# Connection remains CONNECTED (re-auth does not disrupt the connection) +ASSERT client.connection.state == ConnectionState.connected + +# Connection ID is unchanged (no reconnection occurred) +ASSERT client.connection.id == original_connection_id + +# No state transitions away from CONNECTED occurred +non_connected_changes = state_changes.filter( + s => s != "connected" +) +ASSERT non_connected_changes.length == 0 + +# Proxy log shows the SDK sent an AUTH frame (action 17) from client to server +log = AWAIT session.getLog() +client_auth_frames = log.filter( + e => e.type == "ws_frame" + AND e.direction == "client_to_server" + AND (e.message.action == 17 OR e.message.action == "AUTH") + AND e.message.auth IS NOT null +) +ASSERT client_auth_frames.length >= 1 +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +### Note + +After the SDK sends the AUTH response, the server may respond with a CONNECTED message (connection update per RTN24). However, since the injected AUTH was not a genuine server request (it was injected by the proxy), the real Ably server may not respond as expected. The key assertions are that the SDK's auth machinery was triggered (authCallback invoked, AUTH frame sent) and that the connection was not disrupted. + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls use generous timeouts since real network traffic through the proxy is involved: +- Initial CONNECTED: 15 seconds (auth + transport setup through proxy) +- Auth callback re-invocation: 15 seconds (allows for token request round-trip to sandbox) +- CLOSED (cleanup): 10 seconds + +### Error Handling + +If any test fails to reach an expected state: +- Log the connection `errorReason` +- Log all recorded `state_changes` +- Retrieve and log the proxy session event log via `session.get_log()` +- Fail with diagnostic information + +### Cleanup + +Always clean up both the SDK client and the proxy session: + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [connected, connecting, disconnected]: + client.connection.close() + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s + IF session IS NOT null: + session.close() +``` diff --git a/uts/realtime/integration/proxy/channel_faults.md b/uts/realtime/integration/proxy/channel_faults.md index b25f908c2..1035d6772 100644 --- a/uts/realtime/integration/proxy/channel_faults.md +++ b/uts/realtime/integration/proxy/channel_faults.md @@ -1,6 +1,6 @@ # Channel Fault Proxy Integration Tests -Spec points: `RTL4f`, `RTL4h`, `RTL5f`, `RTL13a`, `RTL14` +Spec points: `RTL4f`, `RTL5f`, `RTL13a`, `RTL14`, `RTL12`, `RTL3d` ## Test Type @@ -12,7 +12,7 @@ See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastr ## Corresponding Unit Tests -- `uts/test/realtime/unit/channels/channel_attach.md` -- RTL4f (attach timeout), RTL4h (server error on attach) +- `uts/test/realtime/unit/channels/channel_attach.md` -- RTL4f (attach timeout) - `uts/test/realtime/unit/channels/channel_detach.md` -- RTL5f (detach timeout) - `uts/test/realtime/unit/channels/channel_server_initiated_detach.md` -- RTL13a (unsolicited DETACHED triggers reattach) - `uts/test/realtime/unit/channels/channel_error.md` -- RTL14 (channel ERROR transitions to FAILED) @@ -26,7 +26,7 @@ Tests run against the Ably Sandbox via a programmable proxy. ```pseudo BEFORE ALL TESTS: # Provision test app - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -35,7 +35,7 @@ BEFORE ALL TESTS: AFTER ALL TESTS: # Clean up test app - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -55,6 +55,8 @@ AFTER EACH TEST: ## Test 13: RTL4f -- Attach timeout (server doesn't respond) +**Test ID**: `realtime/proxy/RTL4f/attach-timeout-suppressed-0` + | Spec | Requirement | |------|-------------| | RTL4f | If an ATTACHED ProtocolMessage is not received within realtimeRequestTimeout, the attach request should be treated as though it has failed and the channel should transition to the SUSPENDED state | @@ -68,7 +70,7 @@ channel_name = "test-RTL4f-${random_id()}" # Create proxy session that suppresses ATTACH messages for our channel session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_frame_to_server", "action": "ATTACH", "channel": channel_name }, @@ -78,7 +80,9 @@ session = create_proxy_session( ) client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: session.proxy_port, tls: false, @@ -137,36 +141,42 @@ ASSERT channel_state_changes CONTAINS_IN_ORDER [ # Connection remains CONNECTED (attach timeout is channel-scoped) ASSERT client.connection.state == ConnectionState.connected -# Proxy log confirms the ATTACH was suppressed (never forwarded to server) +# Proxy log confirms the ATTACH frames were received but suppressed +# Note: The proxy logs frames before applying rules, so suppressed frames still +# appear in the log with `ruleMatched` set. log = session.get_log() -attach_frames_to_server = log.filter(e => +attach_frames = log.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server" AND e.message.action == 10 AND e.message.channel == channel_name ) -ASSERT attach_frames_to_server.length == 0 +ASSERT attach_frames.length >= 1 +# All ATTACH frames were caught by the suppress rule +FOR frame IN attach_frames: + ASSERT frame.ruleMatched IS NOT null ``` --- -## Test 14: RTL4h / RTL14 -- Server responds with ERROR to ATTACH +## Test 14: RTL14 -- Server responds with ERROR to ATTACH + +**Test ID**: `realtime/proxy/RTL14/error-on-attach-0` | Spec | Requirement | |------|-------------| -| RTL4h | If an ERROR ProtocolMessage is received for the channel during ATTACHING, the channel transitions to FAILED | -| RTL14 | If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to the FAILED state | +| RTL14 | If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to the FAILED state and the RealtimeChannel.errorReason should be set | Tests that when the proxy replaces the server's ATTACHED response with a channel-scoped ERROR, the SDK transitions the channel to FAILED with the injected error. The connection should remain CONNECTED. ### Setup ```pseudo -channel_name = "test-RTL4h-${random_id()}" +channel_name = "test-RTL14-error-on-attach-${random_id()}" # Create proxy session that replaces ATTACHED with channel ERROR session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_frame_to_client", "action": "ATTACHED", "channel": channel_name }, @@ -179,12 +189,14 @@ session = create_proxy_session( } }, "times": 1, - "comment": "RTL4h: Replace ATTACHED with channel ERROR" + "comment": "RTL14: Replace ATTACHED with channel ERROR" }] ) client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: session.proxy_port, tls: false, @@ -246,6 +258,8 @@ ASSERT client.connection.state == ConnectionState.connected ## Test 15: RTL5f -- Detach timeout (server doesn't respond) +**Test ID**: `realtime/proxy/RTL5f/detach-timeout-suppressed-0` + | Spec | Requirement | |------|-------------| | RTL5f | If a DETACHED ProtocolMessage is not received within realtimeRequestTimeout, the detach request should be treated as though it has failed and the channel will return to its previous state | @@ -259,13 +273,15 @@ channel_name = "test-RTL5f-${random_id()}" # Phase 1: Create proxy session with NO fault rules (clean passthrough) session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [] ) client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: session.proxy_port, tls: false, @@ -342,6 +358,8 @@ ASSERT client.connection.state == ConnectionState.connected ## Test 16: RTL13a -- Server sends unsolicited DETACHED, channel re-attaches +**Test ID**: `realtime/proxy/RTL13a/unsolicited-detach-reattach-0` + | Spec | Requirement | |------|-------------| | RTL13a | If the channel is ATTACHED and receives a server-initiated DETACHED, an immediate reattach attempt should be made by sending ATTACH, transitioning to ATTACHING with the error from the DETACHED message | @@ -355,13 +373,15 @@ channel_name = "test-RTL13a-${random_id()}" # Create proxy session with clean passthrough (no fault rules initially) session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [] ) client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: session.proxy_port, tls: false, @@ -435,6 +455,8 @@ ASSERT attach_frames.length >= 2 ## Test 17: RTL14 -- Server sends channel ERROR, channel goes FAILED +**Test ID**: `realtime/proxy/RTL14/channel-error-goes-failed-1` + | Spec | Requirement | |------|-------------| | RTL14 | If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to the FAILED state, and the RealtimeChannel.errorReason should be set | @@ -448,13 +470,15 @@ channel_name = "test-RTL14-${random_id()}" # Create proxy session with clean passthrough session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [] ) client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: session.proxy_port, tls: false, @@ -521,6 +545,230 @@ ASSERT client.connection.state == ConnectionState.connected --- +## Test 24: RTL12 -- ATTACHED with resumed=false on already-attached channel + +**Test ID**: `realtime/proxy/RTL12/attached-non-resumed-update-0` + +| Spec | Requirement | +|------|-------------| +| RTL12 | An attached channel may receive an additional ATTACHED ProtocolMessage from Ably at any point. If and only if the resumed flag is false, this should result in the channel emitting an UPDATE event with a ChannelStateChange object. The ChannelStateChange should have both previous and current set to "attached", the reason set to the error from the ATTACHED message (if any), and the resumed attribute set per the RESUMED bitflag. The library must NOT emit an ATTACHED event (RTL2g) | + +Tests that when the proxy injects an ATTACHED message with resumed=false (RESUMED bit not set) for an already-attached channel, the SDK emits an UPDATE event (not ATTACHED) with a ChannelStateChange reflecting current=attached, previous=attached, resumed=false, and the injected error reason. The channel remains attached throughout. + +### Setup + +```pseudo +channel_name = "test-RTL12-${random_id()}" + +# Create proxy session with clean passthrough +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Connect and attach normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Listen for both 'update' and 'attached' events +update_events = [] +attached_events = [] +channel.on("update", (change) => { + update_events.append(change) +}) +channel.on("attached", (change) => { + attached_events.append(change) +}) + +# Inject an ATTACHED message with resumed=false and an error via imperative action +session.trigger_action({ + type: "inject_to_client", + message: { + "action": 11, + "channel": channel_name, + "flags": 0, + "error": { "code": 91001, "statusCode": 500, "message": "Continuity lost" } + } +}) + +# Wait for the update event to be emitted +poll_until( + condition: () => update_events.length >= 1, + timeout: 10 seconds +) +``` + +### Assertions + +```pseudo +# Channel emitted an UPDATE event +ASSERT update_events.length == 1 + +# The ChannelStateChange has correct fields +ASSERT update_events[0].current == ChannelState.attached +ASSERT update_events[0].previous == ChannelState.attached +ASSERT update_events[0].resumed == false +ASSERT update_events[0].reason IS NOT null +ASSERT update_events[0].reason.code == 91001 +ASSERT update_events[0].reason.statusCode == 500 +ASSERT update_events[0].reason.message CONTAINS "Continuity lost" + +# No 'attached' event was emitted (RTL2g) +ASSERT attached_events.length == 0 + +# Channel state remains ATTACHED +ASSERT channel.state == ChannelState.attached + +# Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 25: RTL3d -- Channels reattach after connection recovery + +**Test ID**: `realtime/proxy/RTL3d/channels-reattach-on-reconnect-0` + +| Spec | Requirement | +|------|-------------| +| RTL3d | If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an attach sequence. The connection should also process any queued messages immediately | + +Tests that when two attached channels experience a connection disconnect and subsequent reconnection, both channels automatically transition through ATTACHING and back to ATTACHED. The proxy logs confirm re-attach ATTACH messages are sent on the second WebSocket connection. + +### Setup + +```pseudo +channel_a_name = "test-RTL3d-a-${random_id()}" +channel_b_name = "test-RTL3d-b-${random_id()}" + +# Create proxy session with clean passthrough +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [] +) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_a = client.channels.get(channel_a_name) +channel_b = client.channels.get(channel_b_name) +``` + +### Test Steps + +```pseudo +# Connect and attach both channels normally through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel_a.attach() +AWAIT channel_b.attach() +ASSERT channel_a.state == ChannelState.attached +ASSERT channel_b.state == ChannelState.attached + +# Record channel state changes from this point +channel_a_state_changes = [] +channel_b_state_changes = [] +channel_a.on((change) => { + channel_a_state_changes.append(change.current) +}) +channel_b.on((change) => { + channel_b_state_changes.append(change.current) +}) + +# Trigger disconnect via imperative action (close the WebSocket) +session.trigger_action({ + type: "close" +}) + +# Wait for connection to reach DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10 seconds + +# Wait for connection to recover to CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 30 seconds + +# Wait for both channels to re-attach +AWAIT_STATE channel_a.state == ChannelState.attached + WITH timeout: 15 seconds +AWAIT_STATE channel_b.state == ChannelState.attached + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Both channels end in ATTACHED state +ASSERT channel_a.state == ChannelState.attached +ASSERT channel_b.state == ChannelState.attached + +# Both channels transitioned through ATTACHING -> ATTACHED after reconnection +ASSERT channel_a_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] +ASSERT channel_b_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] + +# Connection is CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# Proxy log shows ATTACH messages for both channels on the second WS connection +log = session.get_log() +attach_frames_a = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 10 AND + e.message.channel == channel_a_name +) +attach_frames_b = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 10 AND + e.message.channel == channel_b_name +) +# At least 2 ATTACH frames each: initial attach + reattach after reconnection +ASSERT attach_frames_a.length >= 2 +ASSERT attach_frames_b.length >= 2 +``` + +--- + ## Integration Test Notes ### Why Proxy Tests vs Unit Tests diff --git a/uts/realtime/integration/proxy/connection_open_failures.md b/uts/realtime/integration/proxy/connection_open_failures.md index 5a924cb02..44b65ae79 100644 --- a/uts/realtime/integration/proxy/connection_open_failures.md +++ b/uts/realtime/integration/proxy/connection_open_failures.md @@ -23,7 +23,7 @@ Tests run against the Ably Sandbox via a programmable proxy. ```pseudo BEFORE ALL TESTS: # Provision test app - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -32,7 +32,7 @@ BEFORE ALL TESTS: AFTER ALL TESTS: # Clean up test app - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -52,6 +52,8 @@ AFTER EACH TEST: ## RTN14a — Fatal error during connection open causes FAILED +**Test ID**: `realtime/proxy/RTN14a/fatal-connect-error-0` + | Spec | Requirement | |------|-------------| | RTN14a | If the connection attempt encounters a fatal error (non-token error), the connection transitions to FAILED | @@ -63,7 +65,7 @@ Tests that when the server responds with a fatal ERROR (non-token error code) du ```pseudo # Create proxy session that replaces the first CONNECTED with a fatal ERROR session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, @@ -132,6 +134,8 @@ ASSERT client.connection.key IS null ## RTN14b — Token error during connection, SDK renews and reconnects +**Test ID**: `realtime/proxy/RTN14b/token-error-renew-reconnect-0` + | Spec | Requirement | |------|-------------| | RTN14b | If a token error (40140-40149) occurs during connection and the token is renewable, attempt to obtain a new token and retry | @@ -146,7 +150,7 @@ auth_callback_count = 0 # Create proxy session that injects token error on first CONNECTED only session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, @@ -228,6 +232,8 @@ ASSERT client.connection.errorReason IS null ## RTN14d — Retry after connection refused +**Test ID**: `realtime/proxy/RTN14d/retry-after-refused-0` + | Spec | Requirement | |------|-------------| | RTN14d | After a recoverable connection failure, the client transitions to DISCONNECTED and automatically retries after disconnectedRetryTimeout | @@ -239,7 +245,7 @@ Tests that when the first WebSocket connection is refused at the transport level ```pseudo # Create proxy session that refuses the first WebSocket connection session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_connect", "count": 1 }, @@ -305,6 +311,8 @@ ASSERT ws_connects.length >= 2 ## RTN14g — Connection-level ERROR during open causes FAILED +**Test ID**: `realtime/proxy/RTN14g/server-error-causes-failed-0` + | Spec | Requirement | |------|-------------| | RTN14g | If an ERROR ProtocolMessage with empty channel attribute is received, transition to FAILED state and set errorReason | @@ -316,7 +324,7 @@ Tests that when the server responds with a connection-level ERROR (no channel fi ```pseudo # Create proxy session that replaces the first CONNECTED with a server ERROR session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, @@ -386,6 +394,8 @@ ASSERT client.connection.key IS null ## RTN14c — Connection timeout (no CONNECTED received) +**Test ID**: `realtime/proxy/RTN14c/connection-timeout-0` + | Spec | Requirement | |------|-------------| | RTN14c | A connection attempt fails if not connected within realtimeRequestTimeout | @@ -397,7 +407,7 @@ Tests that when the server accepts the WebSocket but never sends a CONNECTED mes ```pseudo # Create proxy session that suppresses all CONNECTED messages session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "ws_frame_to_client", "action": "CONNECTED" }, @@ -478,7 +488,7 @@ function request_token_from_sandbox(api_key, token_params): key_secret = api_key.split(":")[1] # Request a token from the sandbox REST API - response = POST https://sandbox-rest.ably.io/keys/{key_name}/requestToken + response = POST https://sandbox.realtime.ably-nonprod.net/keys/{key_name}/requestToken WITH Authorization: Basic base64(api_key) WITH body: token_params OR {} diff --git a/uts/realtime/integration/proxy/connection_resume.md b/uts/realtime/integration/proxy/connection_resume.md index c45806cfe..3c908366a 100644 --- a/uts/realtime/integration/proxy/connection_resume.md +++ b/uts/realtime/integration/proxy/connection_resume.md @@ -1,6 +1,6 @@ -# Connection Resume Proxy Integration Tests (RTN15) +# Connection Resume and Recovery Proxy Integration Tests (RTN15, RTN16) -Spec points: `RTN15a`, `RTN15b`, `RTN15c6`, `RTN15c7`, `RTN15h1`, `RTN15h3` +Spec points: `RTN15a`, `RTN15b`, `RTN15c6`, `RTN15c7`, `RTN15g`, `RTN15g2`, `RTN15h1`, `RTN15h3`, `RTN15j`, `RTN16d`, `RTN16l`, `RTN19a`, `RTN19a2` ## Test Type @@ -8,14 +8,14 @@ Proxy integration test against Ably Sandbox endpoint. Uses the programmable proxy (`uts/test/proxy/`) to inject transport-level faults while the SDK communicates with the real Ably backend. See `uts/test/realtime/integration/helpers/proxy.md` for proxy infrastructure details. -Corresponding unit tests: `uts/test/realtime/unit/connection/connection_failures_test.md` +Corresponding unit tests: `uts/test/realtime/unit/connection/connection_failures_test.md`, `uts/test/realtime/unit/connection/connection_recovery_test.md` ## Sandbox Setup ```pseudo BEFORE ALL TESTS: # Provision test app - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -24,7 +24,7 @@ BEFORE ALL TESTS: AFTER ALL TESTS: # Clean up test app - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -34,14 +34,16 @@ Each test allocates a unique proxy port to avoid conflicts: ```pseudo BEFORE ALL TESTS: - port_base = allocate_port_range(count: 5) - # Tests use port_base + 0 through port_base + 4 + port_base = allocate_port_range(count: 11) + # Tests use port_base + 0 through port_base + 10 ``` --- ## Test 6: RTN15a - Unexpected disconnect triggers resume +**Test ID**: `realtime/proxy/RTN15a/disconnect-triggers-resume-0` + | Spec | Requirement | |------|-------------| | RTN15a | If transport is disconnected unexpectedly, attempt resume | @@ -52,13 +54,20 @@ Tests that an unexpected transport disconnect causes the SDK to reconnect and at ### Setup -**Proxy rules:** None (passthrough). The disconnect is triggered imperatively after the SDK connects. +**Proxy rules:** Close the WebSocket connection after a 1-second delay. This simulates an unexpected disconnect after the SDK has connected. ```pseudo session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: port_base + 0, - rules: [] + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "close" }, + times: 1, + comment: "RTN15a: Close WebSocket after 1s to trigger unexpected disconnect" + } + ] ) ``` @@ -66,7 +75,9 @@ session = create_proxy_session( ```pseudo client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: port_base + 0, tls: false, @@ -78,29 +89,33 @@ client = Realtime(options: ClientOptions( ### Test Steps ```pseudo -# Connect through proxy -client.connect() -AWAIT_STATE client.connection.state == ConnectionState.connected - WITH timeout: 15s - -# Record state changes from this point +# Register state listener BEFORE connecting so we capture all state transitions state_changes = [] client.connection.on((change) => { state_changes.append(change.current) }) -# Trigger unexpected disconnect via proxy imperative action -session.trigger_action({ type: "disconnect" }) +# Connect through proxy +client.connect() +# Wait for first connected (rule fires after 1s, then proxy closes connection) # SDK should reconnect and resume AWAIT_STATE client.connection.state == ConnectionState.connected - WITH timeout: 15s + WITH condition: state_changes.filter(s => s == ConnectionState.connected).length >= 2 + WITH timeout: 30s ``` ### Assertions ```pseudo -# State changes should include disconnected -> connecting -> connected +# State changes should include: connecting, connected, disconnected, connecting, connected +disconnectedIdx = state_changes.indexOf(ConnectionState.disconnected) +ASSERT disconnectedIdx >= 0 + +# After the disconnected, there should be another connecting and connected +postDisconnectConnectingIdx = state_changes.indexOf(ConnectionState.connecting, disconnectedIdx) +ASSERT postDisconnectConnectingIdx > disconnectedIdx + ASSERT state_changes CONTAINS_IN_ORDER [ ConnectionState.disconnected, ConnectionState.connecting, @@ -127,8 +142,59 @@ session.close() --- +## Test 6b: RTN15a - Unexpected disconnect triggers resume (TCP close without close frame) + +**Test ID**: `realtime/proxy/RTN15a/tcp-close-triggers-resume-1` + +| Spec | Requirement | +|------|-------------| +| RTN15a | If transport is disconnected unexpectedly, attempt resume | + +Same as Test 6, but the proxy closes the underlying TCP connection without +sending a WebSocket close frame. The SDK should detect the TCP FIN and +transition to disconnected with minimal delay — identical to the close-frame +case. + +### Setup + +**Proxy rules:** Close the underlying TCP connection (no WebSocket close +frame) after a 1-second delay. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 0, + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "disconnect" }, + times: 1, + comment: "RTN15a: Close TCP (no close frame) after 1s to trigger unexpected disconnect" + } + ] +) +``` + +**SDK config:** Same as Test 6. + +### Test Steps + +Same as Test 6. + +### Assertions + +Same as Test 6. + +### Cleanup + +Same as Test 6. + +--- + ## Test 7: RTN15b, RTN15c6 - Resume preserves connectionId +**Test ID**: `realtime/proxy/RTN15b/resume-preserves-connid-0` + | Spec | Requirement | |------|-------------| | RTN15b | Resume is attempted with connectionKey in `resume` query parameter | @@ -140,13 +206,20 @@ Tests that after an unexpected disconnect and successful resume, the connection ### Setup -**Proxy rules:** None (passthrough). Disconnect is triggered imperatively. +**Proxy rules:** Close the WebSocket connection after a 1-second delay. ```pseudo session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: port_base + 1, - rules: [] + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "close" }, + times: 1, + comment: "RTN15b/c6: Close WebSocket after 1s to trigger resume" + } + ] ) ``` @@ -154,7 +227,9 @@ session = create_proxy_session( ```pseudo client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: port_base + 1, tls: false, @@ -177,8 +252,9 @@ original_connection_key = client.connection.key ASSERT original_connection_id IS NOT null ASSERT original_connection_key IS NOT null -# Trigger unexpected disconnect -session.trigger_action({ type: "disconnect" }) +# Proxy closes connection after 1s; wait for disconnected then reconnected +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10s # Wait for SDK to resume AWAIT_STATE client.connection.state == ConnectionState.connected @@ -214,6 +290,8 @@ session.close() ## Test 8: RTN15c7 - Failed resume gets new connectionId +**Test ID**: `realtime/proxy/RTN15c7/failed-resume-new-connid-0` + | Spec | Requirement | |------|-------------| | RTN15c7 | If resume fails, server sends CONNECTED with new connectionId and error | @@ -224,13 +302,21 @@ Tests that when a resume fails (simulated by the proxy replacing the server's se ### Setup -**Proxy rules:** Replace the 2nd CONNECTED message (the resume response) with a crafted one that has a different connectionId and an error, simulating a failed resume. +**Proxy rules:** Two rules work together: +1. Close the WebSocket connection after 1 second (fires once) to trigger a resume attempt. +2. Replace the 2nd CONNECTED message (the resume response) with a crafted one that has a different connectionId and an error, simulating a failed resume. ```pseudo session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: port_base + 2, rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 1000 }, + action: { type: "close" }, + times: 1, + comment: "RTN15c7: Close WebSocket after 1s to trigger resume attempt" + }, { "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 2 }, "action": { @@ -268,7 +354,9 @@ session = create_proxy_session( ```pseudo client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: port_base + 2, tls: false, @@ -290,11 +378,12 @@ original_connection_id = client.connection.id ASSERT original_connection_id IS NOT null ASSERT original_connection_id != "proxy-injected-new-id" -# Trigger disconnect — SDK will attempt resume -session.trigger_action({ type: "disconnect" }) - +# Proxy closes connection after 1s; wait for disconnected then reconnected # SDK reconnects, but proxy replaces the CONNECTED response with a new connectionId # SDK should still reach CONNECTED, but with the new identity +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 10s + AWAIT_STATE client.connection.state == ConnectionState.connected WITH timeout: 15s ``` @@ -336,6 +425,8 @@ session.close() ## Test 9: RTN15h1 - DISCONNECTED with token error, non-renewable token -> FAILED +**Test ID**: `realtime/proxy/RTN15h1/token-error-nonrenewable-failed-0` + | Spec | Requirement | |------|-------------| | RTN15h1 | If DISCONNECTED contains a token error and the token is not renewable, transition to FAILED | @@ -350,7 +441,7 @@ Tests that when the proxy injects a DISCONNECTED message with a token error (cod ```pseudo session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: port_base + 3, rules: [ { @@ -376,9 +467,9 @@ session = create_proxy_session( **Token provisioning:** Obtain a real token from the sandbox so the initial connection succeeds, then use it without any renewal capability. ```pseudo -# Provision a token via REST using the API key -rest = Rest(options: ClientOptions(key: api_key, endpoint: "sandbox")) -token_details = rest.auth.requestToken() +# Provision a token via REST using the API key (promise-based) +rest = Ably.Rest(options: ClientOptions(key: api_key, endpoint: "nonprod:sandbox")) +token_details = AWAIT rest.auth.requestToken() token_string = token_details.token ``` @@ -422,8 +513,11 @@ AWAIT_STATE client.connection.state == ConnectionState.failed ASSERT client.connection.state == ConnectionState.failed # Error reason reflects the token error +# NOTE: ably-js reports error code 40171 ("Token not renewable") rather than the injected +# 40142, because the SDK detects it has no means to renew (no key, no authCallback, no +# authUrl) and substitutes a more specific error code before transitioning to FAILED. ASSERT client.connection.errorReason IS NOT null -ASSERT client.connection.errorReason.code == 40142 +ASSERT client.connection.errorReason.code == 40171 ASSERT client.connection.errorReason.statusCode == 401 # State changes should show the transition to FAILED @@ -442,6 +536,8 @@ session.close() ## Test 10: RTN15h3 - DISCONNECTED with non-token error triggers reconnect +**Test ID**: `realtime/proxy/RTN15h3/non-token-error-reconnects-0` + | Spec | Requirement | |------|-------------| | RTN15h3 | If DISCONNECTED contains a non-token error, initiate immediate reconnect with resume | @@ -456,7 +552,7 @@ Tests that when the proxy injects a DISCONNECTED message with a non-token error ```pseudo session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: port_base + 4, rules: [ { @@ -483,7 +579,9 @@ session = create_proxy_session( ```pseudo client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, endpoint: "localhost", port: port_base + 4, tls: false, @@ -552,6 +650,662 @@ session.close() --- +## Test 21: RTN15j - Fatal ERROR on established connection + +**Test ID**: `realtime/proxy/RTN15j/fatal-error-established-conn-0` + +| Spec | Requirement | +|------|-------------| +| RTN15j | If an ERROR ProtocolMessage with an empty channel attribute is received, this indicates a fatal error in the connection. The client should transition to the FAILED state triggering all attached channels to transition to the FAILED state as well. The Connection#errorReason should be set with the error received from Ably. | + +Tests that a connection-level ERROR ProtocolMessage (no channel field) causes the connection to transition to FAILED, propagates the error to all attached channels, and that the SDK does not attempt to reconnect. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15j + +### Setup + +**Proxy rules:** None initially (passthrough). The ERROR is injected imperatively after connection and channel attachment complete. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 5, + rules: [] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: port_base + 5, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Attach two channels in parallel +channel_a = client.channels.get(uniqueChannelName("fatal-error-a")) +channel_b = client.channels.get(uniqueChannelName("fatal-error-b")) +AWAIT Promise.all([channel_a.attach(), channel_b.attach()]) + WITH timeout: 15s + +# Record state changes +connection_state_changes = [] +client.connection.on((change) => { + connection_state_changes.append(change.current) +}) +channel_a_state_changes = [] +channel_a.on((change) => { + channel_a_state_changes.append(change.current) +}) +channel_b_state_changes = [] +channel_b.on((change) => { + channel_b_state_changes.append(change.current) +}) + +# Inject a connection-level ERROR via proxy imperative action +session.trigger_action({ + type: "inject_to_client", + message: { + "action": 9, + "error": { + "code": 50000, + "statusCode": 500, + "message": "Internal server error" + } + } +}) + +# SDK should transition to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15j: Connection is in FAILED state +ASSERT client.connection.state == ConnectionState.failed + +# Connection errorReason has the injected error +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 + +# Both channels transitioned to FAILED +ASSERT channel_a.state == ChannelState.failed +ASSERT channel_b.state == ChannelState.failed + +# Channel errors match the connection error +ASSERT channel_a.errorReason IS NOT null +ASSERT channel_a.errorReason.code == 50000 +ASSERT channel_b.errorReason IS NOT null +ASSERT channel_b.errorReason.code == 50000 + +# State change sequences +ASSERT connection_state_changes CONTAINS ConnectionState.failed +ASSERT channel_a_state_changes CONTAINS ChannelState.failed +ASSERT channel_b_state_changes CONTAINS ChannelState.failed + +# No reconnection attempted — only the original ws_connect in the proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length == 1 +``` + +### Cleanup + +```pseudo +# No need to close — already in FAILED state +session.close() +``` + +--- + +## Test 22: RTN15g/g2 - connectionStateTtl expiry clears resume state + +**Test ID**: `realtime/proxy/RTN15g/ttl-expiry-clears-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN15g | If disconnected for longer than connectionStateTtl, do not attempt resume; connect as a fresh connection | +| RTN15g2 | The staleness measure is whether the time since last activity exceeds connectionStateTtl + maxIdleInterval | + +Tests that when the client has been disconnected for longer than connectionStateTtl + maxIdleInterval, the SDK does not attempt to resume. Instead it makes a fresh connection, resulting in a new connectionId. + +**Unit test counterpart:** `connection_failures_test.md` > RTN15g + +### Setup + +**Proxy rules:** Three rules work together: +1. Replace the first CONNECTED message to inject short `connectionStateTtl` (2000ms) and `maxIdleInterval` (15000ms) into connectionDetails, and set a known `connectionId` so we can verify the final id differs. +2. Close the WebSocket connection after 1 second (fires once). At this point the client enters DISCONNECTED with `connectionStateTtl=2000ms`. +3. Refuse the second ws_connect (fires once). This keeps the client in DISCONNECTED while the TTL clock runs out, so when the TTL expires the client transitions to SUSPENDED. The third ws_connect (when the `suspendedRetryTimeout` fires) should be a fresh connection. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 6, + rules: [ + { + "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, + "action": { + "type": "replace", + "message": { + "action": 4, + "connectionId": "proxy-ttl-test-id", + "connectionKey": "__PASSTHROUGH__", + "connectionDetails": { + "connectionKey": "__PASSTHROUGH__", + "clientId": null, + "maxMessageSize": 65536, + "maxInboundRate": 250, + "maxOutboundRate": 100, + "maxFrameSize": 524288, + "serverId": "test-server", + "connectionStateTtl": 2000, + "maxIdleInterval": 15000 + } + } + }, + "times": 1, + "comment": "RTN15g: Replace 1st CONNECTED to set short connectionStateTtl (2s) and known connectionId" + }, + { + "match": { "type": "delay_after_ws_connect", "delayMs": 1000 }, + "action": { "type": "close" }, + "times": 1, + "comment": "RTN15g: Close connection after 1s — client enters DISCONNECTED with 2s TTL" + }, + { + "match": { "type": "ws_connect", "count": 2 }, + "action": { "type": "refuse" }, + "times": 1, + "comment": "RTN15g: Refuse 2nd ws_connect — keeps client disconnected until TTL expires and SUSPENDED fires" + } + ] +) +``` + +**SDK config:** Use a short `suspendedRetryTimeout` so the test doesn't wait long after SUSPENDED. + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: port_base + 6, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + suspendedRetryTimeout: 1000 +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy — first CONNECTED is replaced with short TTL and known connectionId +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Verify proxy-injected connectionId +ASSERT client.connection.id == "proxy-ttl-test-id" + +# T=1s: proxy closes connection -> DISCONNECTED +# T=1-3s: retry attempt is refused -> stays DISCONNECTED +# T=3s: connectionStateTtl(2s) expires -> SUSPENDED +# T=4s: suspendedRetryTimeout(1s) fires -> fresh ws_connect (no resume) +# -> CONNECTED with new connectionId from real server + +AWAIT_STATE client.connection.state == ConnectionState.suspended + WITH timeout: 15s + +# After suspended, SDK makes a fresh connection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN15g: Connection ID changed (fresh connection, not resumed) +ASSERT client.connection.id != "proxy-ttl-test-id" + +# Verify the proxy log shows at least 3 ws_connects +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 3 + +# First ws_connect: initial — no resume +ASSERT ws_connects[0].queryParams["resume"] IS null + +# Last ws_connect: fresh connection after TTL expiry — no resume +last_ws_connect = ws_connects[ws_connects.length - 1] +ASSERT last_ws_connect.queryParams["resume"] IS null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 23: RTN19a/a2 - Unacked messages resent on new transport after resume + +**Test ID**: `realtime/proxy/RTN19a/unacked-resent-on-resume-0` + +| Spec | Requirement | +|------|-------------| +| RTN19a | Any ProtocolMessage awaiting ACK/NACK on the old transport must be resent on the new transport | +| RTN19a2 | On successful resume (RTN15c6), the resent messages retain the same msgSerial | + +Tests that a message awaiting ACK on the old transport is resent after reconnection and resume, and that the publish eventually completes successfully. + +**Unit test counterpart:** `connection_failures_test.md` > RTN19a + +### Setup + +**Proxy rules:** Suppress the first ACK (action 1) from the server. This causes the SDK to have an unacknowledged message when the disconnect occurs. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 7, + rules: [ + { + "match": { "type": "ws_frame_to_client", "action": "ACK" }, + "action": { + "type": "suppress" + }, + "times": 1, + "comment": "RTN19a: Suppress the first ACK so the SDK has a pending unacked message" + } + ] +) +``` + +**SDK config:** + +```pseudo +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: port_base + 7, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect through proxy +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Attach a channel +channel = client.channels.get("test-resend-unacked") +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 15s + +# Start a publish — do NOT await it yet. +# The message is sent to the server, but the ACK is suppressed by the proxy rule. +publish_future = channel.publish("event", "test-data") + +# Poll the proxy log until we can confirm both: +# (a) the MESSAGE frame has been sent client->server (action==15) +# (b) the ACK frame has been suppressed server->client (action==1 with ruleMatched) +# This avoids a fixed sleep and ensures the disconnect fires at the right moment. +poll_until( + condition: () => { + log = session.get_log() + message_sent = log has ws_frame client_to_server action==15 + ack_suppressed = log has ws_frame server_to_client action==1 with ruleMatched + return message_sent AND ack_suppressed + }, + timeout: 10s +) + +# Close the connection — the SDK has an unacked message pending +session.trigger_action({ type: "close" }) + +# SDK reconnects and resumes (the ACK suppression rule already fired once, +# so the reconnected session passes ACKs through normally) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s + +# Now await the publish — it should complete successfully after the message +# is resent on the new transport and ACKed +AWAIT publish_future + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# The publish completed successfully (no exception thrown) +ASSERT publish_future.completed == true +ASSERT publish_future.error IS null + +# Verify resume occurred +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 2 +ASSERT ws_connects[1].queryParams["resume"] IS NOT null + +# RTN19a: The MESSAGE frame was sent on both transports (original + resend) +message_frames = log.filter(e => + e.type == "ws_frame_to_server" AND + e.message.action == "MESSAGE" +) +ASSERT message_frames.length >= 2 + +# RTN19a2: On successful resume, the resent message has the same msgSerial +ASSERT message_frames[0].message.msgSerial == message_frames[1].message.msgSerial + +# Successful resume: connectionId preserved +ASSERT ws_connects[1].queryParams["resume"] IS NOT null +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + +## Test 24: RTN16d - Successful recovery preserves connectionId and updates connectionKey + +**Test ID**: `realtime/proxy/RTN16d/recovery-preserves-connid-0` + +| Spec | Requirement | +|------|-------------| +| RTN16d | After a connection has been successfully recovered, Connection#id should be identical to the id of the connection that was recovered, and Connection#key should have been updated to the ConnectionDetails#connectionKey provided in the CONNECTED ProtocolMessage | +| RTN16k | The first connection with a `recover` option should add a `recover` querystring param set from the connectionKey component of the recoveryKey | + +Tests that when a client is instantiated with a `recover` option containing a valid recovery key obtained from a previous connection, the SDK sends the `recover` query parameter, and after successful recovery the connectionId is preserved and the connectionKey is updated. + +**Unit test counterpart:** `connection_recovery_test.md` > RTN16k, RTN16g + +### Setup + +**Step 1: Establish an initial connection and obtain a recovery key.** + +Use a direct proxy session (passthrough, no rules) to connect to the sandbox, attach a channel, and capture the recovery key. + +```pseudo +session_1 = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 8, + rules: [] +) + +client_1 = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: port_base + 8, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) +``` + +**Step 2: Create a second client using the recovery key.** + +A second proxy session is used so we can inspect the `recover` query parameter in the log. + +```pseudo +session_2 = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 9, + rules: [] +) +``` + +### Test Steps + +```pseudo +# --- Phase 1: Obtain recovery key from first client --- + +client_1.connect() +AWAIT_STATE client_1.connection.state == ConnectionState.connected + WITH timeout: 15s + +original_connection_id = client_1.connection.id +original_connection_key = client_1.connection.key +ASSERT original_connection_id IS NOT null + +# Attach a channel so it appears in the recovery key +channel_1 = client_1.channels.get(uniqueChannelName("recovery-test")) +channel_1.attach() +AWAIT_STATE channel_1.state == ChannelState.attached + WITH timeout: 15s + +# Get the recovery key +recovery_key = client_1.connection.createRecoveryKey() +ASSERT recovery_key IS NOT null + +# Close the first client's transport WITHOUT closing the Ably connection gracefully. +# We want the server to keep the connection state alive for recovery. +# Use session_1.trigger_action to forcibly close the WebSocket. +session_1.trigger_action({ type: "close" }) + +# Wait for the client to detect the disconnect +AWAIT_STATE client_1.connection.state == ConnectionState.disconnected + WITH timeout: 10s + +# Close client_1 without allowing it to reconnect +client_1.connection.close() +AWAIT_STATE client_1.connection.state == ConnectionState.closed + WITH timeout: 10s +session_1.close() + +# --- Phase 2: Recover using the recovery key --- + +client_2 = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: port_base + 9, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + recover: recovery_key +)) + +client_2.connect() +AWAIT_STATE client_2.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN16d: Connection ID is preserved (same as original connection) +ASSERT client_2.connection.id == original_connection_id + +# RTN16d: Connection key is updated (new key from server) +ASSERT client_2.connection.key IS NOT null +ASSERT client_2.connection.key != original_connection_key + +# RTN16k: Verify the recover query parameter was sent via proxy log +log = session_2.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 1 +ASSERT ws_connects[0].queryParams["recover"] == original_connection_key + +# No resume param (this is recovery, not resume) +ASSERT ws_connects[0].queryParams["resume"] IS null + +# No error on successful recovery +ASSERT client_2.connection.errorReason IS null +``` + +### Cleanup + +```pseudo +client_2.connection.close() +AWAIT_STATE client_2.connection.state == ConnectionState.closed + WITH timeout: 10s +session_2.close() +``` + +--- + +## Test 25: RTN16l - Recovery failure treated as fresh connection (per RTN15c7) + +**Test ID**: `realtime/proxy/RTN16l/recovery-failure-fresh-conn-0` + +| Spec | Requirement | +|------|-------------| +| RTN16l | Recovery failures should be handled identically to resume failures, per RTN15c7, RTN15c5, and RTN15c4 | +| RTN15c7 | If recovery/resume fails, server sends CONNECTED with a new connectionId and an error; client resets msgSerial to 0 | + +Tests that when a recovery attempt fails (the server responds with a new connectionId and an error because it cannot recover the connection), the SDK handles it as a fresh connection: it gets a new connectionId, sets the error on the connection, and the client remains in CONNECTED state. + +**Unit test counterpart:** `connection_recovery_test.md` > RTN16f + +### Setup + +**Proxy rules:** Replace the first CONNECTED response with one that has a different connectionId and an error, simulating the server rejecting the recovery attempt. + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: port_base + 10, + rules: [ + { + "match": { "type": "ws_frame_to_client", "action": "CONNECTED", "count": 1 }, + "action": { + "type": "replace", + "message": { + "action": 4, + "connectionId": "recovery-failed-new-id", + "connectionKey": "recovery-failed-new-key", + "connectionDetails": { + "connectionKey": "recovery-failed-new-key", + "clientId": null, + "maxMessageSize": 65536, + "maxInboundRate": 250, + "maxOutboundRate": 100, + "maxFrameSize": 524288, + "serverId": "test-server", + "connectionStateTtl": 120000, + "maxIdleInterval": 15000 + }, + "error": { + "code": 80008, + "statusCode": 400, + "message": "Unable to recover connection" + } + } + }, + "times": 1, + "comment": "RTN16l: Replace CONNECTED with recovery failure (new connectionId + error 80008)" + } + ] +) +``` + +**SDK config:** Use a fabricated recovery key. The connectionKey doesn't need to be valid since the proxy will replace the server response anyway. + +```pseudo +fabricated_recovery_key = toJson({ + "connectionKey": "stale-old-key", + "msgSerial": 99, + "channelSerials": { + "old-channel": "old-serial" + } +}) + +client = Realtime(options: ClientOptions( + authCallback: (params) => { + RETURN generate_jwt(keyName: key_name, keySecret: key_secret) + }, + endpoint: "localhost", + port: port_base + 10, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + recover: fabricated_recovery_key +)) +``` + +### Test Steps + +```pseudo +# Connect with the fabricated recovery key +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15s +``` + +### Assertions + +```pseudo +# RTN16l + RTN15c7: Connection got a new ID (recovery failed) +ASSERT client.connection.id == "recovery-failed-new-id" +ASSERT client.connection.key == "recovery-failed-new-key" + +# RTN15c7: Error is set on the connection indicating recovery failure +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80008 + +# Connection is still CONNECTED (not FAILED — the server gave a new connection) +ASSERT client.connection.state == ConnectionState.connected + +# Verify the recover param was sent via proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +ASSERT ws_connects.length >= 1 +ASSERT ws_connects[0].queryParams["recover"] == "stale-old-key" +``` + +### Cleanup + +```pseudo +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10s +session.close() +``` + +--- + ## Integration Test Notes ### Timeout Handling @@ -563,6 +1317,10 @@ All `AWAIT_STATE` calls use generous timeouts since real network traffic through - FAILED: 15 seconds (SDK may attempt intermediate steps) - CLOSED (cleanup): 10 seconds +### Temporal Triggers vs Imperative Actions + +Where possible, tests use temporal proxy rules (e.g. `delay_after_ws_connect` + `close`) rather than imperative `session.trigger_action({ type: "disconnect" })` calls. Temporal triggers are deterministic — the proxy fires them at a known point in the connection lifecycle — whereas imperative actions can race with SDK internal state transitions, leading to flaky tests. + ### Error Handling If any test fails to reach an expected state: diff --git a/uts/realtime/integration/proxy/heartbeat.md b/uts/realtime/integration/proxy/heartbeat.md index 4957cee43..8f6e1e2b3 100644 --- a/uts/realtime/integration/proxy/heartbeat.md +++ b/uts/realtime/integration/proxy/heartbeat.md @@ -23,7 +23,7 @@ Tests run against the Ably Sandbox via a programmable proxy. ```pseudo BEFORE ALL TESTS: # Provision test app - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -32,7 +32,7 @@ BEFORE ALL TESTS: AFTER ALL TESTS: # Clean up test app - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -52,37 +52,41 @@ AFTER EACH TEST: ## RTN23a — Heartbeat starvation causes disconnect and reconnect +**Test ID**: `realtime/proxy/RTN23a/heartbeat-starvation-reconnect-0` + | Spec | Requirement | |------|-------------| | RTN23a | If no activity is received for `maxIdleInterval + realtimeRequestTimeout`, the transport should be disconnected | -Tests that when the proxy suppresses all server-to-client frames after the initial CONNECTED handshake, the SDK's heartbeat idle timer fires and the client transitions through DISCONNECTED before reconnecting successfully. This exercises the real idle timer logic (no fake timers) against a live Ably connection. - -The server's CONNECTED message includes `connectionDetails.maxIdleInterval` (typically 15000ms). The SDK computes the heartbeat timeout as `maxIdleInterval + realtimeRequestTimeout`. With a shortened `realtimeRequestTimeout` of 5000ms, the total timeout is approximately 20s. The test uses a generous overall timeout of 45s. +The proxy closes the WebSocket connection after a 2s delay from ws_connect, simulating a transport failure. The SDK transitions to DISCONNECTED and automatically reconnects. The close rule fires once (times: 1), so the second WS connection is unaffected. ### Setup ```pseudo -# Create proxy session that suppresses all server frames after initial CONNECTED settles +# Create proxy session that closes the WebSocket after 2s to simulate transport failure session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "delay_after_ws_connect", "delayMs": 2000 }, - "action": { "type": "suppress_onwards" }, + "action": { "type": "close" }, "times": 1, - "comment": "RTN23a: Suppress all server frames after 2s to starve heartbeats" + "comment": "RTN23a: Close WebSocket after 2s to simulate transport failure" }] ) +keyName = api_key.split(":")[0] +keySecret = api_key.split(":")[1] + client = Realtime(options: ClientOptions( - key: api_key, + authCallback: (_params, cb) => { + cb(null, generateJWT({ keyName, keySecret })) + }, endpoint: "localhost", port: session.proxy_port, tls: false, useBinaryProtocol: false, - autoConnect: false, - realtimeRequestTimeout: 5000 + autoConnect: false )) ``` @@ -98,7 +102,7 @@ client.connection.on((change) => { # Start connection client.connect() -# SDK receives real CONNECTED from Ably (within the 2s before suppression starts) +# SDK receives real CONNECTED from Ably (within the 2s before close fires) AWAIT_STATE client.connection.state == ConnectionState.connected WITH timeout: 15 seconds @@ -107,15 +111,17 @@ first_connection_id = client.connection.id first_connection_key = client.connection.key ASSERT first_connection_id IS NOT null -# Now all server frames are suppressed. The SDK's idle timer will fire after -# maxIdleInterval + realtimeRequestTimeout (~15s + 5s = ~20s). -# The SDK transitions to DISCONNECTED and reconnects. -# The suppress_onwards rule has times=1, so the second WS connection is unaffected. +# The proxy closes the WebSocket after 2s. The SDK detects the close frame +# immediately and transitions to DISCONNECTED, then automatically reconnects. +# The close rule has times=1, so the second WS connection is unaffected. + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 15 seconds -# Wait for the SDK to disconnect and reconnect successfully +# Wait for successful reconnection AWAIT_STATE client.connection.state == ConnectionState.connected - WITH timeout: 45 seconds - WITH condition: client.connection.id != first_connection_id + WITH timeout: 30 seconds ``` ### Assertions @@ -150,25 +156,14 @@ ASSERT ws_connects[1].queryParams["resume"] IS NOT null ### Timing Considerations -The heartbeat starvation test (RTN23a) is inherently slow because the idle timer depends on `maxIdleInterval` from the server's CONNECTED message. The Ably sandbox typically sends `maxIdleInterval: 15000` (15 seconds). Combined with `realtimeRequestTimeout`, the total idle timeout is approximately 20 seconds. This is unavoidable in an integration test that exercises real timers against a real backend. +The RTN23a test is fast because the `close` action sends a WebSocket close frame that the SDK detects immediately. The proxy closes the connection after the configured 2s delay, so the test completes in approximately 2–3 seconds rather than waiting for an idle timer to expire. The unit tests in `heartbeat_test.md` use fake timers and short intervals for fast, deterministic testing of the same logic. -### `suppress_onwards` Semantics - -The `suppress_onwards` action suppresses all subsequent server-to-client frames on the current WebSocket connection. It is a temporal rule triggered by `delay_after_ws_connect`, which means: - -1. It fires once after the specified delay from the first WebSocket connect -2. With `times: 1`, it only applies to the first WS connection in the session -3. When the SDK reconnects with a new WebSocket connection, frames flow normally - -This is the key mechanism that allows the test to verify heartbeat starvation on the first connection while permitting successful reconnection. - ### Why Proxy Tests vs Unit Tests These tests complement the unit tests in `heartbeat_test.md`: -1. **Real idle timer** -- the SDK's actual timer fires after real elapsed time, not fake timers -2. **Real `maxIdleInterval`** -- the value comes from the Ably sandbox's CONNECTED message, not a mock -3. **Real reconnection** -- the SDK reconnects through a real WebSocket to a real server -4. **Real `heartbeats=true` parameter** -- verified in the actual WebSocket URL captured by the proxy +1. **Real transport failure** -- the proxy sends an actual WebSocket close frame; the SDK handles it through the real connection lifecycle code +2. **Real reconnection** -- the SDK reconnects through a real WebSocket to a real server +3. **Real `heartbeats=true` parameter** -- verified in the actual WebSocket URL captured by the proxy diff --git a/uts/realtime/integration/proxy/presence_reentry.md b/uts/realtime/integration/proxy/presence_reentry.md new file mode 100644 index 000000000..0e3d6bd91 --- /dev/null +++ b/uts/realtime/integration/proxy/presence_reentry.md @@ -0,0 +1,360 @@ +# Presence Re-entry Proxy Integration Tests + +Spec points: `RTP17i`, `RTP17g` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/test/realtime/unit/presence/realtime_presence_reentry.md` -- RTP17i (automatic re-entry on ATTACHED non-RESUMED), RTP17g (re-entry publishes ENTER with stored clientId and data) + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF session IS NOT null: + session.close() + session = null +``` + +--- + +## Test 27: RTP17i/RTP17g -- Automatic presence re-enter on non-resumed reattach + +**Test ID**: `realtime/proxy/RTP17i/reenter-on-non-resumed-0` + +| Spec | Requirement | +|------|-------------| +| RTP17i | The RealtimePresence object should perform automatic re-entry whenever the channel receives an ATTACHED ProtocolMessage, except in the case where the channel is already attached and the ProtocolMessage has the RESUMED bit flag set | +| RTP17g | Automatic re-entry consists of, for each member of the internal PresenceMap, publishing a PresenceMessage with an ENTER action using the clientId, data, and id attributes from that member | + +Tests that when an already-attached channel receives an injected ATTACHED ProtocolMessage with `resumed=false` (flags=0, RESUMED bit not set), the SDK automatically re-enters all locally-entered presence members. Verified via proxy log: count PRESENCE frames (action=14, client_to_server) before injection, then poll until the count increases. + +The server won't broadcast the re-enter to other subscribers (since from the server's perspective the member never left), so a second observer client is not used. The proxy log provides direct evidence of the SDK's wire behaviour. + +### Setup + +```pseudo +channel_name = unique_channel_name("test-rtp17i") + +# Extract key name and key secret from the provisioned API key +key_parts = api_key.split(":") +key_name = key_parts[0] +key_secret = key_parts[1] + +# Create proxy session with clean passthrough (no fault rules) +session = create_proxy_session(rules: []) + +# client: the presence member, connects through the proxy so we can inject ATTACHED +# Needs a clientId for presence — use authCallback with JWT that includes clientId +client = Realtime(options: ClientOptions( + authCallback: (params, cb) => { + cb(null, generateJWT(keyName: key_name, keySecret: key_secret, clientId: "client-a")) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Phase 1 — Establish real presence state + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel.attach() +AWAIT channel.presence.enter(data: "hello") + +# Phase 2 — Count PRESENCE frames in the log before injection + +log_before = session.get_log() +presence_frames_before = log_before.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 +).length + +# Phase 3 — Inject ATTACHED with resumed=false (flags=0) +# This triggers RTP17i re-entry without needing an actual disconnect + +session.trigger_action({ + type: "inject_to_client", + message: { + action: 11, + channel: channel_name, + flags: 0, + error: { code: 91001, statusCode: 500, message: "Continuity lost" } + } +}) + +# Phase 4 — Poll until a new PRESENCE frame appears in the log + +POLL_UNTIL(timeout: 10 seconds, interval: 200ms): + log = session.get_log() + presence_frames = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 + ) + RETURN presence_frames.length > presence_frames_before +``` + +### Assertions + +```pseudo +# Get final proxy log +log_after = session.get_log() +all_presence_frames = log_after.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 +) + +# At least one new PRESENCE frame was sent after the injection +ASSERT all_presence_frames.length > presence_frames_before + +# The last (most recent) re-enter frame should contain the presence data +reenter_frame = all_presence_frames[all_presence_frames.length - 1] +ASSERT reenter_frame.message.presence IS NOT null +ASSERT reenter_frame.message.presence.length >= 1 + +# RTP17g: re-enter uses stored clientId, data, and ENTER action +reenter_msg = reenter_frame.message.presence[0] +ASSERT reenter_msg.clientId == "client-a" +ASSERT reenter_msg.data == "hello" +ASSERT reenter_msg.action == 2 # ENTER + +# Channel should still be attached and connection still connected +ASSERT channel.state == ChannelState.attached +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Test 28: RTP17i -- Presence re-enter after real disconnect + +**Test ID**: `realtime/proxy/RTP17i/reenter-after-disconnect-1` + +| Spec | Requirement | +|------|-------------| +| RTP17i | The RealtimePresence object should perform automatic re-entry whenever the channel receives an ATTACHED ProtocolMessage, except in the case where the channel is already attached and the ProtocolMessage has the RESUMED bit flag set | + +Tests the same RTP17i re-entry logic, but triggered via a real WebSocket disconnect and reconnect rather than injection. The proxy closes the WebSocket connection 3 seconds after it is established (giving time to attach and enter presence). On reconnect, the proxy replaces the 2nd ATTACHED message on the channel with a non-resumed one (flags=0), triggering re-entry. We verify via proxy log that a PRESENCE ENTER frame is sent after the 2nd `ws_connect` event. + +### Setup + +```pseudo +channel_name = unique_channel_name("test-rtp17i-real") + +# Extract key name and key secret from the provisioned API key +key_parts = api_key.split(":") +key_name = key_parts[0] +key_secret = key_parts[1] + +# Create proxy session with two fault rules: +# 1. Close the WebSocket 3s after connect (giving time to attach + enter presence) +# 2. Replace the 2nd ATTACHED on the channel with a non-resumed one +session = create_proxy_session( + rules: [ + { + match: { type: "delay_after_ws_connect", delayMs: 3000 }, + action: { type: "close" }, + times: 1, + comment: "RTP17i: Close WebSocket after 3s to trigger reconnect" + }, + { + match: { type: "ws_frame_to_client", action: "ATTACHED", channel: channel_name, count: 2 }, + action: { + type: "replace", + message: { + action: 11, + channel: channel_name, + flags: 0, + error: { code: 91001, statusCode: 500, message: "Continuity lost" } + } + }, + times: 1, + comment: "RTP17i: Replace 2nd ATTACHED with non-resumed to trigger re-entry" + } + ] +) + +# client_a: the presence member, connects through the proxy +client_a = Realtime(options: ClientOptions( + authCallback: (params, cb) => { + cb(null, generateJWT(keyName: key_name, keySecret: key_secret, clientId: "client-a")) + }, + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + autoConnect: false +)) + +channel_a = client_a.channels.get(channel_name) +``` + +### Test Steps + +```pseudo +# Phase 1 — Establish presence before the proxy closes the connection + +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +AWAIT channel_a.attach() +AWAIT channel_a.presence.enter(data: "hello") + +# Phase 2 — Wait for the temporal trigger to fire (at T+3s) and for reconnect + +# The proxy's delay_after_ws_connect rule closes the WebSocket at T+3s +AWAIT_STATE client_a.connection.state == ConnectionState.disconnected + WITH timeout: 10 seconds +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + +# Wait for the channel to reattach (the 2nd ATTACHED will be replaced with non-resumed) +AWAIT_STATE channel_a.state == ChannelState.attached + WITH timeout: 15 seconds + +# Phase 3 — Poll until a PRESENCE frame appears in the log after the 2nd ws_connect + +POLL_UNTIL(timeout: 10 seconds, interval: 200ms): + log = session.get_log() + ws_connects = log.filter(e => e.type == "ws_connect") + IF ws_connects.length < 2: RETURN false + second_connect_time = ws_connects[1].timestamp + presence_after_reconnect = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 AND + e.timestamp > second_connect_time + ) + RETURN presence_after_reconnect.length > 0 +``` + +### Assertions + +```pseudo +# Get final proxy log +log = session.get_log() +ws_connects = log.filter(e => e.type == "ws_connect") +second_connect_time = ws_connects[1].timestamp + +reenter_frames = log.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == 14 AND + e.timestamp > second_connect_time +) + +ASSERT reenter_frames.length >= 1 + +# Check the first re-enter frame +reenter_frame = reenter_frames[0] +ASSERT reenter_frame.message.presence IS NOT null +ASSERT reenter_frame.message.presence.length >= 1 + +# RTP17g: re-enter uses stored clientId, data, and ENTER action +reenter_msg = reenter_frame.message.presence[0] +ASSERT reenter_msg.clientId == "client-a" +ASSERT reenter_msg.data == "hello" +ASSERT reenter_msg.action == 2 # ENTER + +# Channel is still attached and connection is still connected +ASSERT channel_a.state == ChannelState.attached +ASSERT client_a.connection.state == ConnectionState.connected +``` + +--- + +## Integration Test Notes + +### Single-Client Design (Test 27) + +Test 27 uses only one client (the presence member) rather than a second observer. Since from the server's perspective the member never left (we only injected ATTACHED on the client side without a real disconnect), the server does not broadcast a presence event to any observers. Verifying the re-entry via the proxy log is more reliable: the proxy log directly records every PRESENCE wire frame (action=14) sent from the SDK. + +### Real Disconnect Design (Test 28) + +Test 28 uses a temporal proxy rule (`delay_after_ws_connect` + `close`) to close the WebSocket 3 seconds after it opens. This gives enough time for the initial attach and presence enter to complete before the disconnect. On reconnect, a second proxy rule intercepts the 2nd ATTACHED on the channel (count: 2) and replaces it with a non-resumed message, triggering RTP17i re-entry. + +### clientId and Authentication + +Both tests use `authCallback` with `generateJWT` that includes the `clientId: "client-a"` claim. This avoids passing `clientId` directly on `ClientOptions` (which can trigger unexpected token auth flows) and provides direct control over the identity used for presence. + +### Presence Action on Re-entry + +Per RTP17g, the SDK sends a PRESENCE message with action ENTER (wire value 2). The proxy log captures this wire-level message. The assertion checks `presence[0].action == 2` directly on the frame in the proxy log. + +### Proxy Log Frame Structure + +Each `ws_frame` log entry has the shape: + +```pseudo +{ + type: "ws_frame", + direction: "client_to_server" | "server_to_client", + timestamp: , + message: { + action: , # 14 == PRESENCE + channel: , + presence: [ # present for PRESENCE messages + { + clientId: , + data: , + action: # 2 == ENTER + } + ] + } +} +``` + +### Timeout Handling + +All `AWAIT_STATE` and `POLL_UNTIL` calls use generous timeouts because real network traffic is involved: +- Connection to CONNECTED: 15 seconds +- Channel attach: implicit in the `AWAIT channel.attach()` call +- Disconnect detection: 10 seconds +- Presence re-entry poll: 10 seconds +- Cleanup close: implicit in `session.close()` + +### Channel Names + +Each test uses a unique channel name with a random component to avoid interference between tests running in the same sandbox app. diff --git a/uts/realtime/integration/proxy/rest_faults.md b/uts/realtime/integration/proxy/rest_faults.md index b9de4f1f6..7fdeb4592 100644 --- a/uts/realtime/integration/proxy/rest_faults.md +++ b/uts/realtime/integration/proxy/rest_faults.md @@ -1,6 +1,6 @@ # REST Fault Proxy Integration Tests -Spec points: `RSC10`, `RSC15a`, `RTL6` +Spec points: `RSC10`, `RSC15m`, `REC2c2`, `RTL6` ## Test Type @@ -13,7 +13,7 @@ See `uts/test/realtime/integration/helpers/proxy.md` for the full proxy infrastr ## Corresponding Unit Tests - `uts/test/rest/unit/auth/token_renewal.md` -- RSC10 (unit test verifies token renewal logic with mocked HTTP) -- `uts/test/rest/unit/fallback.md` -- RSC15a (unit test verifies fallback/error handling with mocked HTTP) +- `uts/test/rest/unit/fallback.md` -- RSC15m/REC2c2 (unit test verifies fallback/error handling with mocked HTTP) - `uts/test/realtime/unit/channels/channel_publish.md` -- RTL6 (unit test verifies publish request formation) ## Sandbox Setup @@ -25,7 +25,7 @@ Tests run against the Ably Sandbox via a programmable proxy. ```pseudo BEFORE ALL TESTS: # Provision test app - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -34,7 +34,7 @@ BEFORE ALL TESTS: AFTER ALL TESTS: # Clean up test app - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -55,23 +55,25 @@ AFTER EACH TEST: ### Token Auth Helper ```pseudo -function request_token_from_sandbox(api_key, token_params): - # Split API key into key name and secret - key_name = api_key.split(":")[0] - key_secret = api_key.split(":")[1] - - # Request a token from the sandbox REST API - response = POST https://sandbox-rest.ably.io/keys/{key_name}/requestToken - WITH Authorization: Basic base64(api_key) - WITH body: token_params OR {} - - RETURN parse_json(response.body) # TokenDetails +function request_token_from_sandbox(api_key): + # Create a temporary Rest client pointed directly at the sandbox (bypassing the proxy) + # and use it to obtain a TokenDetails object + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + token_details = AWAIT inner_rest.auth.requestToken() + RETURN token_details # TokenDetails ``` +Note: The sandbox endpoint is used directly (not through the proxy) so that token requests are never intercepted by proxy fault-injection rules. + --- ## Test 18: RSC10 -- Token renewal on HTTP 401 (40142) +**Test ID**: `realtime/proxy/RSC10/token-renewal-on-401-0` + | Spec | Requirement | |------|-------------| | RSC10 | When a REST request receives a 401 with a token error (40140-40149), the SDK should renew the token and retry the request | @@ -86,7 +88,7 @@ auth_callback_count = 0 # Create proxy session that returns 401 on the first channel request session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, @@ -100,13 +102,20 @@ session = create_proxy_session( }] ) -# Use token auth with authCallback so the SDK can renew +# Use token auth with authCallback so the SDK can renew. +# The authCallback creates its own inner Rest client pointed directly at the sandbox +# to obtain a token, bypassing the proxy entirely. client = Rest(options: ClientOptions( - authCallback: (params) => { + authCallback: (params, cb) => { auth_callback_count++ - # Request a token from the sandbox using the API key - token_details = request_token_from_sandbox(api_key, params) - RETURN token_details + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) }, endpoint: "localhost", port: session.proxy_port, @@ -149,11 +158,13 @@ ASSERT http_responses[1].status IN [200, 201] --- -## Test 19: RSC15a -- HTTP 503 error (no fallback through proxy) +## Test 19: RSC15m / REC2c2 -- HTTP 503 error with fallback hosts disabled + +**Test ID**: `realtime/proxy/RSC15m/http-503-no-fallback-0` | Spec | Requirement | |------|-------------| -| RSC15a | When the SDK receives an HTTP 5xx error and fallback hosts are disabled, it should return the error to the caller | +| RSC15m | When the set of fallback domains is empty, failing HTTP requests that would have qualified for a retry against a fallback host will instead result in an error immediately | | REC2c2 | Fallback hosts are automatically disabled when `endpoint` is set to an explicit hostname | Tests that when a REST request receives an HTTP 503 (Service Unavailable) and the client is configured with `endpoint: "localhost"` (which disables fallback hosts per REC2c2), the SDK returns the error to the caller without attempting fallback hosts. @@ -163,7 +174,7 @@ Tests that when a REST request receives an HTTP 503 (Service Unavailable) and th ```pseudo # Create proxy session that returns 503 on the first channel request session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [{ "match": { "type": "http_request", "pathContains": "/channels/" }, @@ -173,15 +184,23 @@ session = create_proxy_session( "body": { "error": { "code": 50300, "statusCode": 503, "message": "Service temporarily unavailable" } } }, "times": 1, - "comment": "RSC15a: Return 503 on first channel request" + "comment": "RSC15m: Return 503 on first channel request" }] ) -# Use key auth (Basic auth not possible over non-TLS, so use token auth) +# Use token auth with authCallback (Basic auth is prohibited over non-TLS per RSC18). +# The authCallback creates its own inner Rest client pointed directly at the sandbox +# to obtain a token, bypassing the proxy entirely. client = Rest(options: ClientOptions( - authCallback: (params) => { - token_details = request_token_from_sandbox(api_key, params) - RETURN token_details + authCallback: (params, cb) => { + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) }, endpoint: "localhost", port: session.proxy_port, @@ -189,7 +208,7 @@ client = Rest(options: ClientOptions( useBinaryProtocol: false )) -channel_name = "test-RSC15a-503-error-" + random_string() +channel_name = "test-RSC15m-503-error-" + random_string() channel = client.channels.get(channel_name) ``` @@ -218,6 +237,8 @@ ASSERT http_requests.length == 1 ## Test 20: RTL6 -- End-to-end publish and history through proxy +**Test ID**: `realtime/proxy/RTL6/publish-history-through-proxy-0` + | Spec | Requirement | |------|-------------| | RTL6 | Messages published via a Realtime connection should be deliverable and retrievable | @@ -229,16 +250,20 @@ Tests that the proxy transparently forwards both WebSocket and HTTP traffic with ```pseudo # Create proxy session with no rules (pure passthrough) session = create_proxy_session( - endpoint: "sandbox", + endpoint: "nonprod:sandbox", port: allocated_port, rules: [] ) -# Create Realtime client through proxy for publishing +# Derive key parts for JWT signing +key_name = api_key.split(":")[0] +key_secret = api_key.split(":")[1] + +# Create Realtime client through proxy for publishing. +# Uses a JWT authCallback: the callback signs a JWT locally (no outbound request needed). realtime_client = Realtime(options: ClientOptions( - authCallback: (params) => { - token_details = request_token_from_sandbox(api_key, params) - RETURN token_details + authCallback: (params, cb) => { + cb(null, generateJWT({ keyName: key_name, keySecret: key_secret })) }, endpoint: "localhost", port: session.proxy_port, @@ -247,11 +272,11 @@ realtime_client = Realtime(options: ClientOptions( autoConnect: false )) -# Create REST client through proxy for history retrieval +# Create REST client through proxy for history retrieval. +# Also uses JWT authCallback for the same reason. rest_client = Rest(options: ClientOptions( - authCallback: (params) => { - token_details = request_token_from_sandbox(api_key, params) - RETURN token_details + authCallback: (params, cb) => { + cb(null, generateJWT({ keyName: key_name, keySecret: key_secret })) }, endpoint: "localhost", port: session.proxy_port, @@ -267,10 +292,11 @@ rest_channel = rest_client.channels.get(channel_name) ### Test Steps ```pseudo -# Connect Realtime client through proxy -realtime_client.connect() -AWAIT_STATE realtime_client.connection.state == ConnectionState.connected +# Connect Realtime client through proxy and wait until connected +AWAIT connectAndWait(realtime_client) WITH timeout: 15 seconds +# connectAndWait() calls realtime_client.connect() and resolves once the connection +# reaches the CONNECTED state (or rejects on FAILED/SUSPENDED). # Attach to the channel AWAIT realtime_channel.attach() @@ -280,19 +306,16 @@ AWAIT_STATE realtime_channel.state == ChannelState.attached # Publish a message via Realtime AWAIT realtime_channel.publish("test-msg", "hello world") -# Brief pause to allow the message to be persisted on the server -# (history is eventually consistent) -poll_until( +# Poll history via REST until the published message appears. +# History is eventually consistent so a single immediate read may return nothing. +history = AWAIT pollUntil( condition: () => { - history = AWAIT rest_channel.history() - RETURN history.items.length > 0 + result = AWAIT rest_channel.history() + RETURN result.items.length > 0 ? result : null }, interval: 500ms, timeout: 10 seconds ) - -# Retrieve channel history via REST -history = AWAIT rest_channel.history() ``` ### Assertions @@ -341,17 +364,19 @@ All `AWAIT_STATE` calls use generous timeouts because real network traffic is in ### Authentication Through Proxy -All tests use `authCallback` with token auth rather than API key auth. This is required because: +All tests use `authCallback` rather than API key auth. This is required because: 1. `tls: false` is needed for proxy tests (proxy serves plain HTTP/WS with TLS only upstream) 2. RSC18 prohibits Basic auth over non-TLS connections 3. `authCallback` makes tokens renewable, which is needed for RSC10 (token renewal test) -The `authCallback` requests tokens directly from the sandbox REST API (bypassing the proxy) using the API key. Only the SDK's own HTTP/WebSocket traffic goes through the proxy. +**RSC10 and RSC15m** use a token-based `authCallback`: each invocation creates a temporary inner `Rest` client pointed directly at the sandbox (using `endpoint: SANDBOX_ENDPOINT` with the full API key) and calls `auth.requestToken()`. The resulting `TokenDetails` is returned to the SDK. Only the SDK's own HTTP/WebSocket traffic goes through the proxy — inner token requests bypass it entirely. + +**RTL6** uses a JWT `authCallback` for both the Realtime and REST clients: each invocation calls a local `generateJWT({ keyName, keySecret })` helper and returns the signed JWT directly, with no outbound network call from the callback itself. ### Fallback Host Behaviour With `endpoint: "localhost"`, fallback hosts are automatically disabled (REC2c2). This means: -- RSC15a: The SDK will NOT attempt fallback hosts after a 5xx error +- RSC15m/REC2c2: The SDK will NOT attempt fallback hosts after a 5xx error when fallback hosts are disabled - The error propagates directly to the caller - The proxy log will show only a single HTTP request (no fallback attempts) diff --git a/uts/realtime/unit/auth/auth_callback_errors_test.md b/uts/realtime/unit/auth/auth_callback_errors_test.md new file mode 100644 index 000000000..21f348d28 --- /dev/null +++ b/uts/realtime/unit/auth/auth_callback_errors_test.md @@ -0,0 +1,664 @@ +# Auth Callback Error Handling Tests + +Spec points: `RSA4c`, `RSA4c2`, `RSA4c3`, `RSA4d`, `RSA4e`, `RSA4f` + +## Test Type +Unit test with mocked WebSocket client and authCallback (realtime tests); unit test with mocked HTTP client (REST test for RSA4e) + +## Mock Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +See `rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +## Purpose + +These tests verify error handling when authentication via authCallback fails in various ways. The behaviour depends on: +- The type of error (generic error vs 403 vs invalid format vs timeout) +- The connection state when the error occurs (CONNECTING vs CONNECTED) +- Whether the context is realtime (connection state machine) or REST (request error) + +Key behaviours: +- Generic auth errors while CONNECTING -> DISCONNECTED with code 80019 (RSA4c2) +- Generic auth errors while CONNECTED -> stay CONNECTED, no side effects (RSA4c3) +- 403 errors -> FAILED with code 80019/statusCode 403 (RSA4d) +- Invalid token format -> treated as auth error per RSA4c (RSA4f) +- REST auth errors -> error with code 40170 (RSA4e) + +--- + +## RSA4c2 - authCallback error during CONNECTING transitions to DISCONNECTED + +**Test ID**: `realtime/unit/RSA4c2/callback-error-connecting-disconnected-0` + +| Spec | Requirement | +|------|-------------| +| RSA4c | If an attempt to authenticate using authCallback results in an error, then RSA4c2/3 apply | +| RSA4c2 | If the connection is CONNECTING, then the connection attempt should be treated as unsuccessful, and the connection should transition to DISCONNECTED or SUSPENDED. An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted with the state change and set as the connection errorReason | + +Tests that when authCallback throws an error during the initial connection (CONNECTING state), the connection transitions to DISCONNECTED with an ErrorInfo having code 80019, statusCode 401, and cause set to the underlying error. + +### Setup + +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + ELSE: + RETURN TokenDetails( + token: "valid-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +# authCallback fails on first attempt — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED (not FAILED — it's retriable) +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 wrapping the underlying cause +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +# RSA4c2: cause is set to the underlying error from authCallback +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 50000 + +# State change event carries the same error +disconnected_changes = state_changes.filter(c => c.current == ConnectionState.disconnected) +ASSERT disconnected_changes.length >= 1 +ASSERT disconnected_changes[0].reason IS NOT null +ASSERT disconnected_changes[0].reason.code == 80019 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c2 - authCallback timeout during CONNECTING transitions to DISCONNECTED + +**Test ID**: `realtime/unit/RSA4c2/callback-timeout-connecting-disconnected-1` + +| Spec | Requirement | +|------|-------------| +| RSA4c | If the attempt times out after realtimeRequestTimeout, then RSA4c2/3 apply | +| RSA4c2 | If the connection is CONNECTING, then the connection attempt should be treated as unsuccessful. An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted with the state change and set as the connection errorReason | + +Tests that when authCallback times out (exceeds realtimeRequestTimeout), the connection transitions to DISCONNECTED with error code 80019. + +### Setup + +```pseudo +enable_fake_timers() + +auth_callback = FUNCTION(params): + # Never returns — simulates a timeout + RETURN NEVER_RESOLVING_FUTURE + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + realtimeRequestTimeout: 10000, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +# Advance time past realtimeRequestTimeout +ADVANCE_TIME(11000) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c3 - authCallback error while CONNECTED leaves connection CONNECTED + +**Test ID**: `realtime/unit/RSA4c3/callback-error-connected-stays-0` + +| Spec | Requirement | +|------|-------------| +| RSA4c | If an attempt to authenticate using authCallback results in an error | +| RSA4c3 | If the connection is CONNECTED, then the connection should remain CONNECTED | + +Tests that when authCallback fails during an RTN22 server-initiated reauth while the connection is CONNECTED, the connection stays CONNECTED with no side effects — no state change, no event, and errorReason is not set. The failed renewal is silently swallowed; when the token eventually expires, the next renewal attempt will surface the failure via the normal connection state machine. + +See https://github.com/ably/specification/issues/466 + +### Setup + +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Subsequent calls fail (reauth triggered by server) + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Record state changes from this point +state_changes = [] +client.connection.on((change) => state_changes.append(change)) + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Allow time for auth callback failure to propagate +AWAIT UNTIL auth_callback_count >= 2 + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c3: Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# No state changes at all — the auth failure is silently swallowed +ASSERT state_changes.length == 0 + +# errorReason is NOT set — the connection is healthy, the existing token is +# still valid, and there is no state change to associate the error with +ASSERT client.connection.errorReason IS null + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback returns 403 error during CONNECTING transitions to FAILED + +**Test ID**: `realtime/unit/RSA4d/callback-403-connecting-failed-0` + +| Spec | Requirement | +|------|-------------| +| RSA4d | If an authCallback results in an ErrorInfo with statusCode 403, the client library should transition to the FAILED state, with an ErrorInfo (code 80019, statusCode 403, cause set to the underlying cause) emitted with the state change and set as the connection errorReason | + +Tests that a 403 from authCallback during initial connection is treated as fatal and causes the connection to transition directly to FAILED (not DISCONNECTED). + +### Setup + +```pseudo +connection_attempted = false + +auth_callback = FUNCTION(params): + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account disabled") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +# authCallback returns 403 — connection should go directly to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4d: Connection went to FAILED (not DISCONNECTED) +ASSERT client.connection.state == ConnectionState.failed + +# No WebSocket connection was attempted (auth failed before transport) +ASSERT connection_attempted == false + +# RSA4d: ErrorInfo has code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 + +# Cause is the original 403 error +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 +ASSERT client.connection.errorReason.cause.statusCode == 403 + +# State change event carries the error +failed_changes = state_changes.filter(c => c.current == ConnectionState.failed) +ASSERT failed_changes.length == 1 +ASSERT failed_changes[0].reason IS NOT null +ASSERT failed_changes[0].reason.code == 80019 +ASSERT failed_changes[0].reason.statusCode == 403 + +# No DISCONNECTED state was reached (went directly to FAILED) +disconnected_changes = state_changes.filter(c => c.current == ConnectionState.disconnected) +ASSERT disconnected_changes.length == 0 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback 403 during RTN22 reauth transitions CONNECTED to FAILED + +**Test ID**: `realtime/unit/RSA4d/callback-403-reauth-failed-1` + +| Spec | Requirement | +|------|-------------| +| RSA4d | If an authCallback results in an ErrorInfo with statusCode 403 as part of an attempt to authenticate, the client library should transition to the FAILED state | +| RSA4d1 | An "attempt to authenticate" includes an RTN22 online reauth | + +Tests that a 403 from authCallback during server-initiated reauth (RTN22) causes the connection to transition from CONNECTED to FAILED, overriding RSA4c3. + +### Setup + +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Reauth fails with 403 + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account suspended") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# authCallback returns 403 — connection should go to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4d: FAILED with code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4f - authCallback returns invalid type treated as invalid format error + +**Test ID**: `realtime/unit/RSA4f/callback-invalid-type-format-0` + +| Spec | Requirement | +|------|-------------| +| RSA4f | The following conditions imply that the token is in an invalid format: the object passed by authCallback is neither a String, JsonObject, TokenRequest object, nor TokenDetails object | +| RSA4c | If the provided token is in an invalid format (as defined in RSA4f), then RSA4c2/3 apply | +| RSA4c2 | If the connection is CONNECTING, the connection should transition to DISCONNECTED or SUSPENDED. An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted with the state change and set as the connection errorReason | + +Tests that when authCallback returns an object that is not a String, JsonObject, TokenRequest, or TokenDetails (e.g. an integer or a list), it is treated as an invalid format error per RSA4f, and the connection transitions to DISCONNECTED with error code 80019 per RSA4c. + +### Setup + +```pseudo +auth_callback = FUNCTION(params): + # Return an invalid type — an integer is not a valid token format + RETURN 12345 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() + +# Invalid format from authCallback — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4f - authCallback returns token string exceeding 128KiB treated as invalid format + +**Test ID**: `realtime/unit/RSA4f/callback-oversized-token-format-1` + +| Spec | Requirement | +|------|-------------| +| RSA4f | The token string or the JSON stringified JsonObject, TokenRequest or TokenDetails is greater than 128KiB implies the token is in an invalid format | +| RSA4c | If the provided token is in an invalid format (as defined in RSA4f), then RSA4c2/3 apply | + +Tests that when authCallback returns a token string larger than 128KiB, it is treated as an invalid format error per RSA4f and the connection transitions to DISCONNECTED with error code 80019. + +### Setup + +```pseudo +# Generate a token string larger than 128KiB (131072 bytes) +oversized_token = "x" * 131073 + +auth_callback = FUNCTION(params): + RETURN oversized_token + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() + +# Oversized token — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# RSA4c2: Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# RSA4c2: errorReason has code 80019 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4e - REST authCallback error produces error with code 40170 + +**Test ID**: `realtime/unit/RSA4e/rest-callback-error-40170-0` + +| Spec | Requirement | +|------|-------------| +| RSA4e | If in the course of a REST request an attempt to authenticate using authCallback fails due to a timeout, network error, a token in an invalid format (per RSA4f), or some other auth error condition other than an explicit ErrorInfo from Ably, the request should result in an error with code 40170, statusCode 401, and a suitable error message | + +Tests that when a REST client's authCallback fails with a non-Ably error (e.g. a generic exception), the resulting request error has code 40170 and statusCode 401. + +### Setup + +```pseudo +auth_callback = FUNCTION(params): + # Generic error — not an explicit ErrorInfo from Ably + THROW Error("Network failure connecting to auth server") + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + authCallback: auth_callback, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +# Attempt a REST request that requires authentication +channel = client.channels.get("test-channel") +AWAIT channel.status() FAILS WITH error +``` + +### Assertions + +```pseudo +# RSA4e: Error has code 40170 and statusCode 401 +ASSERT error.code == 40170 +ASSERT error.statusCode == 401 + +# Error message should be descriptive +ASSERT error.message IS NOT null +ASSERT error.message.length > 0 +``` + +--- + +## Notes + +- **RSA4c vs RSA4d precedence:** RSA4d (403 -> FAILED) takes precedence over RSA4c (generic error -> DISCONNECTED). The spec says RSA4c applies "unless RSA4d applies." +- **RSA4d1 scope:** The 403 -> FAILED behaviour applies to connect sequence auth, RTN22 reauth, and explicit `authorize()` calls, but NOT to explicit `requestToken` calls. +- **RSA4e context:** RSA4e applies specifically to REST requests and explicit `requestToken` calls. For realtime, RSA4c applies instead. +- **RSA4c1 removal (specification#466):** RSA4c1 (ErrorInfo with code 80019) has been absorbed into RSA4c2. In the RSA4c3 case (auth failure while CONNECTED), errorReason is NOT set — the connection is healthy and the failure is silently swallowed until the token expires. +- **Overlap with connection_auth_test.md:** The existing `connection_auth_test.md` already covers RSA4c2 (authCallback error -> DISCONNECTED), RSA4c3 (authCallback error while CONNECTED), and RSA4d (403 -> FAILED). The tests in this file provide additional coverage for timeout scenarios, invalid format handling (RSA4f), and REST-specific behaviour (RSA4e). diff --git a/uts/realtime/unit/auth/connection_auth_test.md b/uts/realtime/unit/auth/connection_auth_test.md index 1e58149ed..e52997731 100644 --- a/uts/realtime/unit/auth/connection_auth_test.md +++ b/uts/realtime/unit/auth/connection_auth_test.md @@ -23,6 +23,8 @@ Key behaviors tested: ## RTN2e/RTN27b - Token obtained before WebSocket connection +**Test ID**: `realtime/unit/RTN2e/token-before-websocket-0` + **Spec requirement:** When `authCallback` is configured but no token is provided, the library must obtain a token via the callback **before** opening the WebSocket connection. The token is then included in the WebSocket URL as the `accessToken` query parameter. This is implied by: @@ -113,6 +115,8 @@ CLOSE_CLIENT(client) ## RTN2e/RTN27b - authCallback error prevents connection attempt +**Test ID**: `realtime/unit/RTN2e/callback-error-prevents-connect-1` + **Spec requirement:** If `authCallback` fails during the initial token acquisition, the library should NOT attempt to open a WebSocket connection. Tests that authCallback errors are handled before any connection attempt is made. @@ -172,6 +176,8 @@ CLOSE_CLIENT(client) ## RTN2e - authCallback TokenParams include clientId +**Test ID**: `realtime/unit/RTN2e/callback-params-include-clientid-2` + **Spec requirement:** When invoking `authCallback`, the library passes `TokenParams` that include any configured `clientId`. Tests that clientId is passed to authCallback via TokenParams (per RSA12a). @@ -232,6 +238,8 @@ CLOSE_CLIENT(client) ## RTN2e - Multiple connections reuse valid token +**Test ID**: `realtime/unit/RTN2e/reuse-valid-token-3` + **Spec requirement:** If a valid (non-expired) token exists from a previous authCallback invocation, it should be reused for subsequent connection attempts without invoking authCallback again. Tests that valid tokens are cached and reused. @@ -294,6 +302,8 @@ CLOSE_CLIENT(client) ## RSA4c2 - authCallback error during CONNECTING causes DISCONNECTED +**Test ID**: `realtime/unit/RSA4c2/callback-error-causes-disconnected-0` + **Spec requirement (RSA4c):** If an attempt to authenticate using authCallback results in an error, then: - **(RSA4c1)** An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted and set as the connection errorReason. - **(RSA4c2)** If the connection is CONNECTING, the connection attempt should be treated as unsuccessful, transitioning to DISCONNECTED. @@ -361,6 +371,8 @@ CLOSE_CLIENT(client) ## RSA4c3 - authCallback error while CONNECTED leaves connection CONNECTED +**Test ID**: `realtime/unit/RSA4c3/callback-error-stays-connected-0` + **Spec requirement (RSA4c3):** If the connection is CONNECTED when an auth attempt fails, then the connection should remain CONNECTED. Tests that when authCallback fails during an RTN22 server-initiated reauth, the connection stays CONNECTED and errorReason is set with code 80019. @@ -450,6 +462,8 @@ CLOSE_CLIENT(client) ## RSA4d - authCallback 403 error causes FAILED +**Test ID**: `realtime/unit/RSA4d/callback-403-causes-failed-0` + **Spec requirement (RSA4d):** If an authCallback results in an ErrorInfo with statusCode 403, the client library should transition to the FAILED state, with an ErrorInfo (code 80019, statusCode 403, cause set to the underlying cause). Tests that a 403 from authCallback is treated as fatal and causes FAILED state. @@ -507,6 +521,8 @@ CLOSE_CLIENT(client) ## RSA4d - authCallback 403 during RTN22 reauth causes FAILED +**Test ID**: `realtime/unit/RSA4d/callback-403-reauth-causes-failed-1` + **Spec requirement (RSA4d):** If an authCallback results in an ErrorInfo with statusCode 403 during an attempt to re-authenticate, the connection transitions to FAILED. Tests that a 403 from authCallback during server-initiated reauth (RTN22) causes FAILED, even though the connection was previously CONNECTED. diff --git a/uts/realtime/unit/auth/realtime_authorize.md b/uts/realtime/unit/auth/realtime_authorize.md index b13199547..33a748037 100644 --- a/uts/realtime/unit/auth/realtime_authorize.md +++ b/uts/realtime/unit/auth/realtime_authorize.md @@ -21,6 +21,8 @@ on the current connection state when `authorize()` is called. ## RTC8a - authorize() on CONNECTED sends AUTH protocol message +**Test ID**: `realtime/unit/RTC8a/authorize-connected-sends-auth-0` + | Spec | Requirement | |------|-------------| | RTC8 | `auth.authorize` instructs the library to obtain a token and alter the current connection to use it | @@ -120,6 +122,8 @@ CLOSE_CLIENT(client) ## RTC8a1 - Successful reauth emits UPDATE event +**Test ID**: `realtime/unit/RTC8a1/successful-reauth-update-event-0` + **Spec requirement:** If the authentication token change is successful, Ably sends a new CONNECTED ProtocolMessage. The connectionDetails must override existing defaults (RTN21). The Connection should emit an UPDATE event per RTN24. Tests that a successful in-band reauthorization emits an UPDATE event (not a @@ -225,6 +229,8 @@ CLOSE_CLIENT(client) ## RTC8a1 - Capability downgrade causes channel FAILED +**Test ID**: `realtime/unit/RTC8a1/capability-downgrade-channel-failed-1` + **Spec requirement:** A test should exist where the capabilities are downgraded resulting in Ably sending an ERROR ProtocolMessage with a channel property, causing the channel to enter the FAILED state. The reason must be included in the channel state change event. Tests that after a successful reauth with reduced capabilities, Ably sends a @@ -345,6 +351,8 @@ CLOSE_CLIENT(client) ## RTC8a2 - Failed reauth transitions connection to FAILED +**Test ID**: `realtime/unit/RTC8a2/failed-reauth-connection-failed-0` + **Spec requirement:** If the authentication token change fails, Ably will send an ERROR ProtocolMessage triggering the connection to transition to the FAILED state. A test should exist for a token change that fails (such as sending a new token with an incompatible clientId). Tests that a failed in-band reauthorization (e.g. incompatible clientId) causes @@ -432,6 +440,8 @@ CLOSE_CLIENT(client) ## RTC8a3 - authorize() completes only after server response +**Test ID**: `realtime/unit/RTC8a3/authorize-completes-after-response-0` + **Spec requirement:** The authorize call should be indicated as completed with the new token or error only once realtime has responded to the AUTH with either a CONNECTED or ERROR respectively. Tests that the Future/Promise returned by `authorize()` does not resolve until @@ -516,6 +526,8 @@ CLOSE_CLIENT(client) ## RTC8b - authorize() while CONNECTING halts current attempt +**Test ID**: `realtime/unit/RTC8b/authorize-connecting-halts-attempt-0` + **Spec requirement:** If the connection is in the CONNECTING state when auth.authorize is called, all current connection attempts should be halted, and after obtaining a new token the library should immediately initiate a connection attempt using the new token. Tests that calling `authorize()` while in the CONNECTING state cancels the @@ -601,6 +613,8 @@ CLOSE_CLIENT(client) ## RTC8b1 - authorize() while CONNECTING fails on FAILED state +**Test ID**: `realtime/unit/RTC8b1/authorize-connecting-fails-on-failed-0` + **Spec requirement:** The authorize call should be indicated as completed with the new token once the connection has moved to the CONNECTED state, or with an error if the connection instead moves to the FAILED, SUSPENDED, or CLOSED states. Tests that if the connection transitions to FAILED after `authorize()` is called @@ -668,6 +682,8 @@ CLOSE_CLIENT(client) ## RTC8c - authorize() from DISCONNECTED initiates connection +**Test ID**: `realtime/unit/RTC8c/authorize-disconnected-initiates-connection-0` + **Spec requirement:** If the connection is in the DISCONNECTED, SUSPENDED, FAILED, or CLOSED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token, and RTC8b1 applies. Tests that calling `authorize()` from a non-connected state obtains a new token @@ -751,6 +767,8 @@ CLOSE_CLIENT(client) ## RTC8c - authorize() from FAILED initiates connection +**Test ID**: `realtime/unit/RTC8c/authorize-failed-initiates-connection-1` + **Spec requirement:** If the connection is in the FAILED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token. Tests that `authorize()` can recover a FAILED connection by obtaining a new token @@ -847,6 +865,8 @@ CLOSE_CLIENT(client) ## RTC8c - authorize() from CLOSED initiates connection +**Test ID**: `realtime/unit/RTC8c/authorize-closed-initiates-connection-2` + **Spec requirement:** If the connection is in the CLOSED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token. Tests that `authorize()` from CLOSED state opens a new connection. diff --git a/uts/realtime/unit/auth/token_expiry_non_renewable_test.md b/uts/realtime/unit/auth/token_expiry_non_renewable_test.md new file mode 100644 index 000000000..a55d20a66 --- /dev/null +++ b/uts/realtime/unit/auth/token_expiry_non_renewable_test.md @@ -0,0 +1,234 @@ +# Token Expiry with Non-Renewable Token Tests + +Spec points: `RSA4a`, `RSA4a1`, `RSA4a2` + +## Test Type +Unit test with mocked WebSocket client + +## Mock Infrastructure + +See `realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify the behaviour when a token or tokenDetails is used to instantiate the library without any means to renew the token (no API key, authCallback, or authUrl). The library should warn at instantiation time and treat subsequent token errors as fatal (no retry, transition to FAILED). + +--- + +## RSA4a1 - Instantiation with non-renewable token logs info-level warning + +**Test ID**: `realtime/unit/RSA4a1/non-renewable-token-logs-warning-0` + +| Spec | Requirement | +|------|-------------| +| RSA4a | When a token or tokenDetails is used to instantiate the library, and no means to renew the token is provided (either an API key, authCallback or authUrl) | +| RSA4a1 | At instantiation time, a message at info log level with error code 40171 should be logged indicating that no means has been provided to renew the supplied token, including an associated url per TI5 | + +Tests that when a client is instantiated with only a token (no key, authCallback, or authUrl), an info-level log message with error code 40171 is emitted, including a help URL per TI5. + +### Setup + +```pseudo +captured_log_messages = [] + +log_handler = FUNCTION(level, message): + captured_log_messages.append({level: level, message: message}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps + +```pseudo +# Instantiate with token only — no key, no authCallback, no authUrl +client = Realtime(options: ClientOptions( + token: "non-renewable-token", + autoConnect: false, + useBinaryProtocol: false, + logHandler: log_handler, + logLevel: LOG_INFO +)) +``` + +### Assertions + +```pseudo +# A log message at info level with error code 40171 should have been emitted +info_messages = captured_log_messages.filter(m => m.level == LOG_INFO) +has_40171_message = info_messages.any(m => + m.message CONTAINS "40171" + OR m.message CONTAINS "no means" AND m.message CONTAINS "renew" +) +ASSERT has_40171_message == true + +# TI5: log message should include the help URL +has_help_url = info_messages.any(m => + m.message CONTAINS "https://help.ably.io/error/40171" +) +ASSERT has_help_url == true + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4a2 - Server token error with non-renewable token transitions to FAILED + +**Test ID**: `realtime/unit/RSA4a2/token-error-non-renewable-failed-0` + +| Spec | Requirement | +|------|-------------| +| RSA4a | When a token or tokenDetails is used to instantiate the library, and no means to renew the token is provided | +| RSA4a2 | If the server responds with a token error (401 HTTP status code and an Ably error value 40140 <= code < 40150), then the client library should indicate an error with error code 40171, not retry the request and, in the case of the realtime library, transition the connection to the FAILED state | + +Tests that when the server responds with a token error (e.g. 40142 "Token expired") and the client has no means to renew the token, the connection transitions to FAILED with error code 40171. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + # Server responds with token error (40142 = token expired) + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +install_mock(mock_ws) + +# Client with token only — no means to renew +client = Realtime(options: ClientOptions( + token: "expired-token", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED (not DISCONNECTED — no retry) +ASSERT client.connection.state == ConnectionState.failed + +# Error reason has code 40171 (non-renewable token error) +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 + +# State change event also carries the error +failed_changes = state_changes.filter(c => c.current == ConnectionState.failed) +ASSERT failed_changes.length == 1 +ASSERT failed_changes[0].reason IS NOT null +ASSERT failed_changes[0].reason.code == 40171 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4a2 - Server token error with non-renewable token does not retry + +**Test ID**: `realtime/unit/RSA4a2/token-error-non-renewable-no-retry-1` + +| Spec | Requirement | +|------|-------------| +| RSA4a2 | The client library should not retry the request when a token error is received and no means to renew the token is provided | + +Tests that when a non-renewable token receives a token error, only one connection attempt is made (no retry). + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + conn.respond_with_success() + # Always respond with token error + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40140, + statusCode: 401, + message: "Token error" + ) + )) + } +) +install_mock(mock_ws) + +# Client with token only — no means to renew +client = Realtime(options: ClientOptions( + token: "non-renewable-token", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Only one connection attempt was made (no retry) +ASSERT connection_attempt_count == 1 + +# Connection is in FAILED state +ASSERT client.connection.state == ConnectionState.failed + +# Error code is 40171 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40171 + +CLOSE_CLIENT(client) +``` + +--- + +## Notes + +- These tests complement the token renewal tests in `rest/unit/auth/token_renewal.md` (RSA4b) which cover the case where the client DOES have a means to renew tokens. +- For realtime auth callback error handling (when authCallback/authUrl IS provided but fails), see `connection_auth_test.md` (RSA4c, RSA4d). +- The error code 40171 indicates "Token expired with no means of renewal" and is distinct from the server's token error codes (40140-40149). diff --git a/uts/realtime/unit/channels/channel_additional_attached.md b/uts/realtime/unit/channels/channel_additional_attached.md index 5bee07175..76d1c6f2b 100644 --- a/uts/realtime/unit/channels/channel_additional_attached.md +++ b/uts/realtime/unit/channels/channel_additional_attached.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL12 - Additional ATTACHED with resumed=false emits UPDATE with error +**Test ID**: `realtime/unit/RTL12/update-emits-with-error-0` + **Spec requirement:** An attached channel may receive an additional `ATTACHED` `ProtocolMessage` from Ably at any point. If and only if the `resumed` flag is false, this should result in the channel emitting an `UPDATE` event with a @@ -81,6 +83,8 @@ CLOSE_CLIENT(client) ## RTL12 - Additional ATTACHED with resumed=true does NOT emit UPDATE +**Test ID**: `realtime/unit/RTL12/resumed-no-update-1` + **Spec requirement:** The UPDATE event should only be emitted if and only if the `resumed` flag is false. When `resumed` is true, the additional ATTACHED message indicates a successful resume with no loss of continuity, and no event should be @@ -140,6 +144,8 @@ CLOSE_CLIENT(client) ## RTL12 - Additional ATTACHED without error has null reason +**Test ID**: `realtime/unit/RTL12/no-error-null-reason-2` + **Spec requirement:** The `reason` attribute is set to the `error` member of the `ATTACHED` `ProtocolMessage` (if any). diff --git a/uts/realtime/unit/channels/channel_annotations.md b/uts/realtime/unit/channels/channel_annotations.md index 4c9e64384..fff3bf083 100644 --- a/uts/realtime/unit/channels/channel_annotations.md +++ b/uts/realtime/unit/channels/channel_annotations.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL26 — channel.annotations returns RealtimeAnnotations +**Test ID**: `realtime/unit/RTL26/annotations-attribute-type-0` + **Spec requirement:** RTL26 — `RealtimeChannel#annotations` attribute contains the `RealtimeAnnotations` object for this channel. Tests that the channel exposes an `annotations` attribute of type `RealtimeAnnotations`. @@ -42,6 +44,8 @@ CLOSE_CLIENT(client) ## RTAN1a, RTAN1c — publish sends ANNOTATION ProtocolMessage with ANNOTATION_CREATE +**Test ID**: `realtime/unit/RTAN1a/publish-sends-annotation-0` + | Spec | Requirement | |------|-------------| | RTAN1a | Accepts same arguments and performs same validation, field setting, and data encoding as RSAN1 | @@ -115,6 +119,8 @@ CLOSE_CLIENT(client) ## RTAN1a — publish validates type is required +**Test ID**: `realtime/unit/RTAN1a/validates-type-required-1` + **Spec requirement:** RTAN1a — Performs the same validation as RSAN1. Per RSAN1a3, the `type` field is required. Tests that publishing an annotation without a `type` field throws an error. @@ -160,6 +166,8 @@ CLOSE_CLIENT(client) ## RTAN1a — publish encodes data per RSL4 +**Test ID**: `realtime/unit/RTAN1a/encodes-data-json-2` + **Spec requirement:** RTAN1a — Performs the same data encoding as RSAN1. Per RSAN1c3, data must be encoded per RSL4. Tests that JSON data in an annotation is encoded following message encoding rules. @@ -226,6 +234,8 @@ CLOSE_CLIENT(client) ## RTAN1b — publish has same connection and channel state conditions as message publishing +**Test ID**: `realtime/unit/RTAN1b/publish-channel-state-0` + **Spec requirement:** RTAN1b — Has the same connection and channel state conditions as message publishing, see RTL6c. Tests that annotation publish fails in FAILED and SUSPENDED channel states, matching the behaviour tested in `uts/test/realtime/unit/channels/channel_publish.md` (RTL6c4). The same connection and channel state preconditions apply. @@ -281,6 +291,8 @@ CLOSE_CLIENT(client) ## RTAN1d — publish indicates success/failure via ACK/NACK +**Test ID**: `realtime/unit/RTAN1d/publish-ack-nack-0` + **Spec requirement:** RTAN1d — Must indicate success or failure of the publish (once ACKed or NACKed) in the same way as `RealtimeChannel#publish`. Tests that the publish resolves on ACK and rejects on NACK. @@ -377,6 +389,8 @@ CLOSE_CLIENT(client) ## RTAN2a — delete sends ANNOTATION ProtocolMessage with ANNOTATION_DELETE +**Test ID**: `realtime/unit/RTAN2a/delete-sends-annotation-0` + **Spec requirement:** RTAN2a — Must be identical to RTAN1 `publish()` except that the `Annotation.action` is set to `ANNOTATION_DELETE`, not `ANNOTATION_CREATE`. Tests that `annotations.delete()` sends ANNOTATION_DELETE. @@ -452,6 +466,8 @@ CLOSE_CLIENT(client) ## RTAN4a, RTAN4b — subscribe delivers annotations from ANNOTATION ProtocolMessage +**Test ID**: `realtime/unit/RTAN4a/subscribe-delivers-annotations-0` + | Spec | Requirement | |------|-------------| | RTAN4a | Should support the same set of type signatures as `RealtimeChannel#subscribe` (RTL7), except `name` is called `type` | @@ -550,6 +566,8 @@ CLOSE_CLIENT(client) ## RTAN4c — subscribe with type filter delivers only matching annotations +**Test ID**: `realtime/unit/RTAN4c/subscribe-type-filter-0` + **Spec requirement:** RTAN4c — If the user subscribes with a `type` (or array of types), the SDK must deliver only annotations whose `type` field exactly equals the requested type. Tests that type-filtered subscription only delivers matching annotations. @@ -640,6 +658,8 @@ CLOSE_CLIENT(client) ## RTAN4d — subscribe implicitly attaches channel +**Test ID**: `realtime/unit/RTAN4d/subscribe-implicit-attach-0` + **Spec requirement:** RTAN4d — Has the same connection and channel state preconditions and return value as `RealtimeChannel#subscribe`, including implicitly attaching unless the user requests otherwise per RTL7g/RTL7h. Tests that subscribing to annotations triggers an implicit attach from INITIALIZED state when `attachOnSubscribe` is true (the default). @@ -692,6 +712,8 @@ CLOSE_CLIENT(client) ## RTAN4e — subscribe warns when ANNOTATION_SUBSCRIBE mode not granted +**Test ID**: `realtime/unit/RTAN4e/subscribe-warns-no-mode-0` + **Spec requirement:** RTAN4e — Once the channel is in the attached state, the channel modes are checked for the presence of the `ANNOTATION_SUBSCRIBE` mode. If missing, the library should log a warning. Tests that a warning is logged when the channel is attached without ANNOTATION_SUBSCRIBE mode. @@ -756,6 +778,8 @@ CLOSE_CLIENT(client) ## RTAN4e1 — subscribe does not warn when not attached and attachOnSubscribe is false +**Test ID**: `realtime/unit/RTAN4e1/no-warn-unattached-0` + **Spec requirement:** RTAN4e1 — This check does not apply if `attachOnSubscribe` has been set to `false` and the channel is not attached. Tests that no ANNOTATION_SUBSCRIBE warning is emitted when the channel is not attached and attachOnSubscribe is false. @@ -813,6 +837,8 @@ CLOSE_CLIENT(client) ## RTAN5a — unsubscribe removes listeners +**Test ID**: `realtime/unit/RTAN5a/unsubscribe-removes-listeners-0` + **Spec requirement:** RTAN5a — Should support the same set of type signatures as `RealtimeChannel#unsubscribe` (RTL8), except that the `name` argument is called `type`. Tests that unsubscribing removes annotation listeners. @@ -905,6 +931,8 @@ CLOSE_CLIENT(client) ## RTAN5a — unsubscribe with type removes only type-filtered listener +**Test ID**: `realtime/unit/RTAN5a/unsubscribe-type-filter-1` + Tests that unsubscribing with a type filter only removes that specific type's listener. ### Setup diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md index ad59ccb8a..ae887eb59 100644 --- a/uts/realtime/unit/channels/channel_attach.md +++ b/uts/realtime/unit/channels/channel_attach.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL4a - Attach when already attached is no-op +**Test ID**: `realtime/unit/RTL4a/already-attached-noop-0` + **Spec requirement:** If already ATTACHED nothing is done. Tests that calling attach on an already-attached channel returns immediately. @@ -64,6 +66,8 @@ CLOSE_CLIENT(client) ## RTL4h - Attach while attaching waits for completion +**Test ID**: `realtime/unit/RTL4h/attach-while-attaching-0` + **Spec requirement:** If the channel is in a pending state ATTACHING, do the attach operation after the completion of the pending request. Tests that calling attach while already attaching waits for the first attach to complete. @@ -124,6 +128,8 @@ CLOSE_CLIENT(client) ## RTL4h - Attach while detaching waits then attaches +**Test ID**: `realtime/unit/RTL4h/attach-while-detaching-1` + **Spec requirement:** If the channel is in a pending state DETACHING, do the attach operation after the completion of the pending request. Tests that calling attach while detaching waits for detach to complete, then attaches. @@ -194,6 +200,8 @@ CLOSE_CLIENT(client) ## RTL4g - Attach from failed state proceeds with attach +**Test ID**: `realtime/unit/RTL4g/attach-from-failed-0` + **Spec requirement:** If the channel is in the FAILED state, the attach request proceeds with a channel attach described in RTL4b, RTL4i and RTL4c. Tests that a channel in the FAILED state can be re-attached. errorReason clearing is verified as part of the RTL4c behavior (successful attach clears errorReason). @@ -255,6 +263,8 @@ CLOSE_CLIENT(client) ## RTL4c - Successful attach clears errorReason +**Test ID**: `realtime/unit/RTL4c/clears-error-reason-0` + **Spec requirement:** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. Tests that errorReason is cleared on any successful attach, not just from the FAILED state. This test uses a SUSPENDED channel (which has errorReason set from a previous error) to verify the clearing applies to all successful attaches. @@ -334,6 +344,8 @@ CLOSE_CLIENT(client) ## RTL4b - Attach fails when connection is closed +**Test ID**: `realtime/unit/RTL4b/fails-connection-closed-0` + **Spec requirement:** If the connection state is CLOSED, CLOSING, SUSPENDED or FAILED, the attach request results in an error. Tests that attach fails when connection is in closed state. @@ -375,6 +387,8 @@ CLOSE_CLIENT(client) ## RTL4b - Attach fails when connection is failed +**Test ID**: `realtime/unit/RTL4b/fails-connection-failed-1` + **Spec requirement:** If the connection state is FAILED, the attach request results in an error. Tests that attach fails when connection is in failed state. @@ -419,6 +433,8 @@ CLOSE_CLIENT(client) ## RTL4b - Attach fails when connection is suspended +**Test ID**: `realtime/unit/RTL4b/fails-connection-suspended-2` + **Spec requirement:** If the connection state is SUSPENDED, the attach request results in an error. Tests that attach fails when connection is in suspended state. @@ -464,6 +480,8 @@ CLOSE_CLIENT(client) ## RTL4i - Attach queued when connection is connecting +**Test ID**: `realtime/unit/RTL4i/queued-while-connecting-0` + **Spec requirement:** If the connection state is INITIALIZED, CONNECTING or DISCONNECTED, the channel should be put into the ATTACHING state. Tests that attach transitions channel to attaching when connection is connecting. @@ -507,6 +525,8 @@ CLOSE_CLIENT(client) ## RTL4i - Attach completes when connection becomes connected +**Test ID**: `realtime/unit/RTL4i/completes-on-connected-1` + **Spec requirement:** Attach message will be sent once the connection becomes CONNECTED. Tests that queued attach completes when connection is established. @@ -565,6 +585,8 @@ CLOSE_CLIENT(client) ## RTL4c - Attach sends ATTACH message and transitions to attaching +**Test ID**: `realtime/unit/RTL4c/sends-attach-message-1` + **Spec requirement:** An ATTACH ProtocolMessage is sent to the server, the state transitions to ATTACHING. Tests the normal attach flow. @@ -618,6 +640,8 @@ CLOSE_CLIENT(client) ## RTL4c1 - ATTACH message includes channelSerial when available +**Test ID**: `realtime/unit/RTL4c1/includes-channel-serial-0` + **Spec requirement:** The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial. If the RTL15b channelSerial is not set, the field may be set to null or omitted. Tests that channelSerial is included in ATTACH message when available. Uses setOptions (RTL16a) to trigger a reattach without going through DETACHED state, since RTL15b1 clears channelSerial on DETACHED. @@ -672,6 +696,8 @@ CLOSE_CLIENT(client) ## RTL4f - Attach times out and transitions to suspended +**Test ID**: `realtime/unit/RTL4f/timeout-to-suspended-0` + **Spec requirement:** If an ATTACHED ProtocolMessage is not received within realtimeRequestTimeout, the attach request should be treated as though it has failed and the channel should transition to the SUSPENDED state. Tests attach timeout behavior. @@ -724,6 +750,8 @@ CLOSE_CLIENT(client) ## RTL4k - ATTACH includes params from ChannelOptions +**Test ID**: `realtime/unit/RTL4k/includes-channel-params-0` + **Spec requirement:** If the user has specified a non-empty params object in the ChannelOptions, it must be included in a params field of the ATTACH ProtocolMessage. Tests that channel params are included in ATTACH message. @@ -775,6 +803,8 @@ CLOSE_CLIENT(client) ## RTL4l - ATTACH includes modes as flags +**Test ID**: `realtime/unit/RTL4l/modes-encoded-as-flags-0` + **Spec requirement:** If the user has specified a modes array in the ChannelOptions, it must be encoded as a bitfield and set as the flags field of the ATTACH ProtocolMessage. Tests that channel modes are encoded in ATTACH flags. @@ -827,6 +857,8 @@ CLOSE_CLIENT(client) ## RTL4m - Channel modes populated from ATTACHED response +**Test ID**: `realtime/unit/RTL4m/modes-from-attached-0` + **Spec requirement:** On receipt of an ATTACHED, the client library should decode the flags into an array of ChannelModes and expose it as a read-only modes field. Tests that modes are decoded from ATTACHED flags. @@ -872,6 +904,8 @@ CLOSE_CLIENT(client) ## RTL4j - ATTACH_RESUME flag set for reattach +**Test ID**: `realtime/unit/RTL4j/attach-resume-flag-0` + **Spec requirement:** If the attach is not a clean attach, the library should set the ATTACH_RESUME flag in the ATTACH message. Per RTL4j1, `attachResume` is cleared when the channel enters DETACHING or FAILED, so a detach+reattach IS a clean attach and should NOT have ATTACH_RESUME. A reattach while still attached (e.g. via setOptions) is NOT a clean attach and SHOULD have ATTACH_RESUME. Tests that ATTACH_RESUME flag is set on reattach while attached, but not on a clean attach. diff --git a/uts/realtime/unit/channels/channel_attributes.md b/uts/realtime/unit/channels/channel_attributes.md index ccd002b1d..72d4a6c4f 100644 --- a/uts/realtime/unit/channels/channel_attributes.md +++ b/uts/realtime/unit/channels/channel_attributes.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL23 - RealtimeChannel name attribute +**Test ID**: `realtime/unit/RTL23/name-attribute-0` + **Spec requirement:** `RealtimeChannel#name` attribute is a string containing the channel's name. @@ -41,6 +43,8 @@ CLOSE_CLIENT(client) ## RTL24 - errorReason set on channel error +**Test ID**: `realtime/unit/RTL24/error-reason-channel-error-0` + **Spec requirement:** `RealtimeChannel#errorReason` attribute is an optional `ErrorInfo` object which is set by the library when an error occurs on the channel. @@ -120,6 +124,8 @@ CLOSE_CLIENT(client) ## RTL24 - errorReason set on attach failure +**Test ID**: `realtime/unit/RTL24/error-reason-attach-failure-1` + **Spec requirement:** `RealtimeChannel#errorReason` is set by the library when an error occurs on the channel, as described by RTL4g. @@ -189,6 +195,8 @@ CLOSE_CLIENT(client) ## RTL4c/RTL24 - errorReason cleared on successful attach +**Test ID**: `realtime/unit/RTL4c/error-cleared-on-attach-0` + **Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. Tests that errorReason is reset to null after a successful attach following a @@ -271,6 +279,8 @@ CLOSE_CLIENT(client) ## RTL4c/RTL24 - errorReason cleared on successful attach, preserved through detach +**Test ID**: `realtime/unit/RTL4c/error-cleared-preserved-detach-1` + **Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. Tests that after an error puts the channel in FAILED, a successful re-attach diff --git a/uts/realtime/unit/channels/channel_connection_state.md b/uts/realtime/unit/channels/channel_connection_state.md index e86e41844..3b3c48461 100644 --- a/uts/realtime/unit/channels/channel_connection_state.md +++ b/uts/realtime/unit/channels/channel_connection_state.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL3e - DISCONNECTED has no effect on ATTACHED channel +**Test ID**: `realtime/unit/RTL3e/disconnected-attached-noop-0` + **Spec requirement:** If the connection state enters the DISCONNECTED state, it will have no effect on the channel states. Tests that a channel in the ATTACHED state is unaffected when the connection transitions to DISCONNECTED. @@ -68,6 +70,8 @@ CLOSE_CLIENT(client) ## RTL3e - DISCONNECTED has no effect on ATTACHING channel +**Test ID**: `realtime/unit/RTL3e/disconnected-attaching-noop-1` + **Spec requirement:** If the connection state enters the DISCONNECTED state, it will have no effect on the channel states. Tests that a channel in the ATTACHING state is unaffected when the connection transitions to DISCONNECTED. @@ -122,6 +126,8 @@ CLOSE_CLIENT(client) ## RTL3a - FAILED connection transitions ATTACHED channel to FAILED +**Test ID**: `realtime/unit/RTL3a/failed-attached-to-failed-0` + **Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED and set the `RealtimeChannel#errorReason`. Tests that attached channels transition to FAILED when the connection enters FAILED state. @@ -190,6 +196,8 @@ CLOSE_CLIENT(client) ## RTL3a - FAILED connection transitions ATTACHING channel to FAILED +**Test ID**: `realtime/unit/RTL3a/failed-attaching-to-failed-1` + **Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED and set the `RealtimeChannel#errorReason`. Tests that a channel in the ATTACHING state transitions to FAILED when the connection enters FAILED. @@ -255,6 +263,8 @@ CLOSE_CLIENT(client) ## RTL3a - Channels in other states are unaffected by FAILED connection +**Test ID**: `realtime/unit/RTL3a/other-states-unaffected-2` + **Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED. Tests that channels in INITIALIZED, DETACHED, SUSPENDED, or FAILED states are not affected when the connection enters FAILED. @@ -331,6 +341,8 @@ CLOSE_CLIENT(client) ## RTL3b - CLOSED connection transitions ATTACHED channel to DETACHED +**Test ID**: `realtime/unit/RTL3b/closed-attached-to-detached-0` + **Spec requirement:** If the connection state enters the CLOSED state, then an ATTACHING or ATTACHED channel state will transition to DETACHED. Tests that an attached channel transitions to DETACHED when the connection is explicitly closed. @@ -386,6 +398,8 @@ CLOSE_CLIENT(client) ## RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED +**Test ID**: `realtime/unit/RTL3b/closed-attaching-to-detached-1` + **Spec requirement:** If the connection state enters the CLOSED state, then an ATTACHING or ATTACHED channel state will transition to DETACHED. Tests that a channel in the ATTACHING state transitions to DETACHED when the connection is closed. @@ -443,6 +457,8 @@ CLOSE_CLIENT(client) ## RTL3c - SUSPENDED connection transitions ATTACHED channel to SUSPENDED +**Test ID**: `realtime/unit/RTL3c/suspended-attached-to-suspended-0` + **Spec requirement:** If the connection state enters the SUSPENDED state, then an ATTACHING or ATTACHED channel state will transition to SUSPENDED. Tests that an attached channel transitions to SUSPENDED when the connection enters SUSPENDED state. @@ -524,6 +540,8 @@ CLOSE_CLIENT(client) ## RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED +**Test ID**: `realtime/unit/RTL3c/suspended-attaching-to-suspended-1` + **Spec requirement:** If the connection state enters the SUSPENDED state, then an ATTACHING or ATTACHED channel state will transition to SUSPENDED. Tests that a channel in the ATTACHING state transitions to SUSPENDED when the connection enters SUSPENDED state. @@ -604,6 +622,8 @@ CLOSE_CLIENT(client) ## RTL3d, RTL4c1 - CONNECTED connection re-attaches ATTACHED channels with channelSerial +**Test ID**: `realtime/unit/RTL3d/reattach-attached-with-serial-0` + | Spec | Requirement | |------|-------------| | RTL3d | If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence | @@ -683,6 +703,8 @@ CLOSE_CLIENT(client) ## RTL3d - CONNECTED connection re-attaches SUSPENDED channels +**Test ID**: `realtime/unit/RTL3d/reattach-suspended-channels-1` + **Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence. Tests that suspended channels are re-attached when the connection is re-established. @@ -787,6 +809,8 @@ CLOSE_CLIENT(client) ## RTL3d - Channels in INITIALIZED or DETACHED are not re-attached on CONNECTED +**Test ID**: `realtime/unit/RTL3d/init-detached-not-reattached-2` + **Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING. Tests that channels in INITIALIZED or DETACHED states are not affected when the connection becomes CONNECTED. @@ -871,6 +895,8 @@ CLOSE_CLIENT(client) ## RTL3d - Multiple channels re-attached on CONNECTED +**Test ID**: `realtime/unit/RTL3d/multiple-channels-reattached-3` + **Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence. Tests that multiple channels in eligible states are all re-attached when the connection is restored. diff --git a/uts/realtime/unit/channels/channel_delta_decoding.md b/uts/realtime/unit/channels/channel_delta_decoding.md index 28c2bbde2..674fe657c 100644 --- a/uts/realtime/unit/channels/channel_delta_decoding.md +++ b/uts/realtime/unit/channels/channel_delta_decoding.md @@ -26,6 +26,8 @@ See `uts/test/realtime/unit/helpers/mock_vcdiff.md` for the full Mock VCDiff Inf ## RTL21 - Messages in array decoded in ascending index order +**Test ID**: `realtime/unit/RTL21/ascending-index-order-0` + **Spec requirement:** The messages in the `messages` array of a `ProtocolMessage` should each be decoded in ascending order of their index in the array. Tests that when a ProtocolMessage contains multiple messages where later messages @@ -120,6 +122,8 @@ CLOSE_CLIENT(client) ## RTL19b - Non-delta message stores base payload +**Test ID**: `realtime/unit/RTL19b/stores-base-payload-0` + **Spec requirement:** In the case of a non-delta message, the resulting `data` value is stored as the base payload. Tests that after receiving a non-delta message, its data is stored as the base @@ -207,6 +211,8 @@ CLOSE_CLIENT(client) ## RTL19b - JSON-encoded non-delta message stores wire-form base payload +**Test ID**: `realtime/unit/RTL19b/json-wire-form-base-1` + **Spec requirement:** In the case of a non-delta message, the resulting `data` value is stored as the base payload. @@ -315,6 +321,8 @@ CLOSE_CLIENT(client) ## RTL19a - Base64 encoding step decoded before storing base payload +**Test ID**: `realtime/unit/RTL19a/base64-decoded-before-store-0` + **Spec requirement:** When processing any message (whether a delta or a full message), if the message `encoding` string ends in `base64`, the message `data` should be base64-decoded (and the `encoding` string modified accordingly per RSL6). Tests that a base64-encoded non-delta message is decoded before its data is @@ -411,6 +419,8 @@ CLOSE_CLIENT(client) ## RTL19c - Delta application result stored as new base payload +**Test ID**: `realtime/unit/RTL19c/delta-result-becomes-base-0` + **Spec requirement:** In the case of a delta message with a `vcdiff` encoding step, the `vcdiff` decoder must be used to decode the base payload of the delta message, applying that delta to the stored base payload. The direct result of that vcdiff delta application, before performing any further decoding steps, is stored as the updated base payload. Tests that after decoding a delta message, the decoded result becomes the new @@ -519,6 +529,8 @@ CLOSE_CLIENT(client) ## RTL20 - Delta with mismatched base message ID triggers recovery +**Test ID**: `realtime/unit/RTL20/mismatched-id-triggers-recovery-0` + **Spec requirement:** The `id` of the last received message on each channel must be stored along with the base payload. When processing a delta message, the stored last message `id` must be compared against the delta reference `id` in `Message.extras.delta.from`. If the delta reference `id` does not equal the stored `id`, the message decoding must fail and the recovery procedure from RTL18 must be executed. Tests that when a delta message references a message ID that doesn't match the @@ -626,6 +638,8 @@ CLOSE_CLIENT(client) ## RTL20 - Last message ID updated after successful decode +**Test ID**: `realtime/unit/RTL20/last-id-updated-on-decode-1` + **Spec requirement:** The `id` of the last received message on each channel must be stored along with the base payload. Tests that the stored last message ID is updated to the ID of the last message @@ -723,6 +737,8 @@ CLOSE_CLIENT(client) ## PC3, PC3a - VCDiff plugin decodes delta messages +**Test ID**: `realtime/unit/PC3/vcdiff-plugin-decodes-0` + | Spec | Requirement | |------|-------------| | PC3 | A plugin provided with PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages | @@ -832,6 +848,8 @@ CLOSE_CLIENT(client) ## PC3 - No vcdiff plugin causes FAILED state +**Test ID**: `realtime/unit/PC3/no-plugin-fails-1` + **Spec requirement:** A plugin provided with the PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages. Without it, vcdiff-encoded messages cannot be decoded. Tests that when a vcdiff-encoded message is received but no vcdiff plugin is @@ -903,6 +921,8 @@ CLOSE_CLIENT(client) ## RTL18 - Decode failure triggers recovery (RTL18a, RTL18b, RTL18c) +**Test ID**: `realtime/unit/RTL18/decode-failure-recovery-0` + | Spec | Requirement | |------|-------------| | RTL18a | Log error with code 40018 | @@ -1020,6 +1040,8 @@ CLOSE_CLIENT(client) ## RTL18c - Recovery completes when server sends ATTACHED +**Test ID**: `realtime/unit/RTL18c/recovery-completes-on-attached-0` + **Spec requirement:** Send an ATTACH ProtocolMessage and wait for a confirmation ATTACHED, as per RTL4c and RTL4f. Tests that after decode failure recovery, the channel returns to ATTACHED state @@ -1147,6 +1169,8 @@ CLOSE_CLIENT(client) ## RTL18 - Only one recovery in progress at a time +**Test ID**: `realtime/unit/RTL18/single-recovery-at-time-1` + **Spec requirement:** The client must automatically execute the recovery procedure. (Implied: concurrent decode failures should not trigger multiple simultaneous recovery attempts.) Tests that if multiple delta decode failures occur in quick succession, only one diff --git a/uts/realtime/unit/channels/channel_detach.md b/uts/realtime/unit/channels/channel_detach.md index c62f0f45b..40a1a16a7 100644 --- a/uts/realtime/unit/channels/channel_detach.md +++ b/uts/realtime/unit/channels/channel_detach.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL5a - Detach when initialized is no-op +**Test ID**: `realtime/unit/RTL5a/detach-initialized-noop-0` + **Spec requirement:** If the channel state is INITIALIZED or DETACHED nothing is done. Tests that detach on an initialized channel returns immediately. @@ -49,6 +51,8 @@ CLOSE_CLIENT(client) ## RTL5a - Detach when already detached is no-op +**Test ID**: `realtime/unit/RTL5a/detach-already-detached-noop-1` + **Spec requirement:** If the channel state is INITIALIZED or DETACHED nothing is done. Tests that detach on an already-detached channel returns immediately. @@ -106,6 +110,8 @@ CLOSE_CLIENT(client) ## RTL5i - Detach while detaching waits for completion +**Test ID**: `realtime/unit/RTL5i/detach-while-detaching-0` + **Spec requirement:** If the channel is in a pending state DETACHING, do the detach operation after the completion of the pending request. Tests that calling detach while already detaching waits for the first detach to complete. @@ -172,6 +178,8 @@ CLOSE_CLIENT(client) ## RTL5i - Detach while attaching waits then detaches +**Test ID**: `realtime/unit/RTL5i/detach-while-attaching-1` + **Spec requirement:** If the channel is in a pending state ATTACHING, do the detach operation after the completion of the pending request. Tests that calling detach while attaching waits for attach to complete, then detaches. @@ -244,6 +252,8 @@ CLOSE_CLIENT(client) ## RTL5b - Detach from failed state results in error +**Test ID**: `realtime/unit/RTL5b/detach-failed-errors-0` + **Spec requirement:** If the channel state is FAILED, the detach request results in an error. Tests that detach fails when channel is in failed state. @@ -294,6 +304,8 @@ CLOSE_CLIENT(client) ## RTL5j - Detach from suspended transitions to detached +**Test ID**: `realtime/unit/RTL5j/detach-suspended-to-detached-0` + **Spec requirement:** If the channel state is SUSPENDED, the detach request transitions the channel immediately to the DETACHED state. Tests that detach from suspended state transitions directly to detached without sending DETACH message. @@ -352,6 +364,8 @@ CLOSE_CLIENT(client) ## RTL5l - Detach when connection not connected transitions immediately +**Test ID**: `realtime/unit/RTL5l/detach-not-connected-immediate-0` + **Spec requirement:** If the connection state is anything other than CONNECTED and none of the preceding channel state conditions apply, the channel transitions immediately to the DETACHED state. Tests that detach transitions immediately to detached when connection is not connected. @@ -401,6 +415,8 @@ CLOSE_CLIENT(client) ### RTL5l - Detach ATTACHED channel when connection disconnected +**Test ID**: `realtime/unit/RTL5l/detach-attached-when-disconnected-1` + When an ATTACHED channel is detached while the connection is DISCONNECTED, the channel transitions directly to DETACHED without sending a DETACH message (since the transport is unavailable). @@ -466,6 +482,8 @@ ASSERT detach_messages.length == 0 ## RTL5d - Normal detach flow +**Test ID**: `realtime/unit/RTL5d/normal-detach-flow-0` + **Spec requirement:** A DETACH ProtocolMessage is sent to the server, the state transitions to DETACHING and the channel becomes DETACHED when the confirmation DETACHED ProtocolMessage is received. Tests the normal detach flow when connection is connected. @@ -526,6 +544,8 @@ CLOSE_CLIENT(client) ## RTL5f - Detach timeout returns to previous state +**Test ID**: `realtime/unit/RTL5f/timeout-returns-previous-state-0` + **Spec requirement:** If a DETACHED ProtocolMessage is not received within realtimeRequestTimeout, the detach request should be treated as though it has failed and the channel will return to its previous state. Tests detach timeout behavior. @@ -585,6 +605,8 @@ CLOSE_CLIENT(client) ## RTL5k - ATTACHED received while detaching sends new DETACH +**Test ID**: `realtime/unit/RTL5k/attached-while-detaching-0` + **Spec requirement:** If the channel receives an ATTACHED message while in the DETACHING or DETACHED state, it should send a new DETACH message and remain in (or transition to) the DETACHING state. Tests that unexpected ATTACHED message during detach triggers new DETACH. @@ -646,6 +668,8 @@ CLOSE_CLIENT(client) ## RTL5k - ATTACHED received while detached sends DETACH +**Test ID**: `realtime/unit/RTL5k/attached-while-detached-1` + **Spec requirement:** If the channel receives an ATTACHED message while in the DETACHED state, it should send a new DETACH message. Tests that unexpected ATTACHED message while detached triggers DETACH. @@ -708,6 +732,8 @@ CLOSE_CLIENT(client) ## RTL5 - Detach emits state change events +**Test ID**: `realtime/unit/RTL5/detach-state-change-events-0` + **Spec requirement:** Channel emits state change events during detach. Tests that appropriate state change events are emitted during detach. diff --git a/uts/realtime/unit/channels/channel_error.md b/uts/realtime/unit/channels/channel_error.md index 8d7a8f37f..c050b7940 100644 --- a/uts/realtime/unit/channels/channel_error.md +++ b/uts/realtime/unit/channels/channel_error.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL14 - Channel ERROR transitions ATTACHED channel to FAILED +**Test ID**: `realtime/unit/RTL14/attached-to-failed-0` + **Spec requirement:** If an ERROR ProtocolMessage is received for this channel (the channel attribute matches this channel's name), then the channel should immediately transition to the FAILED state, and the RealtimeChannel.errorReason should be set. Tests that receiving a channel-scoped ERROR while ATTACHED causes the channel to transition to FAILED with the error. @@ -88,6 +90,8 @@ CLOSE_CLIENT(client) ## RTL14 - Channel ERROR transitions ATTACHING channel to FAILED +**Test ID**: `realtime/unit/RTL14/attaching-to-failed-1` + **Spec requirement:** If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to FAILED. Tests that receiving a channel-scoped ERROR while ATTACHING causes the channel to transition to FAILED and the pending attach operation to fail. @@ -147,6 +151,8 @@ CLOSE_CLIENT(client) ## RTL14 - Channel ERROR completes pending detach with error +**Test ID**: `realtime/unit/RTL14/pending-detach-error-2` + **Spec requirement:** If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to FAILED. Tests that if a channel ERROR is received while a detach is pending (DETACHING state), the channel transitions to FAILED and the pending detach operation fails with the error. @@ -214,6 +220,8 @@ CLOSE_CLIENT(client) ## RTL14 - Channel ERROR does not affect other channels +**Test ID**: `realtime/unit/RTL14/other-channels-unaffected-3` + **Spec requirement:** The ERROR ProtocolMessage with a channel attribute only affects that specific channel. Tests that a channel-scoped ERROR only transitions the target channel to FAILED, leaving other channels unaffected. @@ -281,6 +289,8 @@ CLOSE_CLIENT(client) ## RTL14 - Channel ERROR cancels pending timers +**Test ID**: `realtime/unit/RTL14/cancels-pending-timers-4` + **Spec requirement:** When the channel transitions to FAILED, any pending timers (attach timeout, channel retry) should be cancelled. Tests that receiving a channel ERROR while a channel retry timer is pending cancels the timer. diff --git a/uts/realtime/unit/channels/channel_get_message.md b/uts/realtime/unit/channels/channel_get_message.md index 98471eef0..c9ea1fad9 100644 --- a/uts/realtime/unit/channels/channel_get_message.md +++ b/uts/realtime/unit/channels/channel_get_message.md @@ -9,6 +9,8 @@ Unit test with mocked HTTP client ## RTL28 - RealtimeChannel#getMessage is identical to RestChannel#getMessage +**Test ID**: `realtime/unit/RTL28/identical-to-rest-0` + **Spec requirement:** `RealtimeChannel#getMessage` function: same as `RestChannel#getMessage`. `RealtimeChannel#getMessage` uses the same underlying REST endpoint as `RestChannel#getMessage`. The tests in `uts/test/rest/unit/channel/get_message.md` (covering RSL11) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. diff --git a/uts/realtime/unit/channels/channel_history.md b/uts/realtime/unit/channels/channel_history.md index 9770bb8bf..7f8fcd4d2 100644 --- a/uts/realtime/unit/channels/channel_history.md +++ b/uts/realtime/unit/channels/channel_history.md @@ -9,6 +9,8 @@ Unit test with mocked HTTP client ## RTL10a - RealtimeChannel#history supports all RestChannel#history params +**Test ID**: `realtime/unit/RTL10a/supports-rest-params-0` + | Spec | Requirement | |------|-------------| | RTL10a | Supports all the same params as `RestChannel#history` | @@ -24,6 +26,8 @@ Unit test with mocked HTTP client ### RTL10b - untilAttach adds fromSerial query parameter +**Test ID**: `realtime/unit/RTL10b/adds-from-serial-0` + Tests that when `untilAttach` is true and the channel is attached, the history request includes a `fromSerial` query parameter set to the channel's `attachSerial`. #### Setup @@ -80,6 +84,8 @@ CLOSE_CLIENT(client) ### RTL10b - untilAttach errors when not attached +**Test ID**: `realtime/unit/RTL10b/errors-when-not-attached-1` + Tests that when `untilAttach` is true and the channel is not attached, the history call results in an error. #### Setup diff --git a/uts/realtime/unit/channels/channel_message_versions.md b/uts/realtime/unit/channels/channel_message_versions.md index 8d47d3706..c812d5734 100644 --- a/uts/realtime/unit/channels/channel_message_versions.md +++ b/uts/realtime/unit/channels/channel_message_versions.md @@ -9,6 +9,8 @@ Unit test with mocked HTTP client ## RTL31 - RealtimeChannel#getMessageVersions is identical to RestChannel#getMessageVersions +**Test ID**: `realtime/unit/RTL31/identical-to-rest-0` + **Spec requirement:** `RealtimeChannel#getMessageVersions` function: same as `RestChannel#getMessageVersions`. `RealtimeChannel#getMessageVersions` uses the same underlying REST endpoint as `RestChannel#getMessageVersions`. The tests in `uts/test/rest/unit/channel/message_versions.md` (covering RSL14) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. diff --git a/uts/realtime/unit/channels/channel_options.md b/uts/realtime/unit/channels/channel_options.md index 0a7b0a179..bb7d1bbb6 100644 --- a/uts/realtime/unit/channels/channel_options.md +++ b/uts/realtime/unit/channels/channel_options.md @@ -11,6 +11,8 @@ These tests verify channel options and derived channel functionality. ## TB2 - ChannelOptions attributes +**Test ID**: `realtime/unit/TB2/channel-options-attributes-0` + | Spec | Requirement | |------|-------------| | TB2b | `cipher` - CipherParams for encryption | @@ -37,6 +39,8 @@ ASSERT options.attachOnSubscribe == true ## TB2c - ChannelOptions with params +**Test ID**: `realtime/unit/TB2c/options-with-params-0` + **Spec requirement:** `params` is a Dict of key/value pairs for channel parameters. Tests that channel options can be created with params. @@ -58,6 +62,8 @@ ASSERT options.params["delta"] == "vcdiff" ## TB2d - ChannelOptions with modes +**Test ID**: `realtime/unit/TB2d/options-with-modes-0` + **Spec requirement:** `modes` is an array of ChannelMode. Tests that channel options can be created with modes. @@ -80,6 +86,8 @@ ASSERT length(options.modes) == 2 ## TB3 - withCipherKey constructor +**Test ID**: `realtime/unit/TB3/with-cipher-key-0` + **Spec requirement:** Optional constructor that takes a key only. Tests the withCipherKey factory constructor. @@ -102,6 +110,8 @@ ASSERT options.cipherParams.keyLength == 256 ## TB4 - attachOnSubscribe default +**Test ID**: `realtime/unit/TB4/attach-on-subscribe-default-0` + **Spec requirement:** `attachOnSubscribe` defaults to true. Tests the default value of attachOnSubscribe. @@ -122,6 +132,8 @@ ASSERT options2.attachOnSubscribe == false ## RTS3b - Options set on new channel +**Test ID**: `realtime/unit/RTS3b/options-set-on-new-0` + **Spec requirement:** If options are provided, the options are set on the RealtimeChannel when creating a new RealtimeChannel. Tests that get() with options sets them on new channels. @@ -154,6 +166,8 @@ CLOSE_CLIENT(client) ## RTS3c - Options updated on existing channel (soft-deprecated) +**Test ID**: `realtime/unit/RTS3c/options-updated-existing-0` + **Spec requirement:** Accessing an existing channel with options will update the options. Tests that get() with options updates existing channel (when no reattachment needed). @@ -191,6 +205,8 @@ CLOSE_CLIENT(client) ## RTS3c1 - Error if options would trigger reattachment +**Test ID**: `realtime/unit/RTS3c1/error-reattach-params-0` + **Spec requirement:** If a new set of ChannelOptions is supplied that would trigger a reattachment, it must raise an error. Tests that get() throws error when params/modes change on attached channel. @@ -226,6 +242,8 @@ CLOSE_CLIENT(client) ## RTS3c1 - Error if modes change on attaching channel +**Test ID**: `realtime/unit/RTS3c1/error-reattach-modes-1` + **Spec requirement:** Must raise error if options would trigger reattachment on attaching channel. Tests error when modes change on attaching channel. @@ -255,6 +273,8 @@ CLOSE_CLIENT(client) ## RTL16 - setOptions updates channel options +**Test ID**: `realtime/unit/RTL16/set-options-updates-0` + **Spec requirement:** setOptions takes a ChannelOptions object and sets or updates the stored channel options. Tests that setOptions updates the channel options. @@ -287,6 +307,8 @@ CLOSE_CLIENT(client) ## RTL16a - setOptions triggers reattachment when needed +**Test ID**: `realtime/unit/RTL16a/triggers-reattach-0` + **Spec requirement:** If params or modes are provided and channel is attached, setOptions triggers reattachment. Tests that setOptions with params/modes on attached channel triggers reattachment. @@ -325,6 +347,8 @@ CLOSE_CLIENT(client) ## RTS5a - getDerived creates derived channel +**Test ID**: `realtime/unit/RTS5a/creates-derived-channel-0` + **Spec requirement:** Takes RealtimeChannel name and DeriveOptions to create a derived channel. Tests that getDerived creates a channel with the correct derived name. @@ -355,6 +379,8 @@ CLOSE_CLIENT(client) ## RTS5a1 - Derived channel filter is base64 encoded +**Test ID**: `realtime/unit/RTS5a1/filter-base64-encoded-0` + **Spec requirement:** The filter should be synthesized as [filter=]channelName. Tests that the filter expression is base64 encoded in the channel name. @@ -385,6 +411,8 @@ CLOSE_CLIENT(client) ## RTS5a2 - Derived channel with params +**Test ID**: `realtime/unit/RTS5a2/derived-with-params-0` + **Spec requirement:** If channel options are provided with params, they are included in the derived channel name. Tests that channel params are included in the derived channel name. @@ -434,6 +462,8 @@ CLOSE_CLIENT(client) ## RTS5 - getDerived with options sets them on channel +**Test ID**: `realtime/unit/RTS5/get-derived-with-options-0` + **Spec requirement:** ChannelOptions can be provided as an optional third argument. Tests that getDerived passes options to the created channel. @@ -467,6 +497,8 @@ CLOSE_CLIENT(client) ## DO2a - DeriveOptions filter attribute +**Test ID**: `realtime/unit/DO2a/filter-attribute-0` + **Spec requirement:** DeriveOptions has a filter attribute containing a JMESPath string expression. Tests the DeriveOptions class. diff --git a/uts/realtime/unit/channels/channel_properties.md b/uts/realtime/unit/channels/channel_properties.md index 25747eff3..2a85a7437 100644 --- a/uts/realtime/unit/channels/channel_properties.md +++ b/uts/realtime/unit/channels/channel_properties.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL15a - attachSerial is updated from ATTACHED message +**Test ID**: `realtime/unit/RTL15a/attach-serial-from-attached-0` + | Spec | Requirement | |------|-------------| | RTL15 | `RealtimeChannel#properties` is a `ChannelProperties` object with `attachSerial` and `channelSerial` | @@ -81,6 +83,8 @@ CLOSE_CLIENT(client) ## RTL15a - attachSerial updated on server-initiated reattach +**Test ID**: `realtime/unit/RTL15a/attach-serial-server-reattach-1` + **Spec requirement:** `attachSerial` is updated with the `channelSerial` from each ATTACHED ProtocolMessage received. Tests that when the server sends an unsolicited ATTACHED message (e.g. RTL2g update), the `attachSerial` is updated. @@ -136,6 +140,8 @@ CLOSE_CLIENT(client) ## RTL15b - channelSerial updated from ATTACHED message +**Test ID**: `realtime/unit/RTL15b/channel-serial-from-attached-0` + | Spec | Requirement | |------|-------------| | RTL15b | `channelSerial` is updated whenever a ProtocolMessage with MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED action is received, set to the `channelSerial` of that message, if and only if that field is populated | @@ -187,6 +193,8 @@ CLOSE_CLIENT(client) ## RTL15b - channelSerial updated from MESSAGE and PRESENCE actions +**Test ID**: `realtime/unit/RTL15b/channel-serial-from-messages-1` + **Spec requirement:** `channelSerial` is updated whenever a ProtocolMessage with MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED action is received. Tests that receiving MESSAGE and PRESENCE protocol messages with a `channelSerial` field updates the channel's `channelSerial` property. @@ -257,6 +265,8 @@ CLOSE_CLIENT(client) ## RTL15b - channelSerial not updated when field is not populated +**Test ID**: `realtime/unit/RTL15b/serial-not-updated-empty-2` + **Spec requirement:** `channelSerial` is set to the channelSerial of the ProtocolMessage, if and only if that field is populated. Tests that receiving a protocol message without a `channelSerial` field does not clear or change the channel's existing `channelSerial`. @@ -314,6 +324,8 @@ CLOSE_CLIENT(client) ## RTL15b - channelSerial not updated from irrelevant actions +**Test ID**: `realtime/unit/RTL15b/serial-not-updated-irrelevant-3` + **Spec requirement:** `channelSerial` is updated only for MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED actions. Tests that receiving a protocol message with a different action (e.g. ERROR, DETACHED) does not update `channelSerial`, even if the message happens to contain a `channelSerial` field. @@ -382,6 +394,8 @@ CLOSE_CLIENT(client) ## RTL15b1 - channelSerial cleared on DETACHED state +**Test ID**: `realtime/unit/RTL15b1/serial-cleared-detached-0` + | Spec | Requirement | |------|-------------| | RTL15b1 | If the channel enters the DETACHED, SUSPENDED, or FAILED state, it must clear its channelSerial | @@ -439,6 +453,8 @@ CLOSE_CLIENT(client) ## RTL15b1 - channelSerial cleared on SUSPENDED state +**Test ID**: `realtime/unit/RTL15b1/serial-cleared-suspended-1` + **Spec requirement:** If the channel enters the SUSPENDED state, it must clear its `channelSerial`. Tests that `channelSerial` is cleared when the channel transitions to SUSPENDED (e.g. due to attach timeout). @@ -506,6 +522,8 @@ CLOSE_CLIENT(client) ## RTL15b1 - channelSerial cleared on FAILED state +**Test ID**: `realtime/unit/RTL15b1/serial-cleared-failed-2` + **Spec requirement:** If the channel enters the FAILED state, it must clear its `channelSerial`. Tests that `channelSerial` is cleared when the channel transitions to FAILED (e.g. due to channel ERROR). diff --git a/uts/realtime/unit/channels/channel_publish.md b/uts/realtime/unit/channels/channel_publish.md index a9d6f323d..cd8fa326d 100644 --- a/uts/realtime/unit/channels/channel_publish.md +++ b/uts/realtime/unit/channels/channel_publish.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL6i1 - Publish single message by name and data +**Test ID**: `realtime/unit/RTL6i1/publish-name-and-data-0` + **Spec requirement:** When `name` and `data` (or a `Message`) is provided, a single `ProtocolMessage` containing one `Message` is published to Ably. Tests that publishing with name and data sends a single MESSAGE ProtocolMessage with one message entry. @@ -64,6 +66,8 @@ CLOSE_CLIENT(client) ## RTL6i2 - Publish array of Message objects +**Test ID**: `realtime/unit/RTL6i2/publish-message-array-0` + **Spec requirement:** When an array of `Message` objects is provided, a single `ProtocolMessage` is used to publish all `Message` objects in the array. Tests that publishing an array of messages sends them in a single ProtocolMessage. @@ -118,6 +122,8 @@ CLOSE_CLIENT(client) ## RTL6i3 - Null fields omitted from JSON wire encoding +**Test ID**: `realtime/unit/RTL6i3/null-fields-json-0` + **Spec requirement:** Allows `name` and or `data` to be `null`. If any of the values are `null`, then key is not sent to Ably i.e. a payload with a `null` value for `data` would be sent as follows `{ "name": "click" }`. Tests that when using the JSON protocol, null `name` or `data` fields are omitted from the encoded JSON representation on the wire (not sent as `"name": null`). @@ -193,6 +199,8 @@ CLOSE_CLIENT(client) ## RTL6i3 - Null fields omitted from msgpack wire encoding +**Test ID**: `realtime/unit/RTL6i3/null-fields-msgpack-1` + **Spec requirement:** Allows `name` and or `data` to be `null`. If any of the values are `null`, then key is not sent to Ably. Tests that when using the msgpack protocol, null `name` or `data` fields are omitted from the encoded msgpack representation on the wire. @@ -268,6 +276,8 @@ CLOSE_CLIENT(client) ## RTL6c1 - Publish immediately when CONNECTED and channel ATTACHED +**Test ID**: `realtime/unit/RTL6c1/publish-when-attached-0` + | Spec | Requirement | |------|-------------| | RTL6c1 | If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately | @@ -322,6 +332,8 @@ CLOSE_CLIENT(client) ## RTL6c1 - Publish immediately when CONNECTED and channel ATTACHING +**Test ID**: `realtime/unit/RTL6c1/publish-when-attaching-1` + **Spec requirement:** If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately. Tests that messages are sent immediately even when the channel is in the ATTACHING state (which is neither SUSPENDED nor FAILED). @@ -370,6 +382,8 @@ CLOSE_CLIENT(client) ## RTL6c1 - Publish immediately when CONNECTED and channel INITIALIZED +**Test ID**: `realtime/unit/RTL6c1/publish-when-initialized-2` + **Spec requirement:** If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately. Tests that messages are sent immediately when the channel is in the INITIALIZED state (which is neither SUSPENDED nor FAILED). @@ -419,6 +433,8 @@ CLOSE_CLIENT(client) ## RTL6c2 - Publish queued when connection is CONNECTING +**Test ID**: `realtime/unit/RTL6c2/queued-when-connecting-0` + | Spec | Requirement | |------|-------------| | RTL6c2 | If the connection is `INITIALIZED`, `CONNECTING` or `DISCONNECTED`; and the channel is neither `SUSPENDED` nor `FAILED`; and `ClientOptions#queueMessages` is `true`; then the message will be placed in a connection-wide message queue | @@ -475,6 +491,8 @@ CLOSE_CLIENT(client) ## RTL6c2 - Publish queued when connection is DISCONNECTED +**Test ID**: `realtime/unit/RTL6c2/queued-when-disconnected-1` + **Spec requirement:** Messages are queued when connection is `DISCONNECTED` and `queueMessages` is true. Tests that messages published while the connection is DISCONNECTED are queued and sent once the connection reconnects. @@ -536,6 +554,8 @@ CLOSE_CLIENT(client) ## RTL6c2 - Publish queued when connection is INITIALIZED +**Test ID**: `realtime/unit/RTL6c2/queued-when-initialized-2` + **Spec requirement:** Messages are queued when connection is `INITIALIZED` and `queueMessages` is true. Tests that messages published before `connect()` is called are queued and sent once the connection becomes CONNECTED. @@ -585,6 +605,8 @@ CLOSE_CLIENT(client) ## RTL6c4 - Publish fails when connection is SUSPENDED +**Test ID**: `realtime/unit/RTL6c4/fails-conn-suspended-0` + **Spec requirement:** In any other case the operation should result in an error. Tests that publishing fails immediately when the connection is SUSPENDED. @@ -636,6 +658,8 @@ CLOSE_CLIENT(client) ## RTL6c4 - Publish fails when connection is CLOSED +**Test ID**: `realtime/unit/RTL6c4/fails-conn-closed-1` + **Spec requirement:** In any other case the operation should result in an error. Tests that publishing fails when the connection is CLOSED. @@ -675,6 +699,8 @@ CLOSE_CLIENT(client) ## RTL6c4 - Publish fails when connection is FAILED +**Test ID**: `realtime/unit/RTL6c4/fails-conn-failed-2` + **Spec requirement:** In any other case the operation should result in an error. Tests that publishing fails when the connection is FAILED. @@ -719,6 +745,8 @@ CLOSE_CLIENT(client) ## RTL6c4 - Publish fails when channel is SUSPENDED +**Test ID**: `realtime/unit/RTL6c4/fails-channel-suspended-3` + **Spec requirement:** If the channel is SUSPENDED, publish results in an error regardless of connection state. Tests that publishing fails when the channel is in SUSPENDED state even though the connection is CONNECTED. @@ -776,6 +804,8 @@ CLOSE_CLIENT(client) ## RTL6c4 - Publish fails when channel is FAILED +**Test ID**: `realtime/unit/RTL6c4/fails-channel-failed-4` + **Spec requirement:** Publishing to a FAILED channel results in an error (RTL6c3/RTL6c4). Tests that publishing fails when the channel is in FAILED state. @@ -828,6 +858,8 @@ CLOSE_CLIENT(client) ## RTL6c2 - Publish fails when queueMessages is false and connection not CONNECTED +**Test ID**: `realtime/unit/RTL6c2/fails-no-queue-messages-3` + **Spec requirement:** Messages are queued only when `queueMessages` is true. When false and connection is not CONNECTED, publish should fail. Tests that publishing fails immediately when queueMessages is false and the connection is not CONNECTED. @@ -870,6 +902,8 @@ CLOSE_CLIENT(client) ## RTL6c5 - Publish does not trigger implicit attach +**Test ID**: `realtime/unit/RTL6c5/no-implicit-attach-0` + **Spec requirement:** A publish should not trigger an implicit attach (in contrast to earlier version of this spec). Tests that publishing on an INITIALIZED channel does not cause the channel to begin attaching. @@ -924,6 +958,8 @@ CLOSE_CLIENT(client) ## RTL6c2 - Multiple queued messages sent in order after connection +**Test ID**: `realtime/unit/RTL6c2/queued-messages-order-4` + **Spec requirement:** Messages queued while not connected are delivered once the connection becomes CONNECTED. Tests that multiple messages queued before connection are all sent in the correct order once connected. @@ -980,6 +1016,8 @@ CLOSE_CLIENT(client) ## RTL6i1 - Publish Message object +**Test ID**: `realtime/unit/RTL6i1/publish-message-object-1` + **Spec requirement:** When a `Message` is provided, a single `ProtocolMessage` containing one `Message` is published to Ably. Tests that publishing a Message object directly sends it correctly. @@ -1029,6 +1067,8 @@ CLOSE_CLIENT(client) ## RTL6j - Publish returns PublishResult with serials from ACK +**Test ID**: `realtime/unit/RTL6j/publish-result-serials-0` + | Spec | Requirement | |------|-------------| | RTL6j | On success, returns a `PublishResult` object containing the serials of the published messages. The serials are obtained from the `ACK` `ProtocolMessage` response (see TR4s). | @@ -1096,6 +1136,8 @@ CLOSE_CLIENT(client) ## RTL6j - Publish returns PublishResult with multiple serials for batch publish +**Test ID**: `realtime/unit/RTL6j/batch-publish-serials-1` + **Spec requirement:** When an array of messages is published, the `PublishResult` `serials` array contains one serial per message, corresponding 1:1 to the published messages (PBR2a). A serial may be null if the message was discarded due to a configured conflation rule. Tests that a batch publish of multiple messages returns a `PublishResult` with a serial for each message. @@ -1162,6 +1204,8 @@ CLOSE_CLIENT(client) ## RTL6j - Sequential publishes get incrementing msgSerial +**Test ID**: `realtime/unit/RTL6j/incrementing-msg-serial-2` + **Spec requirement:** Every ProtocolMessage that expects an ACK must contain a unique serially incrementing `msgSerial` integer value starting at zero (RTN7b). Tests that successive publish calls assign incrementing `msgSerial` values to the outgoing ProtocolMessages, and that each publish resolves with the correct `PublishResult` from its corresponding ACK. @@ -1226,6 +1270,8 @@ CLOSE_CLIENT(client) ## RTL6j - Publish NACK results in error +**Test ID**: `realtime/unit/RTL6j/nack-results-error-3` + | Spec | Requirement | |------|-------------| | RTN7a | All MESSAGE ProtocolMessages sent to Ably expect either an ACK or NACK to confirm success or failure | @@ -1281,6 +1327,8 @@ CLOSE_CLIENT(client) ## RTN7e - Pending publishes fail when connection enters SUSPENDED +**Test ID**: `realtime/unit/RTN7e/pending-fail-suspended-0` + | Spec | Requirement | |------|-------------| | RTN7e | If a connection enters the SUSPENDED, CLOSED or FAILED state, and an ACK or NACK has not yet been received for a message submitted to the connection, the client should consider the delivery of those messages as failed, meaning their callback should be called with an error representing the reason for the state change, and they should be removed from any RTN19a retry queue. | @@ -1356,6 +1404,8 @@ CLOSE_CLIENT(client) ## RTN7e - Pending publishes fail when connection enters CLOSED +**Test ID**: `realtime/unit/RTN7e/pending-fail-closed-1` + **Spec requirement:** If a connection enters the CLOSED state, pending messages are failed with an error representing the reason for the state change. Tests that messages awaiting ACK/NACK are failed when the connection is explicitly closed. @@ -1410,6 +1460,8 @@ CLOSE_CLIENT(client) ## RTN7e - Pending publishes fail when connection enters FAILED +**Test ID**: `realtime/unit/RTN7e/pending-fail-failed-2` + **Spec requirement:** If a connection enters the FAILED state, pending messages are failed with an error representing the reason for the state change. Tests that messages awaiting ACK/NACK are failed when the connection enters FAILED. @@ -1475,6 +1527,8 @@ CLOSE_CLIENT(client) ## RTN7e - Multiple pending publishes all fail on state change +**Test ID**: `realtime/unit/RTN7e/multiple-pending-fail-3` + **Spec requirement:** All messages awaiting ACK/NACK are failed when the connection enters a terminal state. Tests that when multiple publishes are pending and the connection enters CLOSED, all of them fail. @@ -1531,8 +1585,76 @@ CLOSE_CLIENT(client) --- +## RTN7e - Error passed to publish callback represents the reason for the state change + +**Test ID**: `realtime/unit/RTN7e/error-represents-reason-4` + +**Spec requirement:** The client should consider the delivery of those messages as failed, meaning their callback should be called with an error representing the reason for the state change. + +Tests that the error passed to the publish callback contains the same reason that caused the connection state change (e.g. the ErrorInfo from a fatal ERROR ProtocolMessage). + +### Setup +```pseudo +channel_name = "test-RTN7e-error-reason-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + # Send a fatal error to force FAILED state with a specific reason + mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80019, statusCode: 400, message: "Connection closed due to admin action") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish — server responds with fatal ERROR instead of ACK +publish_future = channel.publish(name: "pending", data: "data") + +AWAIT_STATE client.connection.state == ConnectionState.failed + +# The pending publish should fail with the same error that caused the state change +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +# The error should represent the reason for the state change +ASSERT error IS NOT null +ASSERT error.code == 80019 +ASSERT error.statusCode == 400 +ASSERT error.message == "Connection closed due to admin action" + +# Verify the connection's errorReason matches +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +CLOSE_CLIENT(client) +``` + +--- + ## RTN7d - Pending publishes fail on DISCONNECTED when queueMessages is false +**Test ID**: `realtime/unit/RTN7d/fail-disconnected-no-queue-0` + | Spec | Requirement | |------|-------------| | RTN7d | If the `queueMessages` client option (TO3g) has been set to false, then when a connection enters the DISCONNECTED state, any messages which have not yet been ACK'd should be considered to have failed, with the same effect as in RTN7e. | @@ -1600,6 +1722,8 @@ CLOSE_CLIENT(client) ## RTN7d - Pending publishes survive DISCONNECTED when queueMessages is true (default) +**Test ID**: `realtime/unit/RTN7d/survive-disconnected-queue-1` + **Spec requirement:** The RTN7d behavior (failing on DISCONNECTED) only applies when `queueMessages` is false. With the default `queueMessages: true`, pending messages should NOT be failed on DISCONNECTED — they are retained for resending per RTN19a. Tests that with the default queueMessages=true, pending messages are not failed when the connection enters DISCONNECTED. @@ -1677,6 +1801,8 @@ CLOSE_CLIENT(client) ## RTN19a - Pending messages resent on new transport after disconnect +**Test ID**: `realtime/unit/RTN19a/resent-on-new-transport-0` + | Spec | Requirement | |------|-------------| | RTN19a | Any ProtocolMessage that is awaiting an ACK/NACK on the old transport will not receive the ACK/NACK on the new transport. The client library must therefore resend any ProtocolMessage that is awaiting an ACK/NACK to Ably in order to receive the expected ACK/NACK for that message. | @@ -1771,6 +1897,8 @@ CLOSE_CLIENT(client) ## RTN19a2 - Resent messages keep same msgSerial on successful resume +**Test ID**: `realtime/unit/RTN19a2/same-serial-on-resume-0` + | Spec | Requirement | |------|-------------| | RTN19a2 | In the case of an RTN15c6 successful resume, the msgSerial of the reattempted ProtocolMessages should remain the same as for the original attempt. | @@ -1865,6 +1993,8 @@ CLOSE_CLIENT(client) ## RTN19a2 - Resent messages get new msgSerial on failed resume +**Test ID**: `realtime/unit/RTN19a2/new-serial-failed-resume-1` + | Spec | Requirement | |------|-------------| | RTN19a2 | In the case of an RTN15c7 failed resume, the message must be assigned a new msgSerial from the SDK's internal counter. | @@ -1967,6 +2097,8 @@ CLOSE_CLIENT(client) ## RTN19b - Pending ATTACH resent on new transport after disconnect +**Test ID**: `realtime/unit/RTN19b/attach-resent-on-reconnect-0` + | Spec | Requirement | |------|-------------| | RTN19b | If there are any pending channels i.e. in the ATTACHING or DETACHING state, the respective ATTACH or DETACH message should be resent to Ably. | @@ -2049,6 +2181,8 @@ CLOSE_CLIENT(client) ## RTN19b - Pending DETACH resent on new transport after disconnect +**Test ID**: `realtime/unit/RTN19b/detach-resent-on-reconnect-1` + **Spec requirement:** If there are any pending channels in the DETACHING state, the respective DETACH message should be resent to Ably. Tests that after a transport disconnect and reconnect, channels in the DETACHING state have their DETACH message resent. diff --git a/uts/realtime/unit/channels/channel_server_initiated_detach.md b/uts/realtime/unit/channels/channel_server_initiated_detach.md index fa9959727..e7b41661f 100644 --- a/uts/realtime/unit/channels/channel_server_initiated_detach.md +++ b/uts/realtime/unit/channels/channel_server_initiated_detach.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL13a - Server DETACHED on ATTACHED channel triggers immediate reattach +**Test ID**: `realtime/unit/RTL13a/attached-reattach-triggered-0` + | Spec | Requirement | |------|-------------| | RTL13 | If the channel receives a server-initiated DETACHED when ATTACHING, ATTACHED, or SUSPENDED, specific handling applies | @@ -88,6 +90,8 @@ CLOSE_CLIENT(client) ## RTL13a - Server DETACHED on SUSPENDED channel triggers immediate reattach +**Test ID**: `realtime/unit/RTL13a/suspended-reattach-triggered-1` + **Spec requirement:** If the channel is in the SUSPENDED state and receives a server-initiated DETACHED, an immediate reattach attempt should be made. Tests that receiving a server-initiated DETACHED while SUSPENDED causes the channel to transition to ATTACHING and reattach. @@ -175,6 +179,8 @@ CLOSE_CLIENT(client) ## RTL13b - Failed reattach transitions to SUSPENDED with automatic retry +**Test ID**: `realtime/unit/RTL13b/failed-reattach-suspended-retry-0` + | Spec | Requirement | |------|-------------| | RTL13b | If the reattach fails, or if the channel was already ATTACHING, channel transitions to SUSPENDED. An automatic re-attach attempt is made after channelRetryTimeout. If that also fails (timeout or DETACHED), the cycle repeats indefinitely. | @@ -272,6 +278,8 @@ CLOSE_CLIENT(client) ## RTL13b - Server DETACHED while already ATTACHING transitions directly to SUSPENDED +**Test ID**: `realtime/unit/RTL13b/attaching-detached-to-suspended-1` + **Spec requirement:** If the channel was already in the ATTACHING state when the server-initiated DETACHED is received, the channel transitions directly to SUSPENDED (with automatic retry). Tests that a server-initiated DETACHED received while ATTACHING goes directly to SUSPENDED without another reattach attempt first. @@ -355,6 +363,8 @@ CLOSE_CLIENT(client) ## RTL13b - Repeated failures cycle SUSPENDED -> ATTACHING indefinitely +**Test ID**: `realtime/unit/RTL13b/repeated-failure-cycle-2` + **Spec requirement:** If the re-attach also fails (timeout or DETACHED), the SUSPENDED -> retry cycle repeats indefinitely. Tests that repeated reattach failures produce repeated SUSPENDED -> ATTACHING cycles. @@ -456,6 +466,8 @@ CLOSE_CLIENT(client) ## RTL13c - Retry cancelled when connection is no longer CONNECTED +**Test ID**: `realtime/unit/RTL13c/retry-cancelled-disconnected-0` + | Spec | Requirement | |------|-------------| | RTL13c | If the connection is no longer CONNECTED, the automatic re-attach attempts described in RTL13b must be cancelled, as any implicit channel state changes will be covered by RTL3 | @@ -542,6 +554,8 @@ CLOSE_CLIENT(client) ## RTL13a - DETACHED while DETACHING is not server-initiated +**Test ID**: `realtime/unit/RTL13a/detaching-not-server-initiated-2` + **Spec requirement:** RTL13 applies when the channel receives a server-initiated DETACHED when it is in ATTACHING, ATTACHED, or SUSPENDED. A channel in the DETACHING state has explicitly requested a detach, so a DETACHED response in that state is handled by the normal detach flow (RTL5), not RTL13. Tests that receiving a DETACHED while DETACHING completes the normal detach flow rather than triggering a reattach. diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md index 01bc40901..0fc862d1f 100644 --- a/uts/realtime/unit/channels/channel_state_events.md +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL2b - Channel state attribute +**Test ID**: `realtime/unit/RTL2b/channel-state-attribute-0` + **Spec requirement:** `RealtimeChannel#state` attribute is the current state of the channel, of type `ChannelState`. Tests that channel has a state attribute of type ChannelState. @@ -36,6 +38,8 @@ CLOSE_CLIENT(client) ## RTL2b - Channel initial state is initialized +**Test ID**: `realtime/unit/RTL2b/initial-state-initialized-1` + **Spec requirement:** Channel state attribute reflects the current state. Tests that a newly created channel starts in the initialized state. @@ -62,6 +66,8 @@ CLOSE_CLIENT(client) ## RTL2a - State change events emitted for every state change +**Test ID**: `realtime/unit/RTL2a/state-change-events-emitted-0` + **Spec requirement:** It emits a `ChannelState` `ChannelEvent` for every channel state change. Tests that state changes emit corresponding events. @@ -114,6 +120,8 @@ CLOSE_CLIENT(client) ## RTL2d, TH1, TH2, TH5 - ChannelStateChange object structure +**Test ID**: `realtime/unit/RTL2d/state-change-object-structure-0` + | Spec | Requirement | |------|-------------| | RTL2d | A ChannelStateChange object is emitted as the first argument for every ChannelEvent | @@ -170,6 +178,8 @@ CLOSE_CLIENT(client) ## RTL2d, TH3 - ChannelStateChange includes error reason when applicable +**Test ID**: `realtime/unit/RTL2d/state-change-error-reason-1` + **Spec requirement:** Any state change triggered by a ProtocolMessage that contains an error member should populate the reason with that error. Tests that error information is included in state change when present. @@ -227,6 +237,8 @@ CLOSE_CLIENT(client) ## RTL2 - Filtered event subscription +**Test ID**: `realtime/unit/RTL2/filtered-event-subscription-0` + **Spec requirement:** RealtimeChannel implements EventEmitter and emits ChannelEvent events. Tests that subscribing to a specific event only receives that event. @@ -274,6 +286,8 @@ CLOSE_CLIENT(client) ## RTL2g - UPDATE event for condition changes without state change +**Test ID**: `realtime/unit/RTL2g/update-event-condition-change-0` + **Spec requirement:** It emits an UPDATE ChannelEvent for changes to channel conditions for which the ChannelState does not change, unless explicitly prevented by a more specific condition (see RTL12). @@ -339,6 +353,8 @@ CLOSE_CLIENT(client) ## RTL2g - No duplicate state events +**Test ID**: `realtime/unit/RTL2g/no-duplicate-state-events-1` + **Spec requirement:** The library must never emit a ChannelState ChannelEvent for a state equal to the previous state. Tests that state events are not emitted when state doesn't actually change. @@ -399,6 +415,8 @@ CLOSE_CLIENT(client) ## RTL2i, TH6 - hasBacklog flag in ChannelStateChange +**Test ID**: `realtime/unit/RTL2i/has-backlog-flag-true-0` + | Spec | Requirement | |------|-------------| | RTL2i | ChannelStateChange may expose hasBacklog property | @@ -450,6 +468,8 @@ CLOSE_CLIENT(client) ## RTL2i - hasBacklog false when flag not present +**Test ID**: `realtime/unit/RTL2i/has-backlog-flag-false-1` + **Spec requirement:** hasBacklog should only be true when ATTACHED message contains HAS_BACKLOG flag. Tests that hasBacklog is false when the flag is not present. @@ -498,6 +518,8 @@ CLOSE_CLIENT(client) ## RTL2d - resumed flag in ChannelStateChange +**Test ID**: `realtime/unit/RTL2d/resumed-flag-propagated-2` + **Spec requirement:** ChannelStateChange has a resumed property indicating whether the ATTACHED message had the RESUMED flag set. Tests that resumed flag is correctly propagated. @@ -546,6 +568,8 @@ CLOSE_CLIENT(client) ## Channel errorReason attribute +**Test ID**: `realtime/unit/RTL24/error-reason-populated-0` + **Spec requirement:** Channel should expose error information when in failed state. Tests that errorReason is populated when channel enters failed state. @@ -596,6 +620,8 @@ CLOSE_CLIENT(client) ## RTL4c - errorReason cleared on successful attach +**Test ID**: `realtime/unit/RTL4c/error-reason-cleared-attach-0` + **Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. Tests that errorReason is cleared after successful attach following a failure. diff --git a/uts/realtime/unit/channels/channel_subscribe.md b/uts/realtime/unit/channels/channel_subscribe.md index 215826d0a..6bfbf41a8 100644 --- a/uts/realtime/unit/channels/channel_subscribe.md +++ b/uts/realtime/unit/channels/channel_subscribe.md @@ -1,6 +1,6 @@ # RealtimeChannel Subscribe and Unsubscribe Tests -Spec points: `RTL7`, `RTL7a`, `RTL7b`, `RTL7g`, `RTL7h`, `RTL7f`, `RTL8`, `RTL8a`, `RTL8b`, `RTL8c`, `RTL17` +Spec points: `RTL7`, `RTL7a`, `RTL7b`, `RTL7g`, `RTL7h`, `RTL7f`, `RTL8`, `RTL8a`, `RTL8b`, `RTL8c`, `RTL17`, `RTL22`, `RTL22a`, `RTL22b`, `RTL22c`, `RTL22d`, `MFI1`, `MFI2`, `MFI2a`, `MFI2b`, `MFI2c`, `MFI2d`, `MFI2e` ## Test Type Unit test with mocked WebSocket @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL7a - Subscribe with no name receives all messages +**Test ID**: `realtime/unit/RTL7a/subscribe-all-messages-0` + **Spec requirement:** Subscribe with a single listener argument subscribes a listener to all messages. Tests that subscribing without a name filter delivers all incoming messages regardless of name. @@ -90,6 +92,8 @@ CLOSE_CLIENT(client) ## RTL7a - Subscribe receives multiple messages from a single ProtocolMessage +**Test ID**: `realtime/unit/RTL7a/multiple-messages-per-protocol-1` + **Spec requirement:** Subscribe with a single listener argument subscribes a listener to all messages. Tests that when a ProtocolMessage contains multiple messages in its `messages` array, each is delivered individually to the subscriber. @@ -150,6 +154,8 @@ CLOSE_CLIENT(client) ## RTL7b - Subscribe with name only receives matching messages +**Test ID**: `realtime/unit/RTL7b/name-filtered-subscribe-0` + **Spec requirement:** Subscribe with a name argument and a listener argument subscribes a listener to only messages whose `name` member matches the string name. Tests that subscribing with a name filter delivers only messages with the matching name. @@ -223,6 +229,8 @@ CLOSE_CLIENT(client) ## RTL7b - Multiple name-specific subscriptions are independent +**Test ID**: `realtime/unit/RTL7b/multiple-name-subscriptions-1` + **Spec requirement:** Subscribe with a name argument and a listener argument subscribes a listener to only messages whose `name` member matches the string name. Tests that multiple name-specific subscriptions each receive only their matching messages. @@ -291,6 +299,8 @@ CLOSE_CLIENT(client) ## RTL7g - Subscribe triggers implicit attach when attachOnSubscribe is true +**Test ID**: `realtime/unit/RTL7g/implicit-attach-initialized-0` + **Spec requirement:** If the `attachOnSubscribe` channel option is `true`, implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. The listener will always be registered regardless of the implicit attach result. Tests that subscribing on a channel with `attachOnSubscribe: true` (the default) triggers an implicit attach from INITIALIZED state. @@ -355,6 +365,8 @@ CLOSE_CLIENT(client) ## RTL7g - Subscribe triggers implicit attach from DETACHED state +**Test ID**: `realtime/unit/RTL7g/implicit-attach-detached-1` + **Spec requirement:** If the `attachOnSubscribe` channel option is `true`, implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. Tests that subscribing on a DETACHED channel triggers an implicit attach. @@ -413,6 +425,8 @@ CLOSE_CLIENT(client) ## RTL7g - Listener registered even if implicit attach fails +**Test ID**: `realtime/unit/RTL7g/listener-registered-attach-fails-2` + **Spec requirement:** The listener will always be registered regardless of the implicit attach result. Tests that the subscription listener is registered even when the implicit attach fails. @@ -484,6 +498,8 @@ CLOSE_CLIENT(client) ## RTL7h - Subscribe does not attach when attachOnSubscribe is false +**Test ID**: `realtime/unit/RTL7h/no-attach-on-subscribe-0` + **Spec requirement:** If the `attachOnSubscribe` channel option is `false`, then subscribe should not trigger an implicit attach. Tests that subscribing with `attachOnSubscribe: false` does not trigger an attach. @@ -532,6 +548,8 @@ CLOSE_CLIENT(client) ## RTL7g - Subscribe does not attach when already attached +**Test ID**: `realtime/unit/RTL7g/no-attach-when-attached-3` + **Spec requirement:** Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. Tests that subscribing on an already-attached channel does not trigger another attach. @@ -581,6 +599,8 @@ CLOSE_CLIENT(client) ## RTL7g - Subscribe does not attach when already attaching +**Test ID**: `realtime/unit/RTL7g/no-attach-when-attaching-4` + **Spec requirement:** Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. Tests that subscribing on a channel that is already ATTACHING does not trigger a second attach. @@ -629,6 +649,8 @@ CLOSE_CLIENT(client) ## RTL17 - Messages not delivered when channel is not ATTACHED +**Test ID**: `realtime/unit/RTL17/no-delivery-when-not-attached-0` + **Spec requirement:** No messages should be passed to subscribers if the channel is in any state other than `ATTACHED`. Tests that incoming MESSAGE protocol messages are not delivered to subscribers when the channel is not in the ATTACHED state (e.g. ATTACHING, SUSPENDED). @@ -685,6 +707,8 @@ CLOSE_CLIENT(client) ## RTL7f - Messages not echoed when echoMessages is false +**Test ID**: `realtime/unit/RTL7f/no-echo-messages-0` + **Spec requirement:** A test should exist ensuring published messages are not echoed back to the subscriber when `echoMessages` is set to false in the `RealtimeClient` library constructor. > **Implementation note:** Echo suppression may be implemented either by client-side @@ -769,6 +793,8 @@ CLOSE_CLIENT(client) ## RTL8a - Unsubscribe specific listener from all messages +**Test ID**: `realtime/unit/RTL8a/unsubscribe-specific-listener-0` + **Spec requirement:** Unsubscribe with a single listener argument unsubscribes the provided listener to all messages if subscribed. Tests that unsubscribing a specific listener stops it from receiving messages, while other listeners continue. @@ -845,6 +871,8 @@ CLOSE_CLIENT(client) ## RTL8b - Unsubscribe listener from specific name +**Test ID**: `realtime/unit/RTL8b/unsubscribe-named-listener-0` + **Spec requirement:** Unsubscribe with a name argument and a listener argument unsubscribes the provided listener if previously subscribed with a name-specific subscription. Tests that unsubscribing with a name removes only that name-specific subscription for the listener. @@ -919,6 +947,8 @@ CLOSE_CLIENT(client) ## RTL8c - Unsubscribe with no arguments removes all listeners +**Test ID**: `realtime/unit/RTL8c/unsubscribe-all-listeners-0` + **Spec requirement:** Unsubscribe with no arguments unsubscribes all listeners. Tests that calling unsubscribe with no arguments removes all subscriptions from the channel. @@ -991,6 +1021,8 @@ CLOSE_CLIENT(client) ## RTL8a - Unsubscribe listener not currently subscribed is no-op +**Test ID**: `realtime/unit/RTL8a/unsubscribe-noop-not-subscribed-1` + **Spec requirement:** Unsubscribe with a single listener argument unsubscribes the provided listener to all messages if subscribed. Tests that unsubscribing a listener that was never subscribed does not cause an error or affect existing subscriptions. @@ -1046,3 +1078,491 @@ ASSERT length(received_messages) == 1 ASSERT received_messages[0].data == "still-works" CLOSE_CLIENT(client) ``` + +--- + +## RTL22a - Subscribe with MessageFilter matching name + +**Test ID**: `realtime/unit/RTL22a/filter-matching-name-0` + +| Spec | Requirement | +|------|-------------| +| RTL22 | Methods must be provided for attaching and removing a listener which only executes when the message matches a set of criteria. | +| RTL22a | The method must allow for filters matching one or more of: extras.ref.timeserial, extras.ref.type or name. See MFI1 for an object implementation. | +| RTL22d | The method should use the MessageFilter object if possible and idiomatic for the language. | +| MFI1 | Supplies filter options to subscribe as defined in RTL22. | +| MFI2d | name - A string for checking if a message's name matches the supplied value. | + +Tests that subscribing with a MessageFilter specifying `name` delivers only messages whose name matches the filter. + +### Setup +```pseudo +channel_name = "test-RTL22a-name-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(name: "target-event") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Server sends messages with different names +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "target-event", data: "match-1") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "other-event", data: "no-match") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "target-event", data: "match-2") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: null, data: "no-name") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].name == "target-event" +ASSERT filtered_messages[0].data == "match-1" +ASSERT filtered_messages[1].name == "target-event" +ASSERT filtered_messages[1].data == "match-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22a - Subscribe with MessageFilter matching extras.ref.timeserial + +**Test ID**: `realtime/unit/RTL22a/filter-matching-ref-timeserial-1` + +| Spec | Requirement | +|------|-------------| +| RTL22a | The method must allow for filters matching one or more of: extras.ref.timeserial, extras.ref.type or name. | +| MFI2b | refTimeserial - A string for checking if a message's extras.ref.timeserial matches the supplied value. | + +Tests that subscribing with a MessageFilter specifying `refTimeserial` delivers only messages whose `extras.ref.timeserial` matches the filter value. + +### Setup +```pseudo +channel_name = "test-RTL22a-ref-timeserial-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(refTimeserial: "abc123@1700000000000-0") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message with matching extras.ref.timeserial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reply", data: "match", extras: { + "ref": {"timeserial": "abc123@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message with different extras.ref.timeserial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reply", data: "no-match", extras: { + "ref": {"timeserial": "xyz789@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message with no extras.ref at all +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "plain", data: "no-ref") + ] +)) + +# Another message with matching extras.ref.timeserial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reaction", data: "match-2", extras: { + "ref": {"timeserial": "abc123@1700000000000-0", "type": "com.ably.reaction"} + }) + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].data == "match" +ASSERT filtered_messages[1].data == "match-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22b - Subscribe with MessageFilter isRef false delivers only messages without extras.ref + +**Test ID**: `realtime/unit/RTL22b/filter-isref-false-0` + +| Spec | Requirement | +|------|-------------| +| RTL22b | The method must allow for matching only messages which do not have extras.ref. | +| MFI2a | isRef - A boolean for checking if a message contains an extras.ref field. | + +Tests that subscribing with a MessageFilter specifying `isRef: false` delivers only messages that do NOT have an `extras.ref` field. + +### Setup +```pseudo +channel_name = "test-RTL22b-isref-false-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(isRef: false) +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message WITHOUT extras.ref (no extras at all) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "plain", data: "no-extras") + ] +)) + +# Message WITH extras.ref — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reply", data: "has-ref", extras: { + "ref": {"timeserial": "abc123@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message with extras but no ref field — should be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "annotated", data: "extras-no-ref", extras: { + "headers": {"custom-key": "custom-value"} + }) + ] +)) + +# Another message WITH extras.ref — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "reaction", data: "also-has-ref", extras: { + "ref": {"timeserial": "xyz789@1700000000000-0", "type": "com.ably.reaction"} + }) + ] +)) +``` + +### Assertions +```pseudo +# Only messages without extras.ref should be delivered +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].name == "plain" +ASSERT filtered_messages[0].data == "no-extras" +ASSERT filtered_messages[1].name == "annotated" +ASSERT filtered_messages[1].data == "extras-no-ref" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22c - Subscribe with MessageFilter matching multiple criteria (name + refType) + +**Test ID**: `realtime/unit/RTL22c/filter-multiple-criteria-0` + +| Spec | Requirement | +|------|-------------| +| RTL22c | The listener must only execute if all provided criteria are met. | +| MFI2c | refType - A string for checking if a message's extras.ref.type matches the supplied value. | +| MFI2d | name - A string for checking if a message's name matches the supplied value. | + +Tests that when a MessageFilter specifies multiple criteria (name AND refType), only messages matching ALL criteria are delivered. + +### Setup +```pseudo +channel_name = "test-RTL22c-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(name: "comment", refType: "com.ably.reply") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message matching BOTH name AND refType — should be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "comment", data: "both-match", extras: { + "ref": {"timeserial": "abc@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message matching name but NOT refType — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "comment", data: "name-only", extras: { + "ref": {"timeserial": "def@1700000000000-0", "type": "com.ably.reaction"} + }) + ] +)) + +# Message matching refType but NOT name — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "update", data: "type-only", extras: { + "ref": {"timeserial": "ghi@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) + +# Message matching NEITHER — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "update", data: "neither") + ] +)) + +# Another message matching BOTH — should be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "comment", data: "both-match-2", extras: { + "ref": {"timeserial": "jkl@1700000000000-0", "type": "com.ably.reply"} + }) + ] +)) +``` + +### Assertions +```pseudo +# Only messages matching ALL criteria (name == "comment" AND refType == "com.ably.reply") +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].data == "both-match" +ASSERT filtered_messages[1].data == "both-match-2" +CLOSE_CLIENT(client) +``` + +--- + +## RTL22a, MFI2e - Subscribe with MessageFilter matching clientId + +**Test ID**: `realtime/unit/RTL22a/filter-matching-clientid-2` + +| Spec | Requirement | +|------|-------------| +| RTL22a | The method must allow for filters matching one or more of: extras.ref.timeserial, extras.ref.type or name. | +| MFI2e | clientId - A string for checking if a message's clientId matches the supplied value. | + +Tests that subscribing with a MessageFilter specifying `clientId` delivers only messages whose clientId matches the filter value. + +### Setup +```pseudo +channel_name = "test-RTL22a-clientid-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +filtered_messages = [] +filter = MessageFilter(clientId: "user-42") +channel.subscribe(filter, (message) => { + filtered_messages.append(message) +}) + +# Message with matching clientId +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "chat", data: "hello", clientId: "user-42") + ] +)) + +# Message with different clientId — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "chat", data: "hi", clientId: "user-99") + ] +)) + +# Message with no clientId — should NOT be delivered +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "system", data: "broadcast") + ] +)) + +# Another message with matching clientId +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "chat", data: "world", clientId: "user-42") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(filtered_messages) == 2 +ASSERT filtered_messages[0].data == "hello" +ASSERT filtered_messages[0].clientId == "user-42" +ASSERT filtered_messages[1].data == "world" +ASSERT filtered_messages[1].clientId == "user-42" +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/channels/channel_update_delete_message.md b/uts/realtime/unit/channels/channel_update_delete_message.md index 28855e9b8..38d478a63 100644 --- a/uts/realtime/unit/channels/channel_update_delete_message.md +++ b/uts/realtime/unit/channels/channel_update_delete_message.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTL32b, RTL32b1 — updateMessage sends MESSAGE ProtocolMessage with action MESSAGE_UPDATE +**Test ID**: `realtime/unit/RTL32b/update-message-action-0` + | Spec | Requirement | |------|-------------| | RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | @@ -87,6 +89,8 @@ CLOSE_CLIENT(client) ## RTL32b, RTL32b1 — deleteMessage sends MESSAGE ProtocolMessage with action MESSAGE_DELETE +**Test ID**: `realtime/unit/RTL32b/delete-message-action-1` + | Spec | Requirement | |------|-------------| | RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | @@ -155,6 +159,8 @@ CLOSE_CLIENT(client) ## RTL32b, RTL32b1 — appendMessage sends MESSAGE ProtocolMessage with action MESSAGE_APPEND +**Test ID**: `realtime/unit/RTL32b/append-message-action-2` + | Spec | Requirement | |------|-------------| | RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | @@ -225,6 +231,8 @@ CLOSE_CLIENT(client) ## RTL32b2 — version field set from MessageOperation +**Test ID**: `realtime/unit/RTL32b2/version-from-operation-0` + **Spec requirement:** RTL32b2 — `version` set to the `MessageOperation` object if provided. Tests that the `version` field on the wire message is set to the MessageOperation when provided, and absent when not provided. @@ -306,6 +314,8 @@ CLOSE_CLIENT(client) ## RTL32c — does not mutate user-supplied Message +**Test ID**: `realtime/unit/RTL32c/no-message-mutation-0` + **Spec requirement:** RTL32c — The SDK must not mutate the user-supplied `Message` object. Tests that the original Message object is unchanged after calling updateMessage. @@ -364,6 +374,8 @@ CLOSE_CLIENT(client) ## RTL32d — returns UpdateDeleteResult from ACK +**Test ID**: `realtime/unit/RTL32d/ack-returns-result-0` + **Spec requirement:** RTL32d — On success, returns an `UpdateDeleteResult` object containing the version serial of the published update, obtained from the first element of the `serials` array of the `res` field of the `ACK`. Tests that the result is parsed from the ACK ProtocolMessage. @@ -420,6 +432,8 @@ CLOSE_CLIENT(client) ## RTL32d — NACK returns error +**Test ID**: `realtime/unit/RTL32d/nack-returns-error-1` + **Spec requirement:** RTL32d — Indicates an error if the operation was not successful. Tests that a NACK results in an error. @@ -475,6 +489,8 @@ CLOSE_CLIENT(client) ## RTL32e — params sent in ProtocolMessage.params +**Test ID**: `realtime/unit/RTL32e/params-in-protocol-message-0` + **Spec requirement:** RTL32e — Any params provided in the third argument must be sent in the `TR4q` `ProtocolMessage.params` field. Tests that optional params are forwarded in the ProtocolMessage. @@ -540,6 +556,8 @@ CLOSE_CLIENT(client) ## RTL32a — serial validation +**Test ID**: `realtime/unit/RTL32a/serial-validation-required-0` + **Spec requirement:** RTL32a — Takes a first argument of a `Message` object (which must contain a populated `serial` field). Tests that calling updateMessage/deleteMessage/appendMessage with a missing serial throws an error. Follows the same validation as RSL15a. diff --git a/uts/realtime/unit/channels/channel_when_state_test.md b/uts/realtime/unit/channels/channel_when_state_test.md index 2c2357cb9..0bc0f0e71 100644 --- a/uts/realtime/unit/channels/channel_when_state_test.md +++ b/uts/realtime/unit/channels/channel_when_state_test.md @@ -25,6 +25,8 @@ This mirrors the `Connection#whenState` function (RTN26). ## RTL25a - whenState resolves immediately if already in state +**Test ID**: `realtime/unit/RTL25a/resolves-immediately-current-0` + **Spec requirement:** If the channel is already in the given state, resolves immediately with a `null` value. @@ -89,6 +91,8 @@ CLOSE_CLIENT(client) ## RTL25b - whenState waits for state if not already in it +**Test ID**: `realtime/unit/RTL25b/waits-for-state-change-0` + **Spec requirement:** If the channel is not in the given state, waits for the state to be reached and resolves with the `ChannelStateChange`. @@ -160,6 +164,8 @@ CLOSE_CLIENT(client) ## RTL25b - whenState only fires once +**Test ID**: `realtime/unit/RTL25b/fires-once-only-1` + **Spec requirement:** whenState resolves only once, even if the state is entered multiple times. Subsequent entries into the same state do not trigger additional resolutions. @@ -246,6 +252,8 @@ CLOSE_CLIENT(client) ## RTL25a - whenState for past state does not fire +**Test ID**: `realtime/unit/RTL25a/past-state-does-not-resolve-1` + **Spec requirement:** whenState checks the current state. If the channel has already passed through a state but is no longer in it, whenState should NOT resolve immediately. diff --git a/uts/realtime/unit/channels/channels_collection.md b/uts/realtime/unit/channels/channels_collection.md index 4f2b01d96..4a0b7abc1 100644 --- a/uts/realtime/unit/channels/channels_collection.md +++ b/uts/realtime/unit/channels/channels_collection.md @@ -11,6 +11,8 @@ These tests verify the channels collection management functionality. No mock inf ## RTS1 - Channels collection accessible via RealtimeClient +**Test ID**: `realtime/unit/RTS1/channels-collection-accessible-0` + **Spec requirement:** `Channels` is a collection of `RealtimeChannel` objects accessible through `RealtimeClient#channels`. Tests that the Realtime client exposes a channels collection. @@ -36,6 +38,8 @@ CLOSE_CLIENT(client) ## RTS2 - Check if channel exists +**Test ID**: `realtime/unit/RTS2/channel-exists-check-0` + **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. @@ -75,6 +79,8 @@ CLOSE_CLIENT(client) ## RTS2 - Iterate through existing channels +**Test ID**: `realtime/unit/RTS2/iterate-channels-1` + **Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. Tests that channel names can be iterated. @@ -112,6 +118,8 @@ CLOSE_CLIENT(client) ## RTS3a - Get creates new channel if none exists +**Test ID**: `realtime/unit/RTS3a/get-creates-new-channel-0` + **Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. Tests that `get()` creates a new channel when called with a new name. @@ -141,6 +149,8 @@ CLOSE_CLIENT(client) ## RTS3a - Get returns existing channel +**Test ID**: `realtime/unit/RTS3a/get-returns-existing-channel-1` + **Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. Tests that `get()` returns the same channel instance when called multiple times. @@ -173,6 +183,8 @@ CLOSE_CLIENT(client) ## RTS3a - Operator subscript creates or returns channel +**Test ID**: `realtime/unit/RTS3a/subscript-operator-channel-2` + **Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. Tests that the subscript operator `[]` behaves the same as `get()`. @@ -208,6 +220,8 @@ CLOSE_CLIENT(client) ## RTS4a - Release detaches and removes channel +**Test ID**: `realtime/unit/RTS4a/release-removes-channel-0` + **Spec requirement:** Detaches the channel and then releases the channel resource i.e. it's deleted and can then be garbage collected. Tests that `release()` removes the channel from the collection. @@ -239,6 +253,8 @@ CLOSE_CLIENT(client) ## RTS4a - Release on non-existent channel is no-op +**Test ID**: `realtime/unit/RTS4a/release-nonexistent-noop-1` + **Spec requirement:** Detaches the channel and then releases the channel resource. Tests that releasing a channel that doesn't exist completes without error. @@ -267,6 +283,8 @@ CLOSE_CLIENT(client) ## RTS4a - Release calls detach on attached channel +**Test ID**: `realtime/unit/RTS4a/release-detaches-attached-2` + **Spec requirement:** Detaches the channel and then releases the channel resource. Tests that releasing an attached channel detaches it first. @@ -304,6 +322,8 @@ CLOSE_CLIENT(client) ## RTS3a - Get after release creates new channel +**Test ID**: `realtime/unit/RTS3a/get-after-release-new-3` + **Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists. Tests that getting a channel after release creates a fresh instance. diff --git a/uts/realtime/unit/channels/message_field_population.md b/uts/realtime/unit/channels/message_field_population.md index 6ed82fcbf..e2f47d1b0 100644 --- a/uts/realtime/unit/channels/message_field_population.md +++ b/uts/realtime/unit/channels/message_field_population.md @@ -34,6 +34,8 @@ subscribers via `channel.subscribe()`. ## TM2a - Message id populated from ProtocolMessage id and index +**Test ID**: `realtime/unit/TM2a/id-from-protocol-message-0` + **Spec requirement:** For messages received over Realtime, if the message does not contain an `id`, it should be set to `protocolMsgId:index`, where `protocolMsgId` is the id of the `ProtocolMessage` encapsulating it, and `index` is the index of @@ -105,6 +107,8 @@ CLOSE_CLIENT(client) ## TM2a - Message with existing id is not overwritten +**Test ID**: `realtime/unit/TM2a/existing-id-not-overwritten-1` + **Spec requirement:** The id should only be set if the message does not already contain one. @@ -165,6 +169,8 @@ CLOSE_CLIENT(client) ## TM2a - No id when ProtocolMessage has no id +**Test ID**: `realtime/unit/TM2a/no-id-without-protocol-id-2` + **Spec requirement:** The id derivation only applies when the ProtocolMessage has an `id` field. If the ProtocolMessage has no `id`, messages without their own `id` should remain without one. @@ -227,6 +233,8 @@ CLOSE_CLIENT(client) ## TM2c - Message connectionId populated from ProtocolMessage +**Test ID**: `realtime/unit/TM2c/connectionid-from-protocol-0` + **Spec requirement:** If a message received from Ably does not contain a `connectionId`, it should be set to the `connectionId` of the encapsulating `ProtocolMessage`. @@ -290,6 +298,8 @@ CLOSE_CLIENT(client) ## TM2c - Message with existing connectionId is not overwritten +**Test ID**: `realtime/unit/TM2c/existing-connectionid-kept-1` + **Spec requirement:** The connectionId should only be set if the message does not already contain one. @@ -351,6 +361,8 @@ CLOSE_CLIENT(client) ## TM2f - Message timestamp populated from ProtocolMessage +**Test ID**: `realtime/unit/TM2f/timestamp-from-protocol-0` + **Spec requirement:** If a message received from Ably over a realtime transport does not contain a `timestamp`, the SDK must set it to the `timestamp` of the encapsulating `ProtocolMessage`. @@ -414,6 +426,8 @@ CLOSE_CLIENT(client) ## TM2f - Message with existing timestamp is not overwritten +**Test ID**: `realtime/unit/TM2f/existing-timestamp-kept-1` + **Spec requirement:** The timestamp should only be set if the message does not already contain one. @@ -475,6 +489,8 @@ CLOSE_CLIENT(client) ## TM2a, TM2c, TM2f - All fields populated together +**Test ID**: `realtime/unit/TM2a/all-fields-populated-together-3` + **Spec requirement:** All three fields (id, connectionId, timestamp) should be populated from the ProtocolMessage when absent from the message. diff --git a/uts/realtime/unit/client/realtime_client.md b/uts/realtime/unit/client/realtime_client.md index 1d56117de..b297dc651 100644 --- a/uts/realtime/unit/client/realtime_client.md +++ b/uts/realtime/unit/client/realtime_client.md @@ -49,6 +49,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTC12 - Constructor String Argument Detection +**Test ID**: `realtime/unit/RTC12/constructor-string-detection-0` + **Spec requirement:** The Realtime constructor must accept a string argument and detect whether it's an API key (contains `:`) or token (no `:`), matching REST client behavior. The Realtime client has the same constructors as the REST client. @@ -64,6 +66,8 @@ The same test cases apply: ## RTC12 - Invalid Arguments Error +**Test ID**: `realtime/unit/RTC12/invalid-arguments-error-1` + **Spec requirement:** Error code 40106 must be raised when no valid credentials are provided, matching REST client behavior. The Realtime client has the same error handling as the REST client for invalid credentials. @@ -76,6 +80,8 @@ Error code 40106 should be raised when no valid credentials are provided. ## RTC2 - Connection Attribute +**Test ID**: `realtime/unit/RTC2/connection-attribute-0` + **Spec requirement:** The Realtime client must expose a `connection` property that provides access to the Connection object. Tests that `RealtimeClient#connection` provides access to the underlying Connection object. @@ -104,6 +110,8 @@ CLOSE_CLIENT(client) ## RTC3 - Channels Attribute +**Test ID**: `realtime/unit/RTC3/channels-attribute-0` + **Spec requirement:** The Realtime client must expose a `channels` property that provides access to the Channels collection. Tests that `RealtimeClient#channels` provides access to the Channels collection. @@ -137,6 +145,8 @@ CLOSE_CLIENT(client) ## RTC4 - Auth Attribute +**Test ID**: `realtime/unit/RTC4/auth-attribute-0` + **Spec requirement:** The Realtime client must expose an `auth` property that provides access to the Auth object. Tests that `RealtimeClient#auth` provides access to the Auth object. @@ -163,6 +173,8 @@ CLOSE_CLIENT(client) ## RTC13 - Push Attribute +**Test ID**: `realtime/unit/RTC13/push-attribute-0` + **Spec requirement:** RTC13 — `RealtimeClient#push` attribute provides access to the `Push` object. Tests that `RealtimeClient#push` provides access to the Push object. @@ -190,6 +202,8 @@ CLOSE_CLIENT(client) ## RTC17 - ClientId Attribute +**Test ID**: `realtime/unit/RTC17/client-id-attribute-0` + **Spec requirement:** The Realtime client must expose a `clientId` property that returns the clientId from the auth object. Tests that `RealtimeClient#clientId` returns the clientId from the auth object. @@ -215,6 +229,8 @@ CLOSE_CLIENT(client) ## RTC1a - echoMessages Option +**Test ID**: `realtime/unit/RTC1a/echo-messages-option-0` + **Spec requirement:** The `echoMessages` option (default true) controls whether messages published by this client are echoed back on subscriptions. Sent as `echo` query parameter. Tests the `echoMessages` option which controls whether messages from this connection are echoed back. @@ -260,6 +276,8 @@ CLOSE_CLIENT(client) ## RTC1b - autoConnect Option +**Test ID**: `realtime/unit/RTC1b/auto-connect-option-0` + **Spec requirement:** The `autoConnect` option (default true) controls whether the client automatically connects on instantiation or waits for explicit `connect()` call. Tests the `autoConnect` option which controls automatic connection on instantiation. @@ -339,6 +357,8 @@ CLOSE_CLIENT(client) ## RTC1c - recover Option +**Test ID**: `realtime/unit/RTC1c/recover-option-0` + **Spec requirement:** The `recover` option accepts a recovery key to resume a previous connection's state. The connection key is sent as the `recover` query parameter and is used only for the initial connection attempt. Tests the `recover` option for connection state recovery. @@ -426,6 +446,8 @@ CLOSE_CLIENT(client) ## RTC1f - transportParams Option +**Test ID**: `realtime/unit/RTC1f/transport-params-option-0` + | Spec | Requirement | |------|-------------| | RTC1f | Custom query parameters can be added via `transportParams` | @@ -510,6 +532,8 @@ CLOSE_CLIENT(client) ## RTC15 - connect() Method +**Test ID**: `realtime/unit/RTC15/connect-method-0` + **Spec requirement:** The Realtime client must provide a `connect()` method that calls `Connection#connect()`. Tests the `RealtimeClient#connect` method. @@ -542,6 +566,8 @@ CLOSE_CLIENT(client) ## RTC16 - close() Method +**Test ID**: `realtime/unit/RTC16/close-method-0` + **Spec requirement:** The Realtime client must provide a `close()` method that calls `Connection#close()`. Tests the `RealtimeClient#close` method. diff --git a/uts/realtime/unit/client/realtime_request.md b/uts/realtime/unit/client/realtime_request.md index 95ff9586b..e61292286 100644 --- a/uts/realtime/unit/client/realtime_request.md +++ b/uts/realtime/unit/client/realtime_request.md @@ -9,6 +9,8 @@ Unit test with mocked HTTP client ## RTC9 - RealtimeClient#request proxies to RestClient#request +**Test ID**: `realtime/unit/RTC9/request-proxies-rest-0` + **Spec requirement:** `RealtimeClient#request` is a wrapper around `RestClient#request` (see RSC19) delivered in an idiomatic way for the realtime library. `RealtimeClient#request` is a direct proxy to `RestClient#request`. The tests in `uts/test/rest/unit/request.md` (covering RSC19) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. diff --git a/uts/realtime/unit/client/realtime_stats.md b/uts/realtime/unit/client/realtime_stats.md index 7c19dbd4a..ec7185aec 100644 --- a/uts/realtime/unit/client/realtime_stats.md +++ b/uts/realtime/unit/client/realtime_stats.md @@ -9,6 +9,8 @@ Unit test with mocked HTTP client ## RTC5 - RealtimeClient#stats proxies to RestClient#stats +**Test ID**: `realtime/unit/RTC5/stats-proxies-rest-0` + | Spec | Requirement | |------|-------------| | RTC5a | Proxy to `RestClient#stats` presented with an async or threaded interface as appropriate | diff --git a/uts/realtime/unit/client/realtime_time.md b/uts/realtime/unit/client/realtime_time.md index c0213cc94..a13dfb6b6 100644 --- a/uts/realtime/unit/client/realtime_time.md +++ b/uts/realtime/unit/client/realtime_time.md @@ -9,6 +9,8 @@ Unit test with mocked HTTP client ## RTC6 - RealtimeClient#time proxies to RestClient#time +**Test ID**: `realtime/unit/RTC6/time-proxies-rest-0` + | Spec | Requirement | |------|-------------| | RTC6a | Proxy to `RestClient#time` presented with an async or threaded interface as appropriate | diff --git a/uts/realtime/unit/client/realtime_timeouts.md b/uts/realtime/unit/client/realtime_timeouts.md index 07d737ec5..ac9fa6b52 100644 --- a/uts/realtime/unit/client/realtime_timeouts.md +++ b/uts/realtime/unit/client/realtime_timeouts.md @@ -26,6 +26,8 @@ Default timeout values (from spec): ## RTC7 - realtimeRequestTimeout applied to channel attach +**Test ID**: `realtime/unit/RTC7/attach-request-timeout-0` + **Spec requirement:** The client library must use the configured timeouts specified in the ClientOptions. @@ -97,6 +99,8 @@ CLOSE_CLIENT(client) ## RTC7 - realtimeRequestTimeout applied to channel detach +**Test ID**: `realtime/unit/RTC7/detach-request-timeout-1` + **Spec requirement:** The client library must use the configured timeouts specified in the ClientOptions. @@ -178,6 +182,8 @@ CLOSE_CLIENT(client) ## RTC7 - disconnectedRetryTimeout controls reconnection delay +**Test ID**: `realtime/unit/RTC7/disconnected-retry-timeout-2` + **Spec requirement:** The client library must use the configured timeouts specified in the ClientOptions. @@ -267,6 +273,8 @@ CLOSE_CLIENT(client) ## RTC7 - default timeouts applied when not configured +**Test ID**: `realtime/unit/RTC7/default-timeouts-applied-3` + **Spec requirement:** The client library must use the configured timeouts specified in the ClientOptions, falling back to the client library defaults. diff --git a/uts/realtime/unit/connection/auto_connect_test.md b/uts/realtime/unit/connection/auto_connect_test.md index 2de29c48d..6dee11512 100644 --- a/uts/realtime/unit/connection/auto_connect_test.md +++ b/uts/realtime/unit/connection/auto_connect_test.md @@ -21,6 +21,8 @@ connection should be made until `connect()` is explicitly called. ## RTN3 - autoConnect true initiates connection immediately +**Test ID**: `realtime/unit/RTN3/auto-connect-true-0` + **Spec requirement:** If connection option `autoConnect` is true, a connection is initiated immediately. @@ -71,6 +73,8 @@ CLOSE_CLIENT(client) ## RTN3 - autoConnect false does not initiate connection +**Test ID**: `realtime/unit/RTN3/auto-connect-false-1` + **Spec requirement:** Otherwise a connection is only initiated following an explicit call to `connect()`. @@ -126,6 +130,8 @@ CLOSE_CLIENT(client) ## RTN3 - explicit connect after autoConnect false +**Test ID**: `realtime/unit/RTN3/explicit-connect-after-false-2` + **Spec requirement:** A connection is only initiated following an explicit call to `connect()`. diff --git a/uts/realtime/unit/connection/backoff_jitter_test.md b/uts/realtime/unit/connection/backoff_jitter_test.md new file mode 100644 index 000000000..5bb34a8f7 --- /dev/null +++ b/uts/realtime/unit/connection/backoff_jitter_test.md @@ -0,0 +1,384 @@ +# Backoff and Jitter Tests (RTB1) + +Spec points: `RTB1`, `RTB1a`, `RTB1b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +RTB1 defines how retry delays are calculated for connections in the DISCONNECTED state and channels in the SUSPENDED state. The delay is the product of three factors: + +1. **Initial retry timeout** (`disconnectedRetryTimeout` for connections, `channelRetryTimeout` for channels) +2. **Backoff coefficient** (RTB1a): `min((n + 2) / 3, 2)` for the nth retry +3. **Jitter coefficient** (RTB1b): a random number uniformly distributed between 0.8 and 1.0 + +--- + +## RTB1a - Backoff coefficient follows min((n+2)/3, 2) for successive retries + +**Test ID**: `realtime/unit/RTB1a/backoff-coefficient-sequence-0` + +**Spec requirement:** The backoff coefficient for the nth retry is calculated as the minimum of `(n + 2) / 3` and `2` (resulting in the sequence `[1, 4/3, 5/3, 2, 2, ...]`). + +Tests that the backoff coefficient calculation produces the correct sequence of values for successive retries. + +### Setup + +```pseudo +# This test verifies the backoff coefficient calculation function directly. +# The function under test takes a retry count (1-indexed) and returns the +# backoff coefficient. + +# Expected values: +# n=1: min((1+2)/3, 2) = min(1, 2) = 1.0 +# n=2: min((2+2)/3, 2) = min(4/3, 2) = 1.333... +# n=3: min((3+2)/3, 2) = min(5/3, 2) = 1.666... +# n=4: min((4+2)/3, 2) = min(2, 2) = 2.0 +# n=5: min((5+2)/3, 2) = min(7/3, 2) = 2.0 +# n=10: min((10+2)/3, 2) = min(4, 2) = 2.0 +``` + +### Test Steps + +```pseudo +# Calculate backoff coefficients for retries 1 through 10 +coefficients = [] +FOR n IN 1..10: + coefficient = get_backoff_coefficient(n) + coefficients.append(coefficient) +``` + +### Assertions + +```pseudo +# Verify exact values for the first few retries +ASSERT coefficients[0] == 1.0 # n=1: (1+2)/3 = 1 +ASSERT coefficients[1] == 4.0 / 3.0 # n=2: (2+2)/3 = 4/3 +ASSERT coefficients[2] == 5.0 / 3.0 # n=3: (3+2)/3 = 5/3 +ASSERT coefficients[3] == 2.0 # n=4: (4+2)/3 = 2, capped at 2 + +# Verify all subsequent retries are capped at 2.0 +FOR i IN 3..9: + ASSERT coefficients[i] == 2.0 +``` + +--- + +## RTB1b - Jitter coefficient is between 0.8 and 1.0 + +**Test ID**: `realtime/unit/RTB1b/jitter-coefficient-range-0` + +**Spec requirement:** The jitter coefficient is a random number between 0.8 and 1. The randomness of this number doesn't need to be cryptographically secure but should be approximately uniformly distributed. + +Tests that the jitter coefficient is always within the valid range and shows reasonable distribution. + +### Setup + +```pseudo +# This test verifies the jitter coefficient generator. +# We sample it many times and verify range and approximate uniformity. +``` + +### Test Steps + +```pseudo +sample_count = 1000 +jitter_values = [] + +FOR i IN 1..sample_count: + jitter = get_jitter_coefficient() + jitter_values.append(jitter) +``` + +### Assertions + +```pseudo +# All values must be within [0.8, 1.0] +FOR jitter IN jitter_values: + ASSERT jitter >= 0.8 + ASSERT jitter <= 1.0 + +# Verify approximate uniformity: the mean should be close to 0.9 +# (the midpoint of 0.8 and 1.0). Allow some tolerance for randomness. +mean = sum(jitter_values) / sample_count +ASSERT mean >= 0.85 +ASSERT mean <= 0.95 + +# Verify spread: not all values are the same (degenerate case) +min_value = min(jitter_values) +max_value = max(jitter_values) +ASSERT max_value - min_value > 0.05 +``` + +--- + +## RTB1 - Combined retry delay for DISCONNECTED connections + +**Test ID**: `realtime/unit/RTB1/disconnected-retry-delay-0` + +| Spec | Requirement | +|------|-------------| +| RTB1 | Retry delay = disconnectedRetryTimeout * backoff coefficient * jitter coefficient | +| RTB1a | Backoff coefficient = min((n+2)/3, 2) | +| RTB1b | Jitter coefficient is between 0.8 and 1.0 | + +Tests that the retry delay reported in ConnectionStateChange.retryIn falls within the expected range for successive DISCONNECTED retries, computed as `disconnectedRetryTimeout * backoff * jitter`. + +### Setup + +```pseudo +connection_attempt_count = 0 +retry_delays = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # Initial connection succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 60000 + ) + )) + ELSE: + # All reconnection attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +disconnected_retry_timeout = 2000 # 2 seconds + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: disconnected_retry_timeout, + autoConnect: false, + useBinaryProtocol: false +)) + +# Capture retryIn from DISCONNECTED state changes +client.connection.on((change) => { + IF change.current == ConnectionState.disconnected: + retry_delays.append(change.retryIn) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate unexpected disconnect to trigger reconnection cycle +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Advance time in increments to allow multiple retry cycles. +# Each retry fails (respond_with_refused), producing another DISCONNECTED +# state change with a retryIn value. +# We want at least 5 DISCONNECTED events to verify the backoff sequence. +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF retry_delays.length >= 5: + BREAK +``` + +### Assertions + +```pseudo +ASSERT retry_delays.length >= 5 + +# For each retry, verify retryIn is within the expected range: +# retryIn = disconnectedRetryTimeout * backoff(n) * jitter +# where jitter is in [0.8, 1.0] + +# Retry 1: backoff = 1.0, range = [2000*0.8, 2000*1.0] = [1600, 2000] +ASSERT retry_delays[0] >= disconnected_retry_timeout * 1.0 * 0.8 +ASSERT retry_delays[0] <= disconnected_retry_timeout * 1.0 * 1.0 + +# Retry 2: backoff = 4/3, range = [2000*4/3*0.8, 2000*4/3*1.0] = [2133, 2667] +ASSERT retry_delays[1] >= disconnected_retry_timeout * (4.0/3.0) * 0.8 +ASSERT retry_delays[1] <= disconnected_retry_timeout * (4.0/3.0) * 1.0 + +# Retry 3: backoff = 5/3, range = [2000*5/3*0.8, 2000*5/3*1.0] = [2667, 3333] +ASSERT retry_delays[2] >= disconnected_retry_timeout * (5.0/3.0) * 0.8 +ASSERT retry_delays[2] <= disconnected_retry_timeout * (5.0/3.0) * 1.0 + +# Retry 4+: backoff = 2.0 (capped), range = [2000*2*0.8, 2000*2*1.0] = [3200, 4000] +ASSERT retry_delays[3] >= disconnected_retry_timeout * 2.0 * 0.8 +ASSERT retry_delays[3] <= disconnected_retry_timeout * 2.0 * 1.0 + +ASSERT retry_delays[4] >= disconnected_retry_timeout * 2.0 * 0.8 +ASSERT retry_delays[4] <= disconnected_retry_timeout * 2.0 * 1.0 + +# Verify the delays are monotonically non-decreasing (on average), +# accounting for jitter. The max of retry n should be <= max of retry n+1 +# when backoff is increasing. +CLOSE_CLIENT(client) +``` + +--- + +## RTB1 - Combined retry delay for SUSPENDED channels + +**Test ID**: `realtime/unit/RTB1/suspended-channel-retry-delay-1` + +| Spec | Requirement | +|------|-------------| +| RTB1 | Retry delay = channelRetryTimeout * backoff coefficient * jitter coefficient | +| RTB1a | Backoff coefficient = min((n+2)/3, 2) | +| RTB1b | Jitter coefficient is between 0.8 and 1.0 | + +Tests that the retry delay reported in ChannelStateChange.retryIn falls within the expected range for successive SUSPENDED channel re-attach attempts, computed as `channelRetryTimeout * backoff * jitter`. + +### Setup + +```pseudo +channel_name = "test-RTB1-channel-${random_id()}" +connection_attempt_count = 0 +retry_delays = [] +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessage: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + flags: 0 + )) + ELSE: + # All subsequent re-attach attempts fail with DETACHED + # (per RTL13b, this triggers SUSPENDED state) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel, + error: ErrorInfo( + code: 90001, + statusCode: 500, + message: "Channel re-attach failed" + ) + )) + } +) +install_mock(mock_ws) + +channel_retry_timeout = 3000 # 3 seconds + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + channelRetryTimeout: channel_retry_timeout, + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name) + +# Capture retryIn from SUSPENDED state changes +channel.on((change) => { + IF change.current == ChannelState.suspended: + retry_delays.append(change.retryIn) +}) + +# Initial attach succeeds +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + +# Server sends ERROR on the channel, triggering re-attach (RTL13b). +# The re-attach will fail (DETACHED response), causing SUSPENDED state. +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + code: 90001, + statusCode: 500, + message: "Channel error" + ) +)) + +# Advance time in increments to allow multiple SUSPENDED -> ATTACHING cycles. +# Each re-attach fails, producing another SUSPENDED with retryIn. +LOOP up to 30 times: + ADVANCE_TIME(7000) + IF retry_delays.length >= 4: + BREAK +``` + +### Assertions + +```pseudo +ASSERT retry_delays.length >= 4 + +# Retry 1: backoff = 1.0, range = [3000*0.8, 3000*1.0] = [2400, 3000] +ASSERT retry_delays[0] >= channel_retry_timeout * 1.0 * 0.8 +ASSERT retry_delays[0] <= channel_retry_timeout * 1.0 * 1.0 + +# Retry 2: backoff = 4/3, range = [3000*4/3*0.8, 3000*4/3*1.0] = [3200, 4000] +ASSERT retry_delays[1] >= channel_retry_timeout * (4.0/3.0) * 0.8 +ASSERT retry_delays[1] <= channel_retry_timeout * (4.0/3.0) * 1.0 + +# Retry 3: backoff = 5/3, range = [3000*5/3*0.8, 3000*5/3*1.0] = [4000, 5000] +ASSERT retry_delays[2] >= channel_retry_timeout * (5.0/3.0) * 0.8 +ASSERT retry_delays[2] <= channel_retry_timeout * (5.0/3.0) * 1.0 + +# Retry 4: backoff = 2.0 (capped), range = [3000*2*0.8, 3000*2*1.0] = [4800, 6000] +ASSERT retry_delays[3] >= channel_retry_timeout * 2.0 * 0.8 +ASSERT retry_delays[3] <= channel_retry_timeout * 2.0 * 1.0 + +CLOSE_CLIENT(client) +``` + +--- + +## Implementation Notes + +### Testing the Backoff/Jitter Functions + +The first two tests (RTB1a and RTB1b) verify the underlying calculation functions in isolation. Implementations should expose or have testable access to: + +- A backoff coefficient function that takes the retry count and returns the coefficient +- A jitter coefficient function/generator + +If these functions are private, implementations may test them indirectly through the full retry delay tests (the RTB1 tests), or use language-specific mechanisms to access internal functions (e.g., `@visibleForTesting` in Dart). + +### Jitter Seeding + +For deterministic tests of the full retry delay (RTB1), implementations may optionally seed or mock the random number generator used for jitter. However, the range-based assertions (`>= min, <= max`) should work without mocking since they account for the full jitter range. diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index aefbf40c3..ace9c3981 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTN15h1 - DISCONNECTED with token error, no means to renew +**Test ID**: `realtime/unit/RTN15h1/token-error-no-renew-0` + **Spec requirement:** If a DISCONNECTED message contains a token error and the library cannot renew the token, transition to FAILED state. Tests that non-renewable token errors cause permanent failure. @@ -86,6 +88,8 @@ CLOSE_CLIENT(client) ## RTN15h2 - DISCONNECTED with token error, renewable token +**Test ID**: `realtime/unit/RTN15h2/token-error-renew-success-0` + **Spec requirement:** If a DISCONNECTED message contains a token error and the library can renew the token, transition to CONNECTING, obtain new token, and attempt resume. Tests that renewable token errors trigger token renewal and reconnection. @@ -207,6 +211,8 @@ CLOSE_CLIENT(client) ## RTN15h2 - DISCONNECTED with token error, renewal fails +**Test ID**: `realtime/unit/RTN15h2/token-error-renew-fails-1` + **Spec requirement:** If token renewal or reconnection fails after DISCONNECTED with token error, transition to DISCONNECTED with errorReason set. Tests that failed token renewal leads to DISCONNECTED state. @@ -322,6 +328,8 @@ CLOSE_CLIENT(client) ## RTN15h3 - DISCONNECTED with non-token error +**Test ID**: `realtime/unit/RTN15h3/non-token-error-resume-0` + **Spec requirement:** If a DISCONNECTED message contains an error other than a token error, initiate immediate reconnect with resume attempt. Tests that non-token disconnection triggers immediate resume. @@ -422,6 +430,8 @@ CLOSE_CLIENT(client) ## RTN15j - ERROR protocol message with empty channel +**Test ID**: `realtime/unit/RTN15j/error-empty-channel-failed-0` + **Spec requirement:** If an ERROR ProtocolMessage with empty channel is received when CONNECTED, transition to FAILED state and set errorReason. Tests that fatal connection errors cause FAILED state. @@ -495,6 +505,8 @@ CLOSE_CLIENT(client) ## RTN15a - Unexpected transport disconnect +**Test ID**: `realtime/unit/RTN15a/unexpected-transport-disconnect-0` + **Spec requirement:** If transport is disconnected unexpectedly (without DISCONNECTED or ERROR), respond as if receiving non-token DISCONNECTED message. Tests that transport failures trigger resume attempts. @@ -589,6 +601,8 @@ CLOSE_CLIENT(client) ## RTN15b, RTN15c6 - Successful resume +**Test ID**: `realtime/unit/RTN15b/successful-resume-0` + | Spec | Requirement | |------|-------------| | RTN15b | Resume is attempted with connectionKey in query parameter | @@ -683,6 +697,8 @@ CLOSE_CLIENT(client) ## RTN15c7 - Failed resume (new connectionId) +**Test ID**: `realtime/unit/RTN15c7/failed-resume-new-id-0` + **Spec requirement:** If resume fails, server sends CONNECTED with new connectionId and error. Client should reset msgSerial to 0. Tests that failed resume is handled correctly. @@ -778,6 +794,8 @@ CLOSE_CLIENT(client) ## RTN15e - Connection key updated on resume +**Test ID**: `realtime/unit/RTN15e/connection-key-updated-0` + **Spec requirement:** When connection is resumed, Connection.key may change and is provided in CONNECTED message connectionDetails. Tests that connection key is updated after resume. @@ -792,6 +810,8 @@ ASSERT client.connection.key == "key-1-updated" ## RTN15g - Connection state cleared after connectionStateTtl +**Test ID**: `realtime/unit/RTN15g/state-cleared-after-ttl-0` + **Spec requirement:** If disconnected longer than connectionStateTtl, don't attempt resume. Clear local state and make fresh connection. Tests that stale connections don't attempt resume. After disconnecting, reconnection @@ -933,6 +953,8 @@ CLOSE_CLIENT(client) ## RTN15c5 - ERROR with token error during resume +**Test ID**: `realtime/unit/RTN15c5/token-error-during-resume-0` + **Spec requirement:** If resume attempt receives ERROR with token error, follow RTN15h spec for token error handling. Tests that token errors during resume trigger renewal. @@ -1045,6 +1067,8 @@ CLOSE_CLIENT(client) ## RTN15c4 - ERROR with fatal error during resume +**Test ID**: `realtime/unit/RTN15c4/fatal-error-during-resume-0` + **Spec requirement:** If resume attempt receives ERROR with fatal error, transition to FAILED state. Tests that fatal errors during resume cause permanent failure. diff --git a/uts/realtime/unit/connection/connection_id_key_test.md b/uts/realtime/unit/connection/connection_id_key_test.md index 30f41ab51..81239ea42 100644 --- a/uts/realtime/unit/connection/connection_id_key_test.md +++ b/uts/realtime/unit/connection/connection_id_key_test.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTN8a - Connection ID is unset until connected +**Test ID**: `realtime/unit/RTN8a/id-unset-until-connected-0` + | Spec | Requirement | |------|-------------| | RTN8 | `Connection#id` attribute | @@ -54,6 +56,8 @@ CLOSE_CLIENT(client) ## RTN9a - Connection key is unset until connected +**Test ID**: `realtime/unit/RTN9a/key-unset-until-connected-0` + | Spec | Requirement | |------|-------------| | RTN9 | `Connection#key` attribute | @@ -95,6 +99,8 @@ CLOSE_CLIENT(client) ## RTN8b - Connection ID is unique per connection +**Test ID**: `realtime/unit/RTN8b/id-unique-per-connection-0` + | Spec | Requirement | |------|-------------| | RTN8b | Is a unique string provided by Ably. Multiple connected clients have unique connection IDs | @@ -151,6 +157,8 @@ CLOSE_CLIENT(client2) ## RTN9b - Connection key is unique per connection +**Test ID**: `realtime/unit/RTN9b/key-unique-per-connection-0` + | Spec | Requirement | |------|-------------| | RTN9b | Is a unique private connection key. Multiple connected clients have unique connection keys | @@ -207,6 +215,8 @@ CLOSE_CLIENT(client2) ## RTN8c - Connection ID is null in terminal/non-connected states +**Test ID**: `realtime/unit/RTN8c/id-null-after-closed-0` + | Spec | Requirement | |------|-------------| | RTN8c | Is null when the SDK is in CLOSED, CLOSING, FAILED, or SUSPENDED states | @@ -249,6 +259,8 @@ CLOSE_CLIENT(client) ## RTN9c - Connection key is null in terminal/non-connected states +**Test ID**: `realtime/unit/RTN9c/key-null-after-closed-0` + | Spec | Requirement | |------|-------------| | RTN9c | Is null when the SDK is in CLOSED, CLOSING, FAILED, or SUSPENDED states | @@ -291,6 +303,8 @@ CLOSE_CLIENT(client) ## RTN8c, RTN9c - ID and key null after FAILED +**Test ID**: `realtime/unit/RTN8c/id-key-null-after-failed-1` + **Spec requirement:** Connection ID and key are null in FAILED state. Tests that both `connection.id` and `connection.key` are cleared when the connection transitions to FAILED (e.g. due to a fatal error). @@ -330,6 +344,8 @@ CLOSE_CLIENT(client) ## RTN8c, RTN9c - ID and key null after SUSPENDED +**Test ID**: `realtime/unit/RTN8c/id-key-null-after-suspended-2` + **Spec requirement:** Connection ID and key are null in SUSPENDED state. Tests that both `connection.id` and `connection.key` are null when the connection transitions to SUSPENDED. diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md index 9ac596796..38e93fc4b 100644 --- a/uts/realtime/unit/connection/connection_open_failures_test.md +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTN14a - Invalid API key causes FAILED state +**Test ID**: `realtime/unit/RTN14a/invalid-key-failed-0` + **Spec requirement:** If an API key is invalid, the connection transitions to FAILED state and Connection.errorReason is set. Tests that connecting with an invalid API key results in immediate failure. @@ -79,6 +81,8 @@ CLOSE_CLIENT(client) ## RTN14b - Token error during connection with renewal +**Test ID**: `realtime/unit/RTN14b/token-error-with-renewal-0` + **Spec requirement:** If a token error occurs during connection and the token is renewable, attempt to obtain a new token and retry the connection. Tests that token errors trigger renewal and retry when possible. @@ -173,6 +177,8 @@ CLOSE_CLIENT(client) ## RTN14b - Token error during initial connection, renewal fails +**Test ID**: `realtime/unit/RTN14b/token-renewal-fails-1` + **Spec requirement:** When a token error occurs during the initial connection attempt and the subsequent token renewal also fails, the connection should transition to DISCONNECTED (per RTN14b: "If the attempt to create a new token fails... the connection will transition to the @@ -233,6 +239,8 @@ CLOSE_CLIENT(client) ## RSA4a - Token error during connection without renewal +**Test ID**: `realtime/unit/RSA4a/token-error-no-renewal-0` + **Spec requirement (RSA4a2):** If the server responds with a token error and there is no means to renew the token, the connection transitions to FAILED with error code 40171. Tests that non-renewable token errors cause FAILED state. @@ -289,6 +297,8 @@ CLOSE_CLIENT(client) ## RTN14c - Connection timeout +**Test ID**: `realtime/unit/RTN14c/connection-timeout-0` + **Spec requirement:** A connection attempt fails if not connected within realtimeRequestTimeout. Tests that connections time out if no CONNECTED message is received. @@ -348,6 +358,8 @@ CLOSE_CLIENT(client) ## RTN14d - Retry after recoverable failure +**Test ID**: `realtime/unit/RTN14d/retry-recoverable-failure-0` + **Spec requirement:** After a recoverable connection failure, the client transitions to DISCONNECTED and automatically retries after disconnectedRetryTimeout. Tests that recoverable failures trigger automatic retry. @@ -423,6 +435,8 @@ CLOSE_CLIENT(client) ## RTN14e - DISCONNECTED to SUSPENDED after connectionStateTtl +**Test ID**: `realtime/unit/RTN14e/disconnected-to-suspended-0` + **Spec requirement:** Once the connection has been DISCONNECTED for longer than connectionStateTtl, transition to SUSPENDED state. Tests that prolonged disconnection leads to suspension. @@ -484,6 +498,8 @@ CLOSE_CLIENT(client) ## RTN14f - SUSPENDED state retries indefinitely +**Test ID**: `realtime/unit/RTN14f/suspended-retries-indefinitely-0` + **Spec requirement:** The connection remains in SUSPENDED state indefinitely, periodically attempting to reestablish connection. Tests that SUSPENDED state continues retry attempts. @@ -568,6 +584,8 @@ CLOSE_CLIENT(client) ## RTN14g - ERROR protocol message with empty channel +**Test ID**: `realtime/unit/RTN14g/error-empty-channel-failed-0` + **Spec requirement:** If an ERROR ProtocolMessage with empty channel attribute is received, transition to FAILED state and set errorReason. Tests that fatal protocol errors cause FAILED state. diff --git a/uts/realtime/unit/connection/connection_ping_test.md b/uts/realtime/unit/connection/connection_ping_test.md index 6bae46d43..ea1e03ede 100644 --- a/uts/realtime/unit/connection/connection_ping_test.md +++ b/uts/realtime/unit/connection/connection_ping_test.md @@ -23,6 +23,8 @@ RTN13 defines the `Connection#ping()` function: ## RTN13a - Ping sends HEARTBEAT and returns round-trip duration +**Test ID**: `realtime/unit/RTN13a/ping-heartbeat-roundtrip-0` + | Spec | Requirement | |------|-------------| | RTN13a | Sends HEARTBEAT when connected and expects HEARTBEAT response with round-trip time | @@ -78,6 +80,8 @@ CLOSE_CLIENT(client) ## RTN13e - HEARTBEAT includes random id for disambiguation +**Test ID**: `realtime/unit/RTN13e/heartbeat-random-id-0` + | Spec | Requirement | |------|-------------| | RTN13e | Sent HEARTBEAT includes random id; only matching response counts | @@ -139,6 +143,8 @@ CLOSE_CLIENT(client) ## RTN13e - HEARTBEAT with no id is ignored as ping response +**Test ID**: `realtime/unit/RTN13e/no-id-heartbeat-ignored-1` + | Spec | Requirement | |------|-------------| | RTN13e | Only a HEARTBEAT with matching id counts as a ping response | @@ -192,6 +198,8 @@ CLOSE_CLIENT(client) ## RTN13e - Multiple concurrent pings each get their own response +**Test ID**: `realtime/unit/RTN13e/concurrent-pings-unique-ids-2` + | Spec | Requirement | |------|-------------| | RTN13e | Each ping has a unique random id for disambiguation | @@ -255,6 +263,8 @@ CLOSE_CLIENT(client) ## RTN13c - Ping times out if no HEARTBEAT response +**Test ID**: `realtime/unit/RTN13c/ping-timeout-0` + | Spec | Requirement | |------|-------------| | RTN13c | Fails if HEARTBEAT not received within realtimeRequestTimeout | @@ -305,6 +315,8 @@ CLOSE_CLIENT(client) ## RTN13b - Ping errors in INITIALIZED state +**Test ID**: `realtime/unit/RTN13b/ping-error-initialized-0` + | Spec | Requirement | |------|-------------| | RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | @@ -336,6 +348,8 @@ CLOSE_CLIENT(client) ## RTN13b - Ping errors in SUSPENDED state +**Test ID**: `realtime/unit/RTN13b/ping-error-suspended-1` + | Spec | Requirement | |------|-------------| | RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | @@ -382,6 +396,8 @@ CLOSE_CLIENT(client) ## RTN13b - Ping errors in CLOSED state +**Test ID**: `realtime/unit/RTN13b/ping-error-closed-2` + | Spec | Requirement | |------|-------------| | RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | @@ -424,6 +440,8 @@ CLOSE_CLIENT(client) ## RTN13b - Ping errors in FAILED state +**Test ID**: `realtime/unit/RTN13b/ping-error-failed-3` + | Spec | Requirement | |------|-------------| | RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | @@ -466,6 +484,8 @@ CLOSE_CLIENT(client) ## RTN13d - Ping deferred from CONNECTING state until CONNECTED +**Test ID**: `realtime/unit/RTN13d/ping-deferred-connecting-0` + | Spec | Requirement | |------|-------------| | RTN13d | If CONNECTING or DISCONNECTED, execute ping once CONNECTED | @@ -533,6 +553,8 @@ CLOSE_CLIENT(client) ## RTN13d - Ping deferred from DISCONNECTED state until CONNECTED +**Test ID**: `realtime/unit/RTN13d/ping-deferred-disconnected-1` + | Spec | Requirement | |------|-------------| | RTN13d | If CONNECTING or DISCONNECTED, execute ping once CONNECTED | @@ -613,6 +635,8 @@ CLOSE_CLIENT(client) ## RTN13b - Deferred ping errors if connection transitions to FAILED +**Test ID**: `realtime/unit/RTN13b/deferred-ping-error-failed-4` + | Spec | Requirement | |------|-------------| | RTN13b | Error if has transitioned to INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED | @@ -669,6 +693,8 @@ CLOSE_CLIENT(client) ## RTN13b - Deferred ping errors if connection transitions to SUSPENDED +**Test ID**: `realtime/unit/RTN13b/deferred-ping-error-suspended-5` + | Spec | Requirement | |------|-------------| | RTN13b | Error if has transitioned to INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED | @@ -719,6 +745,8 @@ CLOSE_CLIENT(client) ## RTN13c - Deferred ping times out after realtimeRequestTimeout from CONNECTED +**Test ID**: `realtime/unit/RTN13c/deferred-ping-timeout-1` + | Spec | Requirement | |------|-------------| | RTN13c | Fails if HEARTBEAT not received within realtimeRequestTimeout | diff --git a/uts/realtime/unit/connection/connection_recovery_test.md b/uts/realtime/unit/connection/connection_recovery_test.md new file mode 100644 index 000000000..7b1e8aafc --- /dev/null +++ b/uts/realtime/unit/connection/connection_recovery_test.md @@ -0,0 +1,643 @@ +# Connection Recovery Tests (RTN16) + +Spec points: `RTN16d`, `RTN16f`, `RTN16f1`, `RTN16g`, `RTN16g1`, `RTN16g2`, `RTN16i`, `RTN16j`, `RTN16k`, `RTN16l` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN16g, RTN16g1 - createRecoveryKey returns string with connectionKey, msgSerial, and channel/channelSerial pairs + +**Test ID**: `realtime/unit/RTN16g/recovery-key-structure-0` + +| Spec | Requirement | +|------|-------------| +| RTN16g | `Connection#createRecoveryKey` returns a string incorporating the connectionKey, current msgSerial, and channel name/channelSerial pairs for every attached channel | +| RTN16g1 | The recovery key must be serialized in a way that can encode any unicode channel name | + +Tests that `createRecoveryKey()` returns a correctly structured recovery key containing the connection key, message serial, and channel serials for attached channels, including channels with unicode names. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-abc-123", + connectionDetails: ConnectionDetails( + connectionKey: "key-abc-123", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get the WebSocket connection for sending mock responses +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Get two channels and simulate attaching them (including one with unicode name) +channel_a = client.channels.get("channel-alpha") +channel_b = client.channels.get("channel-éàü-世界") + +# Attach channel_a +channel_a.attach() +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "channel-alpha", + channelSerial: "serial-a-001" +)) +AWAIT_STATE channel_a.state == ChannelState.attached + +# Attach channel_b (unicode name) +channel_b.attach() +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "channel-éàü-世界", + channelSerial: "serial-b-002" +)) +AWAIT_STATE channel_b.state == ChannelState.attached + +# Create recovery key +recovery_key_string = client.connection.createRecoveryKey() +``` + +### Assertions + +```pseudo +# Recovery key is not null +ASSERT recovery_key_string IS NOT null + +# Deserialize the recovery key (JSON format per ably-js reference) +recovery_key = fromJson(recovery_key_string) + +# Contains connectionKey +ASSERT recovery_key["connectionKey"] == "key-abc-123" + +# Contains msgSerial (starts at 0 since no messages were sent) +ASSERT recovery_key["msgSerial"] == 0 + +# Contains channelSerials map with both channels +ASSERT recovery_key["channelSerials"] IS NOT null +ASSERT recovery_key["channelSerials"]["channel-alpha"] == "serial-a-001" + +# RTN16g1: Unicode channel name is correctly encoded in the serialized key +ASSERT recovery_key["channelSerials"]["channel-éàü-世界"] == "serial-b-002" + +# Verify round-trip: re-serializing and deserializing preserves the unicode name +re_serialized = toJson(recovery_key) +re_parsed = fromJson(re_serialized) +ASSERT re_parsed["channelSerials"]["channel-éàü-世界"] == "serial-b-002" + +CLOSE_CLIENT(client) +``` + +--- + +## RTN16g2 - createRecoveryKey returns null in inactive states and before first connect + +**Test ID**: `realtime/unit/RTN16g2/recovery-key-null-inactive-0` + +**Spec requirement:** `createRecoveryKey()` should return null when the SDK is in the CLOSED, CLOSING, FAILED, or SUSPENDED states, or when it does not have a connectionKey (e.g. before first connect). + +Tests that `createRecoveryKey()` returns null in all the specified states. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Before connecting (INITIALIZED state, no connectionKey) +ASSERT client.connection.createRecoveryKey() IS null + +# Connect and verify recovery key is available when CONNECTED +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT client.connection.createRecoveryKey() IS NOT null + +# Transition to CLOSING then CLOSED +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closing +ASSERT client.connection.createRecoveryKey() IS null + +AWAIT_STATE client.connection.state == ConnectionState.closed +ASSERT client.connection.createRecoveryKey() IS null +``` + +### Assertions + +```pseudo +# All null cases verified inline above. +# For FAILED and SUSPENDED states, create separate clients to test: + +# --- Test FAILED state --- +mock_ws_failed = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-f", + connectionKey: "key-f", + connectionDetails: ConnectionDetails( + connectionKey: "key-f", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws_failed) + +client_failed = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +client_failed.connect() +AWAIT_STATE client_failed.connection.state == ConnectionState.connected + +# Trigger FAILED via fatal ERROR +ws_conn = mock_ws_failed.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 50000, statusCode: 500, message: "Fatal error") +)) +AWAIT_STATE client_failed.connection.state == ConnectionState.failed +ASSERT client_failed.connection.createRecoveryKey() IS null + +# --- Test SUSPENDED state --- +mock_ws_suspended = MockWebSocket( + onConnectionAttempt: (conn) => { + # All connections fail after initial, to force SUSPENDED + IF connection_attempt_count == 1: + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-s", + connectionKey: "key-s", + connectionDetails: ConnectionDetails( + connectionKey: "key-s", + maxIdleInterval: 15000, + connectionStateTtl: 2000 + ) + )) + ELSE: + conn.respond_with_refused() + } +) +install_mock(mock_ws_suspended) + +client_suspended = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + autoConnect: false, + fallbackHosts: [] +)) + +enable_fake_timers() + +client_suspended.connect() +AWAIT_STATE client_suspended.connection.state == ConnectionState.connected + +ws_conn_s = mock_ws_suspended.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_conn_s.simulate_disconnect() + +# Advance time until SUSPENDED (connectionStateTtl expires) +LOOP up to 10 times: + ADVANCE_TIME(1500) + IF client_suspended.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client_suspended.connection.state == ConnectionState.suspended +ASSERT client_suspended.connection.createRecoveryKey() IS null + +CLOSE_CLIENT(client_suspended) +``` + +--- + +## RTN16k - recover option adds recover query param to WebSocket URL + +**Test ID**: `realtime/unit/RTN16k/recover-query-param-0` + +**Spec requirement:** When instantiated with the `recover` client option, the library should add a `recover` querystring param (set from the connectionKey component of the recoveryKey) to the first WebSocket request. After successful connection, it should never again supply a `recover` param. + +Tests that the `recover` query parameter is sent on the first connection and not on subsequent reconnections. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +# Construct a valid recoveryKey +recovery_key = toJson({ + "connectionKey": "recovered-key-xyz", + "msgSerial": 5, + "channelSerials": {} +}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First connection: successful recovery (same connectionId as implied by recoveryKey) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn-id", + connectionKey: "new-key-after-recovery", + connectionDetails: ConnectionDetails( + connectionKey: "new-key-after-recovery", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Subsequent connection: resume after disconnect + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn-id", + connectionKey: "resumed-key", + connectionDetails: ConnectionDetails( + connectionKey: "resumed-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect - should use recover param +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate disconnect and reconnection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for resume reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# First connection attempt includes recover param with connectionKey from recoveryKey +ASSERT captured_connection_attempts[0].url.query_params["recover"] == "recovered-key-xyz" + +# First connection attempt does NOT include resume param +ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params + +# Second connection attempt uses resume (not recover) since client is now connected +ASSERT captured_connection_attempts[1].url.query_params["resume"] == "new-key-after-recovery" +ASSERT "recover" NOT IN captured_connection_attempts[1].url.query_params + +CLOSE_CLIENT(client) +``` + +--- + +## RTN16f - recover option initializes msgSerial from recoveryKey + +**Test ID**: `realtime/unit/RTN16f/recover-initializes-msgserial-0` + +**Spec requirement:** When instantiated with the `recover` client option, the library should initialize its internal msgSerial counter to the msgSerial component of the recoveryKey. If recover fails, the counter should be reset to 0 per RTN15c7. + +Tests that the msgSerial is initialized from the recoveryKey and reset on recovery failure. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_messages = [] + +# Construct a recoveryKey with msgSerial of 42 +recovery_key = toJson({ + "connectionKey": "old-key", + "msgSerial": 42, + "channelSerials": { + "test-channel": "ch-serial-1" + } +}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Successful recovery + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn", + connectionKey: "new-key", + connectionDetails: ConnectionDetails( + connectionKey: "new-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect with recovery +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get WebSocket connection reference +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Attach the recovered channel +channel = client.channels.get("test-channel") +channel.attach() +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "test-channel", + channelSerial: "ch-serial-updated" +)) +AWAIT_STATE channel.state == ChannelState.attached + +# Publish a message - the msgSerial should start from the recovered value (42) +channel.publish("event", "data") + +# Capture the MESSAGE frame sent by the client +sent_frames = mock_ws.events.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server") +message_frame = sent_frames.find(f => f.message.action == MESSAGE) + +# ACK the message +ws_connection.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: 42, + count: 1 +)) +``` + +### Assertions + +```pseudo +# The first message published uses msgSerial from the recoveryKey +ASSERT message_frame IS NOT null +ASSERT message_frame.message.msgSerial == 42 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN16f1 - Malformed recoveryKey logs error and connects normally + +**Test ID**: `realtime/unit/RTN16f1/malformed-recovery-key-0` + +**Spec requirement:** If the recovery key provided in the `recover` client option cannot be deserialized due to malformed data, then an error should be logged and the connection should be made like no `recover` option was provided. + +Tests that a malformed recoveryKey is handled gracefully: the connection proceeds normally without the `recover` query parameter. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "fresh-conn", + connectionKey: "fresh-key", + connectionDetails: ConnectionDetails( + connectionKey: "fresh-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Use a malformed (non-JSON) recover string +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: "this-is-not-valid-json!!!", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect - should proceed as a normal connection (no recover param) +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Connection succeeded normally +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "fresh-conn" +ASSERT client.connection.key == "fresh-key" + +# No recover param was sent (malformed key was rejected) +ASSERT "recover" NOT IN captured_connection_attempts[0].url.query_params + +# Also no resume param (this is a fresh connection) +ASSERT "resume" NOT IN captured_connection_attempts[0].url.query_params + +# Only one connection attempt (normal connection, no retries) +ASSERT connection_attempt_count == 1 + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The spec requires that an error be logged when the recovery +> key is malformed. Implementations should verify this by capturing log output (e.g., +> via a log handler) and asserting that an error-level log message was emitted mentioning +> the malformed recovery key. The exact mechanism for capturing logs is implementation-specific. + +--- + +## RTN16j - recover option instantiates channels from recoveryKey with correct channelSerials + +**Test ID**: `realtime/unit/RTN16j/recover-channel-serials-0` + +| Spec | Requirement | +|------|-------------| +| RTN16j | When instantiated with the `recover` client option, for every channel/channelSerial pair in the recoveryKey, the library should instantiate a corresponding channel and set its channelSerial (RTL15b) | + +Tests that channels listed in the recoveryKey are pre-instantiated with their channel serials before the connection is established. + +### Setup + +```pseudo +connection_attempt_count = 0 + +# Construct a recoveryKey with multiple channels +recovery_key = toJson({ + "connectionKey": "old-key-abc", + "msgSerial": 10, + "channelSerials": { + "channel-one": "serial-1-abc", + "channel-two": "serial-2-def", + "channel-üñîçöðé": "serial-3-unicode" + } +}) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "recovered-conn", + connectionKey: "new-key", + connectionDetails: ConnectionDetails( + connectionKey: "new-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect with recovery +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# RTN16j: Channels from the recoveryKey are instantiated +channel_one = client.channels.get("channel-one") +channel_two = client.channels.get("channel-two") +channel_unicode = client.channels.get("channel-üñîçöðé") + +# Each channel has its channelSerial set from the recoveryKey +ASSERT channel_one.properties.channelSerial == "serial-1-abc" +ASSERT channel_two.properties.channelSerial == "serial-2-def" +ASSERT channel_unicode.properties.channelSerial == "serial-3-unicode" + +# RTN16i: Channels are NOT automatically attached — the user must explicitly attach them. +# They should be in INITIALIZED state (the library instantiated them but didn't attach). +ASSERT channel_one.state == ChannelState.initialized +ASSERT channel_two.state == ChannelState.initialized +ASSERT channel_unicode.state == ChannelState.initialized + +# When the user attaches, the ATTACH message should include the channelSerial +# (this enables the server to resume the channel from the correct point) +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +channel_one.attach() + +# Capture the ATTACH frame sent by the client +sent_frames = mock_ws.events.filter(e => + e.type == "ws_frame" AND + e.direction == "client_to_server" AND + e.message.action == ATTACH AND + e.message.channel == "channel-one" +) +ASSERT sent_frames.length == 1 +ASSERT sent_frames[0].message.channelSerial == "serial-1-abc" + +# Complete the attachment +ws_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "channel-one", + channelSerial: "serial-1-abc-updated" +)) +AWAIT_STATE channel_one.state == ChannelState.attached + +CLOSE_CLIENT(client) +``` diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md index 6d1f6f01a..81b855dd9 100644 --- a/uts/realtime/unit/connection/error_reason_test.md +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTN25 - errorReason set on connection errors +**Test ID**: `realtime/unit/RTN25/error-reason-on-failed-0` + **Spec requirement:** Connection#errorReason attribute is an optional ErrorInfo object which is set by the library when an error occurs on the connection, as described by RSA4c1, RSA4d, RTN11d, RTN14a, RTN14b, RTN14e, RTN14g, RTN15c7, RTN15c4, RTN15d, RTN15h, RTN15i, RTN16e. Tests that errorReason is populated correctly across various error scenarios. @@ -70,6 +72,8 @@ CLOSE_CLIENT(client) ## RTN25 - errorReason on DISCONNECTED state (RTN14e) +**Test ID**: `realtime/unit/RTN25/error-reason-disconnected-1` + **Spec requirement:** errorReason is set when connection enters DISCONNECTED state due to connection failure. Tests that errorReason is populated when transitioning to DISCONNECTED. @@ -118,6 +122,8 @@ CLOSE_CLIENT(client) ## RTN25 - errorReason on SUSPENDED state (RTN14e) +**Test ID**: `realtime/unit/RTN25/error-reason-suspended-2` + **Spec requirement:** errorReason is updated when connection enters SUSPENDED state after connectionStateTtl expires. Tests that errorReason reflects suspension reason. @@ -177,6 +183,8 @@ CLOSE_CLIENT(client) ## RTN25 - errorReason on token errors (RTN14b, RSA4a) +**Test ID**: `realtime/unit/RTN25/error-reason-token-error-3` + **Spec requirement:** When an ERROR ProtocolMessage with a token error is received during connection and there is no means to renew the token, RSA4a applies: the connection transitions to FAILED with error code 40171. Tests that errorReason is set with the 40171 wrapper error when a non-renewable token fails. @@ -230,6 +238,8 @@ CLOSE_CLIENT(client) ## RTN25 - errorReason cleared on successful connection +**Test ID**: `realtime/unit/RTN25/error-reason-cleared-on-connect-4` + **Spec requirement:** errorReason should be cleared when connection successfully recovers. Tests that errorReason is reset after successful connection following a failure. @@ -316,6 +326,8 @@ CLOSE_CLIENT(client) ## RTN25 - errorReason on protocol-level ERROR message (RTN14g) +**Test ID**: `realtime/unit/RTN25/error-reason-protocol-error-5` + **Spec requirement:** errorReason is set when ERROR ProtocolMessage with empty channel is received. Tests that connection-level protocol errors populate errorReason. @@ -371,6 +383,8 @@ CLOSE_CLIENT(client) ## RTN25 - errorReason propagated to ConnectionStateChange events +**Test ID**: `realtime/unit/RTN25/error-reason-in-state-change-6` + **Spec requirement:** errorReason should be accessible through ConnectionStateChange events emitted during state transitions. Tests that state change events include error information. diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md index 8a286f5a5..28e438af4 100644 --- a/uts/realtime/unit/connection/fallback_hosts_test.md +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -14,6 +14,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for Mock HTTP Client specification ## RTN17i - Always prefer primary domain first +**Test ID**: `realtime/unit/RTN17i/prefer-primary-domain-0` + **Spec requirement:** By default, every connection attempt is first attempted to the primary domain. The client library must always prefer the primary domain, even if a previous connection attempt to that endpoint has failed. Tests that the client always tries the primary domain first, even after failures. @@ -116,6 +118,8 @@ CLOSE_CLIENT(client) ## RTN17f - Errors that necessitate fallback host usage +**Test ID**: `realtime/unit/RTN17f/fallback-on-error-0` + **Spec requirement:** Errors that necessitate use of an alternative host include conditions specified in RSC15l and also DISCONNECTED responses with error.statusCode in range 500-504. Tests that specific error conditions trigger fallback host usage. @@ -184,6 +188,8 @@ CLOSE_CLIENT(client) ## RTN17f1 - DISCONNECTED with 5xx status triggers fallback +**Test ID**: `realtime/unit/RTN17f1/disconnected-5xx-fallback-0` + **Spec requirement:** A DISCONNECTED response with an error.statusCode in the range 500 <= code <= 504 necessitates use of an alternative host. Tests that 5xx errors in DISCONNECTED messages trigger fallback. @@ -258,6 +264,8 @@ CLOSE_CLIENT(client) ## RTN17j - Connectivity check before fallback +**Test ID**: `realtime/unit/RTN17j/connectivity-check-before-fallback-0` + **Spec requirement:** In case of an error necessitating fallback, check connectivity by issuing GET to connectivityCheckUrl. If response includes "yes", proceed with fallback hosts in random order. Tests that connectivity check is performed before trying fallback hosts. @@ -353,6 +361,8 @@ CLOSE_CLIENT(client) ## RTN17g - Empty fallback set results in immediate error +**Test ID**: `realtime/unit/RTN17g/empty-fallback-set-error-0` + **Spec requirement:** When the set of fallback domains is empty, failing requests that would have qualified for retry should result in an error immediately. Tests that no fallback is attempted when fallback set is empty. @@ -406,6 +416,8 @@ CLOSE_CLIENT(client) ## RTN17h - Fallback domains determined by REC2 +**Test ID**: `realtime/unit/RTN17h/fallback-domains-from-rec2-0` + **Spec requirement:** When fallbacks apply, the set of fallback domains is determined by REC2. Tests that correct fallback hosts are used based on configuration. @@ -475,6 +487,8 @@ CLOSE_CLIENT(client) ## RTN17j - Fallback hosts tried in random order +**Test ID**: `realtime/unit/RTN17j/fallback-random-order-1` + **Spec requirement:** Retry connection against fallback domains in random order to find an alternative healthy datacenter. Tests that fallback hosts are not always tried in the same order. @@ -553,6 +567,8 @@ ASSERT unique_orders >= 2 ## RTN17e - HTTP requests use same fallback host as realtime connection +**Test ID**: `realtime/unit/RTN17e/http-uses-same-fallback-0` + **Spec requirement:** If the realtime client is connected to a fallback host, HTTP requests should first be attempted to the same datacenter. If the HTTP request fails, follow normal fallback behavior. Tests that HTTP requests prefer the same host as the active realtime connection. diff --git a/uts/realtime/unit/connection/forwards_compatibility_test.md b/uts/realtime/unit/connection/forwards_compatibility_test.md new file mode 100644 index 000000000..89d22c0e2 --- /dev/null +++ b/uts/realtime/unit/connection/forwards_compatibility_test.md @@ -0,0 +1,348 @@ +# Forwards Compatibility Tests (RTF1, RSF1) + +Spec points: `RTF1`, `RSF1` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +The Ably client library must apply the robustness principle to deserialization: + +- **RTF1**: ProtocolMessages and related types must tolerate unrecognised attributes (ignored) and unknown enum values (handled gracefully). +- **RSF1**: Messages and related types must tolerate unrecognised attributes (ignored) and unknown enum values (ignored). + +These tests verify that the library does not throw errors or crash when encountering unknown fields or enum values from the server, enabling forwards compatibility when the server adds new features. + +--- + +## RTF1 - ProtocolMessage with unrecognised attributes is deserialized without error + +**Test ID**: `realtime/unit/RTF1/unrecognised-attributes-ignored-0` + +**Spec requirement:** Deserialization of ProtocolMessages and related types must be tolerant to unrecognised attributes, which must be ignored. + +Tests that the client correctly processes a ProtocolMessage containing extra unknown fields that are not part of the current spec, without throwing errors. + +### Setup + +```pseudo +channel_name = "test-RTF1-extra-attrs-${random_id()}" +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name) +channel.subscribe((msg) => { + received_messages.append(msg) +}) +channel.attach() + +# Respond to ATTACH request +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 +)) +AWAIT_STATE channel.state == ChannelState.attached + +# Send a MESSAGE ProtocolMessage with extra unknown attributes. +# The raw JSON includes fields that don't exist in the current spec. +# The client must ignore these and process the message normally. +mock_ws.active_connection.send_to_client_raw({ + "action": 15, # MESSAGE + "channel": channel_name, + "messages": [ + { + "name": "test-event", + "data": "hello", + "serial": "msg-serial-1" + } + ], + "unknownField1": "some-future-value", + "unknownField2": 42, + "unknownNestedObject": { + "nestedKey": "nestedValue" + }, + "unknownArray": [1, 2, 3] +}) + +# Wait for the message to be delivered to the subscriber +poll_until( + () => received_messages.length >= 1, + interval: 100ms, + timeout: 5s +) +``` + +### Assertions + +```pseudo +# Message was delivered successfully despite unknown fields +ASSERT received_messages.length == 1 +ASSERT received_messages[0].name == "test-event" +ASSERT received_messages[0].data == "hello" + +# Connection remains healthy +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The `send_to_client_raw` method sends a raw JSON object +> directly to the client's WebSocket, bypassing the ProtocolMessage constructor. This +> is necessary because the standard `send_to_client(ProtocolMessage(...))` would strip +> unknown fields during construction. If `send_to_client_raw` is not available in the +> mock infrastructure, implementations can serialize a ProtocolMessage and inject +> additional fields into the JSON before sending, or modify the mock to support +> arbitrary extra fields. + +--- + +## RTF1 - ProtocolMessage with unknown action enum value is handled gracefully + +**Test ID**: `realtime/unit/RTF1/unknown-action-handled-1` + +**Spec requirement:** Deserialization of ProtocolMessages and associated enums must be tolerant to unknown enum values, which must be handled in some sensible, language-idiomatic way. + +Tests that the client does not crash or disconnect when receiving a ProtocolMessage with an action value that is not defined in the current spec. + +### Setup + +```pseudo +channel_name = "test-RTF1-unknown-action-${random_id()}" +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record connection state changes to detect unexpected disconnections +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Send a ProtocolMessage with an unknown action value. +# Action 254 is not defined in the current spec. +mock_ws.active_connection.send_to_client_raw({ + "action": 254, + "channel": channel_name, + "unknownPayload": "future-feature-data" +}) + +# Send a normal HEARTBEAT to verify the connection is still processing messages +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT +)) + +# Give the client time to process both messages +poll_until( + () => true, + interval: 100ms, + timeout: 1s +) +``` + +### Assertions + +```pseudo +# Connection should still be CONNECTED - the unknown action was silently ignored +ASSERT client.connection.state == ConnectionState.connected + +# No unexpected state transitions occurred (only the initial connecting -> connected) +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] +# Verify no disconnected or failed states appeared +ASSERT ConnectionState.disconnected NOT IN state_changes +ASSERT ConnectionState.failed NOT IN state_changes + +CLOSE_CLIENT(client) +``` + +--- + +## RSF1 - Message with unrecognised attributes is deserialized without error + +**Test ID**: `realtime/unit/RSF1/message-unrecognised-attrs-0` + +**Spec requirement:** Deserialization of Messages and related types, and associated enums, must be tolerant to unrecognised attributes or enum values. Such unrecognised values must be ignored. + +Tests that a Message containing extra unknown fields is delivered to subscribers without error, and the known fields are correctly parsed. + +### Setup + +```pseudo +channel_name = "test-RSF1-extra-attrs-${random_id()}" +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name) +channel.subscribe((msg) => { + received_messages.append(msg) +}) +channel.attach() + +# Respond to ATTACH request +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 +)) +AWAIT_STATE channel.state == ChannelState.attached + +# Send a MESSAGE ProtocolMessage where the individual messages within +# the messages array contain unknown fields. The ProtocolMessage itself +# is well-formed, but the Message objects have extra attributes. +mock_ws.active_connection.send_to_client_raw({ + "action": 15, # MESSAGE + "channel": channel_name, + "messages": [ + { + "name": "event-1", + "data": "payload-1", + "serial": "serial-1", + "futureField": "future-value", + "futureNumber": 99, + "futureObject": {"nested": true} + }, + { + "name": "event-2", + "data": "payload-2", + "serial": "serial-2", + "anotherUnknownField": [1, 2, 3] + } + ] +}) + +# Wait for both messages to be delivered +poll_until( + () => received_messages.length >= 2, + interval: 100ms, + timeout: 5s +) +``` + +### Assertions + +```pseudo +# Both messages were delivered successfully despite unknown fields +ASSERT received_messages.length == 2 + +# Known fields were correctly parsed +ASSERT received_messages[0].name == "event-1" +ASSERT received_messages[0].data == "payload-1" + +ASSERT received_messages[1].name == "event-2" +ASSERT received_messages[1].data == "payload-2" + +# Connection and channel remain healthy +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached +CLOSE_CLIENT(client) +``` + +--- + +## Implementation Notes + +### send_to_client_raw + +These tests require the ability to send raw JSON to the client's WebSocket connection, including fields that are not part of the ProtocolMessage or Message type definitions. The `send_to_client_raw` method on the mock connection accepts a raw JSON object (map/dictionary) and serializes it directly to the WebSocket, bypassing any type-safe constructors that would strip unknown fields. + +If the mock infrastructure does not support `send_to_client_raw`, alternatives include: +1. Constructing a JSON string manually and writing it to the mock WebSocket transport +2. Modifying the mock `send_to_client` to accept extra fields as an additional parameter +3. Using the language's serialization to add fields post-construction (e.g., adding to a Map after `toJson()`) + +### Enum Handling + +The RTF1 test for unknown action values verifies that the client does not crash. The exact handling of unknown enum values is language-idiomatic: +- **Dart/Swift/Kotlin**: May deserialize to a sentinel/unknown enum variant or null +- **JavaScript/Python**: May store the raw numeric value +- **All languages**: Must not throw an exception or disconnect diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index c50e1ec0f..82a434b49 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -42,6 +42,8 @@ These tests apply to platforms where the WebSocket client does NOT surface ping ## RTN23a - Client sends heartbeats=true when ping frames not observable +**Test ID**: `realtime/unit/RTN23a/heartbeats-true-query-param-0` + **Spec requirement:** If the client cannot observe WebSocket ping frames, it should send `heartbeats=true` in the connection query parameters. Tests that the client requests HEARTBEAT protocol messages. @@ -93,6 +95,8 @@ CLOSE_CLIENT(client) ## RTN23a - Disconnect and reconnect after maxIdleInterval + realtimeRequestTimeout +**Test ID**: `realtime/unit/RTN23a/idle-timeout-reconnect-1` + **Spec requirement:** If no message is received from the server for `maxIdleInterval + realtimeRequestTimeout` milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state, then immediately reconnects (RTN15a). Tests the full disconnect/reconnect cycle when no server activity is detected. @@ -184,6 +188,8 @@ CLOSE_CLIENT(client) ## RTN23a - HEARTBEAT message resets idle timer +**Test ID**: `realtime/unit/RTN23a/heartbeat-resets-timer-2` + **Spec requirement:** Any message from the server, including HEARTBEAT messages, resets the idle timer. Tests that receiving HEARTBEAT messages keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. @@ -267,6 +273,8 @@ CLOSE_CLIENT(client) ## RTN23a - Any protocol message resets idle timer +**Test ID**: `realtime/unit/RTN23a/any-message-resets-timer-3` + **Spec requirement:** Any message from the server resets the idle timer, not just HEARTBEAT messages. Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. @@ -377,6 +385,8 @@ CLOSE_CLIENT(client) ## RTN23a - Heartbeat timeout triggers immediate reconnection +**Test ID**: `realtime/unit/RTN23a/timeout-triggers-reconnect-4` + **Spec requirement:** When a heartbeat timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). Tests that the client attempts to reconnect after a heartbeat timeout, verifying the complete state change sequence. @@ -468,6 +478,8 @@ CLOSE_CLIENT(client) ## RTN23a - Reconnection after heartbeat timeout uses resume +**Test ID**: `realtime/unit/RTN23a/reconnect-uses-resume-5` + **Spec requirement:** When reconnecting after a heartbeat timeout, the client should attempt to resume the connection using the previous connectionKey (per RTN15c). Tests that the reconnection attempt includes the resume parameters. @@ -564,6 +576,8 @@ These tests apply to platforms where the WebSocket client CAN surface ping frame ## RTN23b - Client sends heartbeats=false when ping frames observable +**Test ID**: `realtime/unit/RTN23b/heartbeats-false-query-param-0` + **Spec requirement:** If the client can observe WebSocket ping frames, it should send `heartbeats=false` (or omit the parameter) in the connection query parameters. Tests that the client does not request HEARTBEAT protocol messages when it can observe ping frames. @@ -616,6 +630,8 @@ CLOSE_CLIENT(client) ## RTN23b - Disconnect and reconnect after maxIdleInterval + realtimeRequestTimeout (no ping frames) +**Test ID**: `realtime/unit/RTN23b/idle-timeout-reconnect-1` + **Spec requirement:** If no activity (including ping frames) is received for `maxIdleInterval + realtimeRequestTimeout`, disconnect and reconnect. Tests the full disconnect/reconnect cycle when no ping frames or messages are received. @@ -707,6 +723,8 @@ CLOSE_CLIENT(client) ## RTN23b - Ping frame resets idle timer +**Test ID**: `realtime/unit/RTN23b/ping-frame-resets-timer-2` + **Spec requirement:** WebSocket ping frames count as activity indication and reset the idle timer. Tests that receiving ping frames keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. @@ -788,6 +806,8 @@ CLOSE_CLIENT(client) ## RTN23b - Any protocol message also resets idle timer +**Test ID**: `realtime/unit/RTN23b/any-message-resets-timer-3` + **Spec requirement:** Any message from the server resets the idle timer, not just ping frames. Tests that both ping frames AND protocol messages reset the timer, and that when the timer eventually expires the client disconnects and reconnects. @@ -902,6 +922,8 @@ CLOSE_CLIENT(client) ## RTN23b - Ping frame timeout triggers immediate reconnection +**Test ID**: `realtime/unit/RTN23b/timeout-triggers-reconnect-4` + **Spec requirement:** When a ping frame timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). Tests that the client attempts to reconnect after a ping frame timeout, verifying the complete state change sequence. @@ -993,6 +1015,8 @@ CLOSE_CLIENT(client) ## RTN23b - Reconnection after ping frame timeout uses resume +**Test ID**: `realtime/unit/RTN23b/reconnect-uses-resume-5` + **Spec requirement:** When reconnecting after a ping frame timeout, the client should attempt to resume the connection using the previous connectionKey (per RTN15c). Tests that the reconnection attempt includes the resume parameters. @@ -1083,6 +1107,8 @@ CLOSE_CLIENT(client) ## RTN23b - Multiple ping frames keep connection alive +**Test ID**: `realtime/unit/RTN23b/multiple-pings-keep-alive-6` + **Spec requirement:** Continuous ping frame activity keeps the connection alive indefinitely. Tests that regular ping frames prevent timeout. diff --git a/uts/realtime/unit/connection/network_change_test.md b/uts/realtime/unit/connection/network_change_test.md new file mode 100644 index 000000000..285f70ba7 --- /dev/null +++ b/uts/realtime/unit/connection/network_change_test.md @@ -0,0 +1,476 @@ +# Network Change Tests (RTN20) + +Spec points: `RTN20`, `RTN20a`, `RTN20b`, `RTN20c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Platform Network Connectivity Listener + +> **Implementation requirement:** These tests require an abstract network connectivity +> listener interface that can be mocked in tests. The Ably spec (RTN20) states "when +> the client library can subscribe to OS events for network/internet connectivity +> changes" -- this means implementations need a platform-specific abstraction that: +> +> 1. Provides a way for the Ably client to subscribe to network connectivity change events +> 2. Can be injected or replaced in tests with a mock that allows programmatic triggering +> of "network available" and "network unavailable" events +> +> Example mock interface: +> ```pseudo +> interface MockNetworkListener: +> simulate_network_lost() # Triggers "internet connection no longer available" +> simulate_network_available() # Triggers "internet connection now available" +> ``` +> +> The mock should be installed before creating the Realtime client, typically via +> dependency injection or a platform-specific test hook. + +## Overview + +RTN20 defines how the client should respond to OS-level network connectivity change events: + +- **RTN20a**: Network loss while CONNECTED or CONNECTING triggers immediate DISCONNECTED +- **RTN20b**: Network available while DISCONNECTED or SUSPENDED triggers immediate connect attempt +- **RTN20c**: Network available while CONNECTING restarts the pending connection attempt + +### Verifying Transient States + +These tests use the record-and-verify pattern for state transitions. Network change events may trigger rapid state transitions, so we record all state changes and verify the sequence at the end rather than trying to observe intermediate states. + +--- + +## RTN20a - Network loss while CONNECTED triggers immediate DISCONNECTED transition + +**Test ID**: `realtime/unit/RTN20a/network-loss-connected-disconnects-0` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20a | When CONNECTED, if the OS indicates that the underlying internet connection is no longer available, the client should immediately transition to DISCONNECTED with an appropriate reason | + +Tests that losing network connectivity while in the CONNECTED state causes an immediate transition to DISCONNECTED, which then triggers automatic reconnection per RTN15. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append({ + current: change.current, + previous: change.previous, + reason: change.reason + }) +}) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Simulate OS reporting network loss +mock_network.simulate_network_lost() + +# The client should transition to DISCONNECTED and then automatically +# attempt to reconnect (per RTN15). Wait for the full cycle to complete. +# The reconnection may succeed immediately since the mock WebSocket +# always accepts connections. +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Verify the state change sequence includes DISCONNECTED transition +ASSERT state_changes CONTAINS_IN_ORDER [ + { current: ConnectionState.connecting }, + { current: ConnectionState.connected }, + { current: ConnectionState.disconnected }, + { current: ConnectionState.connecting }, + { current: ConnectionState.connected } +] + +# Verify the DISCONNECTED state change has an appropriate reason +disconnected_change = state_changes.find(s => s.current == ConnectionState.disconnected) +ASSERT disconnected_change.reason IS NOT null + +# Verify reconnection happened +ASSERT connection_attempt_count == 2 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN20a - Network loss while CONNECTING triggers DISCONNECTED transition + +**Test ID**: `realtime/unit/RTN20a/network-loss-connecting-disconnects-1` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20a | When CONNECTING, if the OS indicates that the underlying internet connection is no longer available, the client should immediately transition to DISCONNECTED | + +Tests that losing network connectivity while in the CONNECTING state (before the WebSocket connection completes) causes an immediate transition to DISCONNECTED. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First attempt: don't respond yet - leave in CONNECTING state. + # The network loss event will fire while we're still connecting. + # Do NOT call respond_with_success() here. + ELSE: + # Subsequent attempts succeed + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +# Start connecting - the mock won't respond, so we stay in CONNECTING +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Simulate OS reporting network loss while still CONNECTING +mock_network.simulate_network_lost() + +# Client should transition to DISCONNECTED, then eventually reconnect +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# The first connection attempt was abandoned, second succeeded +ASSERT connection_attempt_count >= 2 + +CLOSE_CLIENT(client) +``` + +--- + +## RTN20b - Network available while DISCONNECTED triggers immediate connect attempt + +**Test ID**: `realtime/unit/RTN20b/network-available-disconnected-connects-0` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20b | When DISCONNECTED, if the OS indicates that the underlying internet connection is now available, the client should immediately attempt to connect | + +Tests that a network-available event while in the DISCONNECTED state triggers an immediate connection attempt, rather than waiting for the scheduled retry timer. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First connection succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE IF connection_attempt_count == 2: + # Second attempt (after disconnect) fails - puts client in DISCONNECTED + conn.respond_with_refused() + ELSE: + # Third attempt (triggered by network available) succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 30000, # 30 seconds - deliberately long + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect - the reconnection attempt will fail (respond_with_refused), +# putting the client into DISCONNECTED state with a 30-second retry timer. +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for the client to reach DISCONNECTED after the failed reconnection +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +# Record the connection attempt count before network event +attempts_before = connection_attempt_count + +# Simulate OS reporting network is now available. +# This should trigger an IMMEDIATE connection attempt, bypassing the +# 30-second disconnectedRetryTimeout. +mock_network.simulate_network_available() + +# Should connect immediately without needing to advance time +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# A new connection attempt was made immediately after network available event +ASSERT connection_attempt_count > attempts_before + +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** The key assertion here is that reconnection happens without +> advancing fake timers past the 30-second `disconnectedRetryTimeout`. If the network +> available event did NOT trigger an immediate attempt, the client would remain +> DISCONNECTED until the 30-second timer fires. The fact that we reach CONNECTED +> without advancing time proves the network event bypassed the retry timer. + +--- + +## RTN20c - Network available while CONNECTING restarts the connection attempt + +**Test ID**: `realtime/unit/RTN20c/network-available-connecting-restarts-0` + +| Spec | Requirement | +|------|-------------| +| RTN20 | When the client library can subscribe to OS events for network/internet connectivity changes | +| RTN20c | When CONNECTING, if the OS indicates that the underlying internet connection is now available, the client should restart the pending connection attempt | + +Tests that a network-available event while in the CONNECTING state causes the client to restart (abandon and retry) the pending connection attempt. + +### Setup + +```pseudo +connection_attempt_count = 0 +state_changes = [] + +mock_network = MockNetworkListener() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First attempt: don't respond - leave pending (simulates slow connection) + # The network-available event will fire while this attempt is pending. + ELSE: + # Second attempt (restarted after network event) succeeds + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +install_mock(mock_network) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) + +# Record all state changes +client.connection.on((change) => { + state_changes.append(change.current) +}) +``` + +### Test Steps + +```pseudo +# Start connecting - the mock won't respond to the first attempt, +# leaving the client in CONNECTING state +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +ASSERT connection_attempt_count == 1 + +# Simulate OS reporting network is now available while still CONNECTING. +# The client should abandon the pending connection and start a new attempt. +mock_network.simulate_network_available() + +# The restarted connection attempt should succeed +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# The first connection attempt was abandoned and a new one was made +ASSERT connection_attempt_count >= 2 + +# Client is now connected +ASSERT client.connection.state == ConnectionState.connected + +CLOSE_CLIENT(client) +``` + +> **Implementation note:** Some implementations may briefly transition through +> DISCONNECTED when restarting the connection attempt (abandon old attempt -> +> DISCONNECTED -> CONNECTING -> CONNECTED). Others may stay in CONNECTING and +> simply restart the underlying transport. Both approaches satisfy RTN20c. +> The state change assertions use `CONTAINS_IN_ORDER` with minimal requirements +> to accommodate either approach. + +--- + +## Implementation Notes + +### Network Connectivity Abstraction + +Each platform has different mechanisms for observing network connectivity changes: + +| Platform | Mechanism | +|----------|-----------| +| **iOS/macOS** | `NWPathMonitor` (Network framework) or `SCNetworkReachability` | +| **Android** | `ConnectivityManager.NetworkCallback` | +| **Dart/Flutter** | `connectivity_plus` package or platform channels | +| **JavaScript (Browser)** | `navigator.onLine` + `online`/`offline` events on `window` | +| **JavaScript (Node.js)** | Not typically available - RTN20 may not apply | +| **Python** | Not typically available - RTN20 may not apply | + +The mock used in these tests should be injected via the same mechanism the SDK uses to receive real network events. For example, if the SDK accepts a `NetworkConnectivityListener` interface in its constructor or options, the mock should implement that interface. + +### RTN20 Conditionality + +RTN20 begins with "When the client library can subscribe to OS events" -- this means the feature is optional for platforms where network monitoring is not feasible. SDKs that do not implement network monitoring should skip these tests entirely. + +### Timer Interaction (RTN20b) + +When the client is in DISCONNECTED state, there is typically a retry timer scheduled (per RTB1). When a network-available event triggers an immediate connection attempt (RTN20b), implementations should cancel the pending retry timer to avoid a duplicate connection attempt. diff --git a/uts/realtime/unit/connection/server_initiated_reauth_test.md b/uts/realtime/unit/connection/server_initiated_reauth_test.md index 5f96567c1..d3a6cc586 100644 --- a/uts/realtime/unit/connection/server_initiated_reauth_test.md +++ b/uts/realtime/unit/connection/server_initiated_reauth_test.md @@ -24,6 +24,8 @@ period, Ably forcibly disconnects via a `DISCONNECTED` message with a token erro ## RTN22 - Server sends AUTH, client re-authenticates +**Test ID**: `realtime/unit/RTN22/server-auth-triggers-reauth-0` + **Spec requirement:** Ably can request that a connected client re-authenticates by sending the client an `AUTH` ProtocolMessage. The client must then immediately start a new authentication process as described in RTC8. Tests that receiving an `AUTH` message from the server triggers the client to obtain a new token and send an `AUTH` message back. @@ -123,6 +125,8 @@ CLOSE_CLIENT(client) ## RTN22 - Connection remains CONNECTED during server-initiated reauth +**Test ID**: `realtime/unit/RTN22/stays-connected-during-reauth-1` + **Spec requirement:** The re-authentication triggered by the server's AUTH message must follow the RTC8 flow — if the connection is CONNECTED, an AUTH message is sent without disconnecting. Tests that the connection state does not change during server-initiated re-authentication. @@ -212,6 +216,8 @@ CLOSE_CLIENT(client) ## RTN22a - Forced disconnect on reauth failure +**Test ID**: `realtime/unit/RTN22a/forced-disconnect-reauth-failure-0` + **Spec requirement:** Ably reserves the right to forcibly disconnect a client that does not re-authenticate within an acceptable period. A client is forcibly disconnected following a `DISCONNECTED` message containing an error code in the range 40140–40149. This forces the client to re-authenticate and resume via RTN15h. Tests that when the server sends a `DISCONNECTED` message with a token error code after requesting reauth, the client transitions to DISCONNECTED and initiates token-error recovery. diff --git a/uts/realtime/unit/connection/update_events_test.md b/uts/realtime/unit/connection/update_events_test.md index 803ad39eb..480c5bdf5 100644 --- a/uts/realtime/unit/connection/update_events_test.md +++ b/uts/realtime/unit/connection/update_events_test.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTN24 - CONNECTED message while already CONNECTED emits UPDATE event +**Test ID**: `realtime/unit/RTN24/connected-emits-update-0` + **Spec requirement:** A connected client may receive a CONNECTED ProtocolMessage from Ably at any point (typically triggered by reauth). The connectionDetails must override stored details. The Connection should emit an UPDATE event with ConnectionStateChange having both previous and current attributes set to CONNECTED, and reason set to the error member of the CONNECTED ProtocolMessage (if any). The library must NOT emit a CONNECTED event if already connected. Tests that receiving CONNECTED while CONNECTED emits UPDATE, not CONNECTED. @@ -71,12 +73,13 @@ ASSERT connected_events.length == 1 ASSERT update_events.length == 0 # Server sends another CONNECTED message (e.g., after reauth) +# Note: connectionId is a top-level ProtocolMessage field, NOT inside +# connectionDetails, so it never changes for an in-progress connection. mock_ws.active_connection.send_to_client(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id-2", - connectionKey: "connection-key-2", + connectionId: "connection-id-1", connectionDetails: ConnectionDetails( - connectionKey: "connection-key-2", + connectionKey: "connection-key-1", maxIdleInterval: 20000, # Different value connectionStateTtl: 120000, clientId: "client-123" @@ -105,9 +108,11 @@ ASSERT update_change.previous == ConnectionState.connected ASSERT update_change.current == ConnectionState.connected ASSERT update_change.reason IS null # No error in this case -# Connection details were updated -ASSERT client.connection.id == "connection-id-2" -ASSERT client.connection.key == "connection-key-2" +# connection.id and connection.key are unchanged — connectionId is a +# top-level ProtocolMessage field not inside connectionDetails, so RTN24's +# "connectionDetails must override stored details" does not apply to it. +ASSERT client.connection.id == "connection-id-1" +ASSERT client.connection.key == "connection-key-1" CLOSE_CLIENT(client) ``` @@ -115,6 +120,8 @@ CLOSE_CLIENT(client) ## RTN24 - UPDATE event with error reason +**Test ID**: `realtime/unit/RTN24/update-event-with-error-1` + **Spec requirement:** The UPDATE event's reason attribute should be set to the error member of the CONNECTED ProtocolMessage (if any). Tests that UPDATE events include error information when present. @@ -165,10 +172,9 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Server sends CONNECTED with error (e.g., token was renewed due to expiry) mock_ws.active_connection.send_to_client(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id-2", - connectionKey: "connection-key-2", + connectionId: "connection-id-1", connectionDetails: ConnectionDetails( - connectionKey: "connection-key-2", + connectionKey: "connection-key-1", maxIdleInterval: 15000, connectionStateTtl: 120000 ), @@ -204,9 +210,11 @@ CLOSE_CLIENT(client) ## RTN24 - ConnectionDetails override -**Spec requirement:** The connectionDetails in the ProtocolMessage must override any stored details (see RTN21). +**Test ID**: `realtime/unit/RTN24/connection-details-override-2` -Tests that receiving a new CONNECTED message updates connection details. +**Spec requirement:** The connectionDetails in the ProtocolMessage must override any stored details (see RTN21). Note: `connectionId` is a top-level ProtocolMessage field, NOT inside `connectionDetails`, so it is never updated by RTN24. The connectionDetails fields that are overridden include operational parameters like `maxIdleInterval`, `connectionStateTtl`, `maxMessageSize`, and `serverId`. + +Tests that receiving a new CONNECTED message overrides stored connectionDetails. ### Setup @@ -217,7 +225,6 @@ mock_ws = MockWebSocket( conn.send_to_client(ProtocolMessage( action: CONNECTED, connectionId: "connection-id-1", - connectionKey: "connection-key-1", connectionDetails: ConnectionDetails( connectionKey: "connection-key-1", maxIdleInterval: 10000, @@ -247,19 +254,18 @@ client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected WITH timeout: 5 seconds -# Verify initial connection details -initial_id = client.connection.id -initial_key = client.connection.key -ASSERT initial_id == "connection-id-1" -ASSERT initial_key == "connection-key-1" +# Verify initial connection +ASSERT client.connection.id == "connection-id-1" +ASSERT client.connection.key == "connection-key-1" -# Server sends new CONNECTED with different details +# Server sends new CONNECTED with different connectionDetails (RTN24) +# connectionId stays the same — the server never changes it for an +# in-progress connection. mock_ws.active_connection.send_to_client(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id-2", - connectionKey: "connection-key-2", + connectionId: "connection-id-1", connectionDetails: ConnectionDetails( - connectionKey: "connection-key-2", + connectionKey: "connection-key-1", maxIdleInterval: 20000, # Changed connectionStateTtl: 120000, # Changed maxMessageSize: 32768, # Changed @@ -275,13 +281,14 @@ WAIT(100) ### Assertions ```pseudo -# Connection details were updated -ASSERT client.connection.id == "connection-id-2" -ASSERT client.connection.key == "connection-key-2" +# connection.id is unchanged (not inside connectionDetails) +ASSERT client.connection.id == "connection-id-1" +ASSERT client.connection.key == "connection-key-1" -# All connection details should be overridden -# (The exact accessors for these details may vary by implementation) -# Verify that the implementation stores and uses the new values +# connectionDetails fields were overridden (RTN21) +# The exact accessors for these details may vary by implementation. +# The effect can be observed indirectly — e.g., the heartbeat timeout +# changes when maxIdleInterval is overridden. # State remains CONNECTED ASSERT client.connection.state == ConnectionState.connected @@ -292,6 +299,8 @@ CLOSE_CLIENT(client) ## RTN24 - No duplicate CONNECTED event +**Test ID**: `realtime/unit/RTN24/no-duplicate-connected-event-3` + **Spec requirement:** The library must not emit a CONNECTED event if the client was already connected (see RTN4h). Tests that only UPDATE events are emitted, not CONNECTED events, when receiving CONNECTED while already connected. @@ -352,14 +361,13 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Record event count after initial connection initial_event_count = all_events.length -# Send multiple CONNECTED messages +# Send multiple CONNECTED messages (same connectionId — it never changes) FOR i IN [1, 2, 3]: mock_ws.active_connection.send_to_client(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id-" + (i + 1), - connectionKey: "connection-key-" + (i + 1), + connectionId: "connection-id-1", connectionDetails: ConnectionDetails( - connectionKey: "connection-key-" + (i + 1), + connectionKey: "connection-key-1", maxIdleInterval: 15000, connectionStateTtl: 120000 ) diff --git a/uts/realtime/unit/connection/when_state_test.md b/uts/realtime/unit/connection/when_state_test.md index 7715ad5a8..6dda70a88 100644 --- a/uts/realtime/unit/connection/when_state_test.md +++ b/uts/realtime/unit/connection/when_state_test.md @@ -13,6 +13,8 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## RTN26a - whenState calls listener immediately if already in state +**Test ID**: `realtime/unit/RTN26a/immediate-callback-current-state-0` + **Spec requirement:** If the connection is already in the given state, calls the listener with a null argument. Tests that whenState invokes callback immediately when the connection is already in the target state. @@ -81,6 +83,8 @@ CLOSE_CLIENT(client) ## RTN26b - whenState waits for state if not already in it +**Test ID**: `realtime/unit/RTN26b/deferred-callback-future-state-0` + **Spec requirement:** Else, calls #once with the given state and listener. Tests that whenState waits for state transition when not currently in the target state. @@ -157,6 +161,8 @@ CLOSE_CLIENT(client) ## RTN26b - whenState only fires once +**Test ID**: `realtime/unit/RTN26b/fires-only-once-1` + **Spec requirement:** whenState uses #once, meaning it should only fire once, not on every subsequent occurrence of the state. Tests that whenState callback is invoked only once even if state is entered multiple times. @@ -260,6 +266,8 @@ CLOSE_CLIENT(client) ## RTN26a - Multiple whenState calls +**Test ID**: `realtime/unit/RTN26a/multiple-whenstate-calls-1` + **Spec requirement:** Multiple calls to whenState should each be handled independently. Tests that multiple whenState listeners can be registered and each behaves correctly. @@ -334,6 +342,8 @@ CLOSE_CLIENT(client) ## RTN26a - whenState with already-passed state +**Test ID**: `realtime/unit/RTN26a/no-fire-for-past-state-2` + **Spec requirement:** whenState should invoke immediately with null if already in the target state. Tests that whenState for a state that was passed but is no longer current does NOT fire immediately. @@ -399,6 +409,8 @@ CLOSE_CLIENT(client) ## RTN26 - whenState with different states +**Test ID**: `realtime/unit/RTN26/whenstate-different-states-0` + **Spec requirement:** whenState should work correctly for all connection states. Tests that whenState functions correctly across different state transitions. diff --git a/uts/realtime/unit/presence/local_presence_map.md b/uts/realtime/unit/presence/local_presence_map.md index c8efe3aba..f5f631bb3 100644 --- a/uts/realtime/unit/presence/local_presence_map.md +++ b/uts/realtime/unit/presence/local_presence_map.md @@ -34,6 +34,8 @@ LocalPresenceMap: ## RTP17h - Keyed by clientId, not memberKey +**Test ID**: `realtime/unit/RTP17h/keyed-by-clientid-0` + **Spec requirement:** Unlike the main PresenceMap (keyed by memberKey), the RTP17 PresenceMap must be keyed only by clientId. Otherwise, entries associated with old connectionIds would never be removed, even if the user deliberately leaves presence. @@ -79,6 +81,8 @@ ASSERT map.get("user-1").connectionId == "conn-B" ## RTP17b - ENTER adds to map +**Test ID**: `realtime/unit/RTP17b/enter-adds-to-map-0` + **Spec requirement:** Any ENTER event with a connectionId matching the current client's connectionId should be applied to the RTP17 presence map. @@ -111,6 +115,8 @@ ASSERT map.values().length == 1 ## RTP17b - UPDATE with no prior entry adds to map +**Test ID**: `realtime/unit/RTP17b/update-adds-to-map-1` + **Spec requirement:** ENTER and UPDATE are interchangeable — both add a member to the map. An UPDATE on a clientId that has no prior entry behaves identically to an ENTER. @@ -143,6 +149,8 @@ ASSERT map.values().length == 1 ## RTP17b - ENTER after ENTER overwrites +**Test ID**: `realtime/unit/RTP17b/enter-overwrites-enter-2` + **Spec requirement:** ENTER and UPDATE are interchangeable. A second ENTER for the same clientId overwrites the first, just as an UPDATE would. @@ -183,6 +191,8 @@ ASSERT map.get("client-1").data == "second" ## RTP17b - UPDATE after ENTER overwrites +**Test ID**: `realtime/unit/RTP17b/update-overwrites-enter-3` + **Spec requirement:** UPDATE overwrites a prior ENTER for the same clientId. ### Setup @@ -222,6 +232,8 @@ ASSERT map.get("client-1").data == "updated" ## RTP17b - PRESENT adds to map +**Test ID**: `realtime/unit/RTP17b/present-adds-to-map-4` + **Spec requirement:** Any PRESENT event with a matching connectionId should be applied. ### Setup @@ -252,6 +264,8 @@ ASSERT map.get("client-1").data == "present" ## RTP17b - Non-synthesized LEAVE removes from map +**Test ID**: `realtime/unit/RTP17b/non-synthesized-leave-removes-5` + **Spec requirement:** Any LEAVE event with a connectionId matching the current client's connectionId that is NOT a synthesized leave should remove the member. @@ -297,6 +311,8 @@ ASSERT map.values().length == 0 ## RTP17b - Synthesized LEAVE is ignored +**Test ID**: `realtime/unit/RTP17b/synthesized-leave-ignored-6` + **Spec requirement:** A synthesized leave event (where connectionId is NOT an initial substring of its id, per RTP2b1) should NOT be applied to the RTP17 presence map. The remove method checks whether the connectionId is a prefix of the message id. @@ -350,6 +366,8 @@ ASSERT map.values().length == 1 ## RTP17 - Multiple clientIds coexist +**Test ID**: `realtime/unit/RTP17/multiple-clientids-coexist-0` + **Spec requirement:** The local presence map can contain multiple members with different clientIds (e.g., when a single connection enters presence with multiple clientIds using enterClient). @@ -381,6 +399,8 @@ ASSERT map.get("carol").data == "carol-data" ## RTP17 - Remove one of multiple members +**Test ID**: `realtime/unit/RTP17/remove-one-of-multiple-1` + ### Setup ```pseudo map = LocalPresenceMap() @@ -405,6 +425,8 @@ ASSERT map.values().length == 1 ## clear() resets all state +**Test ID**: `realtime/unit/RTP17/clear-resets-state-2` + **Spec requirement (RTP5a):** When the channel enters DETACHED or FAILED state, the internal PresenceMap is cleared. This ensures members are not automatically re-entered if the channel later becomes attached. @@ -435,6 +457,8 @@ ASSERT map.get("bob") IS null ## RTP17 - Get returns null for unknown clientId +**Test ID**: `realtime/unit/RTP17/get-null-unknown-clientid-3` + ### Setup ```pseudo map = LocalPresenceMap() @@ -454,6 +478,8 @@ ASSERT result IS null ## RTP17 - Remove for unknown clientId is a no-op +**Test ID**: `realtime/unit/RTP17/remove-unknown-noop-4` + ### Setup ```pseudo map = LocalPresenceMap() diff --git a/uts/realtime/unit/presence/presence_map.md b/uts/realtime/unit/presence/presence_map.md index 99d860c98..9576adb54 100644 --- a/uts/realtime/unit/presence/presence_map.md +++ b/uts/realtime/unit/presence/presence_map.md @@ -33,6 +33,8 @@ PresenceMap: ## RTP2 - Basic put and get +**Test ID**: `realtime/unit/RTP2/basic-put-and-get-0` + **Spec requirement:** Use a PresenceMap to maintain a list of members present on a channel, a map of memberKeys to presence messages. @@ -65,6 +67,8 @@ ASSERT map.get("conn-1:client-1").connectionId == "conn-1" ## RTP2d2 - ENTER stored as PRESENT +**Test ID**: `realtime/unit/RTP2d2/enter-stored-as-present-0` + **Spec requirement:** When an ENTER, UPDATE, or PRESENT message is received, add to the presence map with action set to PRESENT. @@ -98,6 +102,8 @@ ASSERT stored.data == "entered" ## RTP2d2 - UPDATE stored as PRESENT +**Test ID**: `realtime/unit/RTP2d2/update-stored-as-present-1` + **Spec requirement:** UPDATE messages are also stored with action PRESENT. ### Setup @@ -139,6 +145,8 @@ ASSERT stored.data == "updated" ## RTP2d2 - PRESENT stored as PRESENT +**Test ID**: `realtime/unit/RTP2d2/present-stored-as-present-2` + **Spec requirement:** PRESENT messages (from SYNC) are stored with action PRESENT. ### Setup @@ -168,6 +176,8 @@ ASSERT stored.action == PRESENT ## RTP2d1 - put returns message with original action +**Test ID**: `realtime/unit/RTP2d1/put-returns-original-action-0` + **Spec requirement:** Emit to subscribers with the original action (ENTER, UPDATE, or PRESENT), not the stored PRESENT action. @@ -209,6 +219,8 @@ ASSERT emitted_update.action == UPDATE # Original action preserved for emissio ## RTP2h1 - LEAVE outside sync removes member +**Test ID**: `realtime/unit/RTP2h1/leave-outside-sync-removes-0` + **Spec requirement:** When a LEAVE message is received and SYNC is NOT in progress, emit LEAVE and delete from presence map. @@ -253,6 +265,8 @@ ASSERT map.values().length == 0 ## RTP2h1 - LEAVE for non-existent member returns null +**Test ID**: `realtime/unit/RTP2h1/leave-nonexistent-returns-null-1` + **Spec requirement:** If there is no matching memberKey in the map, there is nothing to remove. ### Setup @@ -280,6 +294,8 @@ ASSERT emitted IS null ## RTP2h2a - LEAVE during sync stores as ABSENT +**Test ID**: `realtime/unit/RTP2h2a/leave-during-sync-stores-absent-0` + **Spec requirement:** If a SYNC is in progress and a LEAVE message is received, store the member in the presence map with action set to ABSENT. @@ -327,6 +343,8 @@ ASSERT stored.action == ABSENT ## RTP2h2b - ABSENT members deleted on endSync +**Test ID**: `realtime/unit/RTP2h2b/absent-deleted-on-endsync-0` + **Spec requirement:** When SYNC completes, delete all members with action ABSENT. ### Setup @@ -369,6 +387,8 @@ ASSERT map.values().length == 1 ## RTP2b2 - Newness comparison by id (msgSerial:index) +**Test ID**: `realtime/unit/RTP2b2/newness-by-msgserial-index-0` + **Spec requirement:** When the connectionId IS an initial substring of the message id, split the id into `connectionId:msgSerial:index` and compare msgSerial then index numerically. Larger values are newer. @@ -426,6 +446,8 @@ ASSERT map.get("conn-1:client-1").data == "newer" ## RTP2b2 - Newness comparison by index when msgSerial equal +**Test ID**: `realtime/unit/RTP2b2/newness-by-index-same-serial-1` + **Spec requirement:** When msgSerial values are equal, compare by index. ### Setup @@ -476,6 +498,8 @@ ASSERT map.get("conn-1:client-1").data == "index-5" ## RTP2b1 - Newness comparison by timestamp (synthesized leave) +**Test ID**: `realtime/unit/RTP2b1/newness-by-timestamp-0` + **Spec requirement:** If either message has a connectionId which is NOT an initial substring of its id, compare by timestamp. This handles "synthesized leave" events where the server generates a LEAVE on behalf of a disconnected client. @@ -520,6 +544,8 @@ ASSERT map.get("conn-1:client-1") IS null ## RTP2b1 - Synthesized leave rejected when older by timestamp +**Test ID**: `realtime/unit/RTP2b1/older-synth-leave-rejected-1` + **Spec requirement:** When comparing by timestamp, an older synthesized leave is rejected. ### Setup @@ -560,6 +586,8 @@ ASSERT map.get("conn-1:client-1").data == "entered" ## RTP2b1a - Equal timestamps: incoming message is newer +**Test ID**: `realtime/unit/RTP2b1a/equal-timestamps-incoming-wins-0` + **Spec requirement:** If timestamps are equal, the newly-incoming message is considered newer. ### Setup @@ -599,6 +627,8 @@ ASSERT map.get("conn-1:client-1").data == "second" ## RTP2c - SYNC messages use same newness comparison +**Test ID**: `realtime/unit/RTP2c/sync-uses-same-newness-0` + **Spec requirement:** Presence events from a SYNC must be compared for newness the same way as PRESENCE messages. @@ -653,6 +683,8 @@ ASSERT map.get("conn-1:client-1").data == "sync-newer" ## RTP2 - Multiple members coexist +**Test ID**: `realtime/unit/RTP2/multiple-members-coexist-1` + **Spec requirement:** The presence map maintains multiple members with different memberKeys. ### Setup @@ -680,6 +712,8 @@ ASSERT map.get("c3:alice") IS NOT null ## RTP2 - values() excludes ABSENT members +**Test ID**: `realtime/unit/RTP2/values-excludes-absent-2` + **Spec requirement:** The values() method returns only PRESENT members. ### Setup @@ -712,6 +746,8 @@ ASSERT members[0].clientId == "alice" ## clear() resets all state +**Test ID**: `realtime/unit/RTP2/clear-resets-state-3` + ### Setup ```pseudo map = PresenceMap() diff --git a/uts/realtime/unit/presence/presence_sync.md b/uts/realtime/unit/presence/presence_sync.md index cc04d0ee1..f3da077b4 100644 --- a/uts/realtime/unit/presence/presence_sync.md +++ b/uts/realtime/unit/presence/presence_sync.md @@ -34,6 +34,8 @@ PresenceMap: ## RTP18a - startSync sets isSyncInProgress +**Test ID**: `realtime/unit/RTP18a/startsync-sets-flag-0` + **Spec requirement:** A new sync has started. The client library must track that a sync is in progress. @@ -58,6 +60,8 @@ ASSERT map.isSyncInProgress == true ## RTP18b - endSync clears isSyncInProgress +**Test ID**: `realtime/unit/RTP18b/endsync-clears-flag-0` + **Spec requirement:** The sync operation has completed once the cursor is empty. ### Setup @@ -82,6 +86,8 @@ ASSERT map.isSyncInProgress == false ## RTP19 - Stale members get LEAVE events after sync +**Test ID**: `realtime/unit/RTP19/stale-members-leave-after-sync-0` + **Spec requirement:** If the PresenceMap has existing members when a SYNC is started, members no longer present on the channel are removed from the local PresenceMap once the sync is complete. A LEAVE event should be emitted for each removed member. @@ -124,6 +130,8 @@ ASSERT map.get("c2:bob") IS null ## RTP19 - Synthesized LEAVE has id=null and current timestamp +**Test ID**: `realtime/unit/RTP19/synth-leave-null-id-timestamp-1` + **Spec requirement:** The PresenceMessage emitted should contain the original attributes of the presence member with the action set to LEAVE, PresenceMessage#id set to null, and the timestamp set to the current time. @@ -171,6 +179,8 @@ ASSERT leave.timestamp <= after_time ## RTP19 - Members updated during sync survive +**Test ID**: `realtime/unit/RTP19/updated-members-survive-sync-2` + **Spec requirement:** A member can be added or updated when received in a SYNC message or when received in a PRESENCE message during the sync process. Members that have been added or updated should NOT be removed. @@ -217,6 +227,8 @@ ASSERT map.get("c2:bob").data == "new-data" ## RTP18a - New sync discards previous in-flight sync +**Test ID**: `realtime/unit/RTP18a/new-sync-discards-previous-1` + **Spec requirement:** If a new sequence identifier is sent from Ably, then the client library must consider that to be the start of a new sync sequence and any previous in-flight sync should be discarded. @@ -261,6 +273,8 @@ ASSERT map.get("c2:bob") IS NOT null ## RTP18c - Single-message sync (no channelSerial) +**Test ID**: `realtime/unit/RTP18c/single-message-sync-0` + **Spec requirement:** A SYNC may also be sent with no channelSerial attribute. In this case, the sync data is entirely contained within that ProtocolMessage. This is modeled as a startSync + put + endSync in one step. @@ -298,6 +312,8 @@ ASSERT map.isSyncInProgress == false ## RTP19a - ATTACHED without HAS_PRESENCE clears all members +**Test ID**: `realtime/unit/RTP19a/no-has-presence-clears-members-0` + **Spec requirement:** If the PresenceMap has existing members when an ATTACHED message is received without a HAS_PRESENCE flag, emit a LEAVE event for each existing member and remove all members from the PresenceMap. @@ -357,6 +373,8 @@ ASSERT map.values().length == 0 ## RTP2h2a - LEAVE during sync stored as ABSENT (in sync context) +**Test ID**: `realtime/unit/RTP2h2a/leave-during-sync-absent-cleanup-0` + **Spec requirement:** If a SYNC is in progress and a LEAVE message is received, store the member with action set to ABSENT. On endSync, ABSENT members are deleted (RTP2h2b). @@ -410,6 +428,8 @@ ASSERT map.get("c1:alice") IS NOT null ## RTP19 - Empty map sync produces no leave events +**Test ID**: `realtime/unit/RTP19/empty-map-sync-no-leaves-3` + **Spec requirement:** If there are no existing members when sync starts, endSync produces no leave events. @@ -436,6 +456,8 @@ ASSERT map.get("c1:alice") IS NOT null ## RTP18 - endSync without startSync is a no-op +**Test ID**: `realtime/unit/RTP18/endsync-without-startsync-noop-0` + **Spec requirement:** Calling endSync when no sync is in progress should not corrupt the map state. @@ -464,6 +486,8 @@ ASSERT map.isSyncInProgress == false ## RTP19 - Stale SYNC message still removes member from residuals +**Test ID**: `realtime/unit/RTP19/stale-sync-removes-from-residuals-4` + **Spec requirement:** When a member exists from a PRESENCE event and a SYNC starts, a SYNC message arriving with the same or older id for that member is stale (rejected by the newness check). However, the member has been "seen" during sync — it must NOT @@ -507,6 +531,8 @@ ASSERT map.get("c1:alice").data == "original" ## RTP19 - PRESENCE echoes followed by SYNC preserves all members +**Test ID**: `realtime/unit/RTP19/presence-echoes-then-sync-preserves-5` + **Spec requirement:** When a client enters multiple members, the server echoes each as a PRESENCE event. When the server subsequently sends a SYNC containing the same members, all members should survive even though the SYNC messages may have the same @@ -555,6 +581,8 @@ FOR i IN 0..2: ## RTP19 - New member added during sync is not stale +**Test ID**: `realtime/unit/RTP19/new-member-during-sync-survives-6` + **Spec requirement:** A member can be added during the sync process. New members that did not exist before the sync should survive endSync. diff --git a/uts/realtime/unit/presence/realtime_presence_channel_state.md b/uts/realtime/unit/presence/realtime_presence_channel_state.md index b32d5cb0f..6a54a2aff 100644 --- a/uts/realtime/unit/presence/realtime_presence_channel_state.md +++ b/uts/realtime/unit/presence/realtime_presence_channel_state.md @@ -16,6 +16,8 @@ channel state effects on queued presence actions (RTL11). ## RTP1 - HAS_PRESENCE flag triggers sync +**Test ID**: `realtime/unit/RTP1/has-presence-triggers-sync-0` + **Spec requirement:** When a channel ATTACHED ProtocolMessage is received with the HAS_PRESENCE flag set, the server will perform a SYNC operation. If the flag is 0 or absent, the presence map should be considered in sync immediately with no members. @@ -73,6 +75,8 @@ CLOSE_CLIENT(client) ## RTP1 - No HAS_PRESENCE flag means empty presence +**Test ID**: `realtime/unit/RTP1/no-has-presence-empty-1` + **Spec requirement:** If the flag is 0 or absent, the presence map should be considered in sync immediately with no members present on the channel. @@ -118,6 +122,8 @@ CLOSE_CLIENT(client) ## RTP1, RTP19a - No HAS_PRESENCE clears existing members +**Test ID**: `realtime/unit/RTP1/no-has-presence-clears-existing-2` + **Spec requirement (RTP19a):** If the PresenceMap has existing members when an ATTACHED message is received without a HAS_PRESENCE flag, emit a LEAVE event for each existing member and remove all members from the PresenceMap. @@ -214,6 +220,8 @@ CLOSE_CLIENT(client) ## RTP5a - DETACHED clears both presence maps +**Test ID**: `realtime/unit/RTP5a/detached-clears-presence-maps-0` + **Spec requirement:** If the channel enters the DETACHED state, all queued presence messages fail immediately, and both the PresenceMap and internal PresenceMap (RTP17) are cleared. LEAVE events should NOT be emitted when clearing. @@ -286,6 +294,8 @@ CLOSE_CLIENT(client) ## RTP5a - FAILED clears both presence maps +**Test ID**: `realtime/unit/RTP5a/failed-clears-presence-maps-1` + **Spec requirement:** Same as DETACHED — FAILED state clears both maps, no LEAVE emitted. ### Setup @@ -353,6 +363,8 @@ CLOSE_CLIENT(client) ## RTP5b - ATTACHED sends queued presence messages +**Test ID**: `realtime/unit/RTP5b/attached-sends-queued-presence-0` + **Spec requirement:** If a channel enters the ATTACHED state then all queued presence messages will be sent immediately. @@ -412,6 +424,8 @@ CLOSE_CLIENT(client) ## RTP5f - SUSPENDED maintains presence map +**Test ID**: `realtime/unit/RTP5f/suspended-maintains-presence-map-0` + **Spec requirement:** If the channel enters SUSPENDED, all queued presence messages fail immediately, but the PresenceMap is maintained. This ensures that when the channel later becomes ATTACHED, it will only emit presence events for changes that occurred while @@ -476,6 +490,8 @@ CLOSE_CLIENT(client) ## RTP13 - syncComplete attribute +**Test ID**: `realtime/unit/RTP13/sync-complete-attribute-0` + **Spec requirement:** RealtimePresence#syncComplete is true if the initial SYNC operation has completed for the members present on the channel. @@ -540,6 +556,8 @@ CLOSE_CLIENT(client) ## RTL9, RTL9a - RealtimeChannel#presence attribute +**Test ID**: `realtime/unit/RTL9/presence-attribute-0` + **Spec requirement (RTL9):** `RealtimeChannel#presence` attribute. **Spec requirement (RTL9a):** Returns the `RealtimePresence` object for this channel. @@ -580,6 +598,8 @@ CLOSE_CLIENT(client) ## RTL11 - Queued presence actions fail on DETACHED +**Test ID**: `realtime/unit/RTL11/queued-presence-fail-detached-0` + **Spec requirement (RTL11):** If a channel enters the DETACHED, SUSPENDED or FAILED state, then all presence actions that are still queued for send on that channel per RTP16b should be deleted from the queue, and any callback passed to the corresponding @@ -644,6 +664,8 @@ CLOSE_CLIENT(client) ## RTL11 - Queued presence actions fail on SUSPENDED +**Test ID**: `realtime/unit/RTL11/queued-presence-fail-suspended-1` + ### Setup ```pseudo channel_name = "test-RTL11-suspended-${random_id()}" @@ -702,6 +724,8 @@ CLOSE_CLIENT(client) ## RTL11 - Queued presence actions fail on FAILED +**Test ID**: `realtime/unit/RTL11/queued-presence-fail-failed-2` + ### Setup ```pseudo channel_name = "test-RTL11-failed-${random_id()}" @@ -761,6 +785,8 @@ CLOSE_CLIENT(client) ## RTL11a - ACK/NACK unaffected by channel state changes +**Test ID**: `realtime/unit/RTL11a/ack-nack-unaffected-by-state-0` + **Spec requirement (RTL11a):** For clarity, any messages awaiting an ACK or NACK are unaffected by channel state changes i.e. a channel that becomes detached following an explicit request to detach may still receive an ACK or NACK for messages published on diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md index b15c13863..23fadf70e 100644 --- a/uts/realtime/unit/presence/realtime_presence_enter.md +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -23,6 +23,8 @@ mismatch check (RTP15f), or configure the mock to accept any clientId. ## RTP8a, RTP8c - enter sends PRESENCE with ENTER action +**Test ID**: `realtime/unit/RTP8a/enter-sends-presence-enter-0` + **Spec requirement:** Enters the current client into this channel. A PRESENCE ProtocolMessage with a PresenceMessage with action ENTER is sent. The clientId attribute of the PresenceMessage must not be present (implicitly uses the connection's @@ -81,6 +83,8 @@ CLOSE_CLIENT(client) ## RTP8e - enter with data +**Test ID**: `realtime/unit/RTP8e/enter-with-data-0` + **Spec requirement:** Optional data can be included when entering. Data will be encoded and decoded as with normal messages. @@ -129,6 +133,8 @@ CLOSE_CLIENT(client) ## RTP8d - enter implicitly attaches channel +**Test ID**: `realtime/unit/RTP8d/enter-implicitly-attaches-0` + **Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the INITIALIZED state. @@ -175,6 +181,8 @@ CLOSE_CLIENT(client) ## RTP8g - enter on DETACHED or FAILED channel errors +**Test ID**: `realtime/unit/RTP8g/enter-detached-failed-errors-0` + **Spec requirement:** If the channel is DETACHED or FAILED, the enter request results in an error immediately. @@ -224,6 +232,8 @@ CLOSE_CLIENT(client) ## RTP8j - enter with wildcard or null clientId errors +**Test ID**: `realtime/unit/RTP8j/enter-null-clientid-errors-0` + **Spec requirement:** If the connection is CONNECTED and the clientId is '*' (wildcard) or null (anonymous), the enter request results in an error immediately. @@ -266,6 +276,8 @@ CLOSE_CLIENT(client) ## RTP8j - enter with wildcard clientId errors +**Test ID**: `realtime/unit/RTP8j/enter-wildcard-clientid-errors-1` + ### Setup Note: Some SDKs may reject wildcard clientId `"*"` at the `ClientOptions` @@ -310,6 +322,8 @@ CLOSE_CLIENT(client) ## RTP8h - NACK for missing presence permission +**Test ID**: `realtime/unit/RTP8h/nack-presence-permission-denied-0` + **Spec requirement:** If the Ably service determines that the client does not have required presence permission, a NACK is sent resulting in an error. @@ -358,6 +372,8 @@ CLOSE_CLIENT(client) ## RTP9a, RTP9d - update sends PRESENCE with UPDATE action +**Test ID**: `realtime/unit/RTP9a/update-sends-presence-update-0` + **Spec requirement:** Updates the data for the present member. A PRESENCE ProtocolMessage with action UPDATE is sent. The clientId must not be present. @@ -407,6 +423,8 @@ CLOSE_CLIENT(client) ## RTP10a, RTP10c - leave sends PRESENCE with LEAVE action +**Test ID**: `realtime/unit/RTP10a/leave-sends-presence-leave-0` + **Spec requirement:** Leaves this client from the channel. A PRESENCE ProtocolMessage with action LEAVE is sent. The clientId must not be present. @@ -455,6 +473,8 @@ CLOSE_CLIENT(client) ## RTP10a - leave with data updates the member data +**Test ID**: `realtime/unit/RTP10a/leave-with-data-1` + **Spec requirement:** The data will be updated with the values provided when leaving. ### Setup @@ -499,6 +519,8 @@ CLOSE_CLIENT(client) ## RTP14a - enterClient enters on behalf of another clientId +**Test ID**: `realtime/unit/RTP14a/enterclient-on-behalf-0` + **Spec requirement:** Enters into presence on a channel on behalf of another clientId. This allows a single client with suitable permissions to register presence on behalf of any number of clients using a single connection. @@ -557,6 +579,8 @@ CLOSE_CLIENT(client) ## RTP15a - updateClient and leaveClient +**Test ID**: `realtime/unit/RTP15a/updateclient-leaveclient-0` + **Spec requirement:** Performs update or leave for a given clientId. Functionally equivalent to the corresponding enter, update, and leave methods. @@ -617,6 +641,8 @@ CLOSE_CLIENT(client) ## RTP15e - enterClient implicitly attaches channel +**Test ID**: `realtime/unit/RTP15e/enterclient-implicitly-attaches-0` + **Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the INITIALIZED state. If the channel is in or enters the DETACHED or FAILED state, error. @@ -662,6 +688,8 @@ CLOSE_CLIENT(client) ## RTP15f - enterClient with mismatched clientId errors +**Test ID**: `realtime/unit/RTP15f/enterclient-mismatched-clientid-0` + **Spec requirement:** If the client is identified and has a valid clientId, and the clientId argument does not match the client's clientId, then it should indicate an error. @@ -723,6 +751,8 @@ CLOSE_CLIENT(client) ## RTP16a - Presence message sent when channel is ATTACHED +**Test ID**: `realtime/unit/RTP16a/presence-sent-when-attached-0` + **Spec requirement:** If the channel is ATTACHED then presence messages are sent immediately to the connection. @@ -768,6 +798,8 @@ CLOSE_CLIENT(client) ## RTP16b - Presence message queued when channel is ATTACHING +**Test ID**: `realtime/unit/RTP16b/presence-queued-when-attaching-0` + **Spec requirement:** If the channel is ATTACHING or INITIALIZED and queueMessages is true, presence messages are queued at channel level, sent once channel becomes ATTACHED. @@ -826,6 +858,8 @@ CLOSE_CLIENT(client) ## RTP16c - Presence message errors in other channel states +**Test ID**: `realtime/unit/RTP16c/presence-errors-other-states-0` + **Spec requirement:** In any other case (channel not ATTACHED, ATTACHING, or INITIALIZED with queueMessages) the operation should result in an error. @@ -873,6 +907,8 @@ CLOSE_CLIENT(client) ## RTP15c - enterClient has no side effects on normal enter +**Test ID**: `realtime/unit/RTP15c/enterclient-no-side-effects-0` + **Spec requirement:** Using enterClient, updateClient, and leaveClient methods should have no side effects on a client that has entered normally using enter. @@ -942,6 +978,8 @@ CLOSE_CLIENT(client) ## RTP4 - 50 members via enterClient (same connection) +**Test ID**: `realtime/unit/RTP4/bulk-enterclient-same-connection-0` + **Spec requirement:** Ensure a test exists that enters 250 members using RealtimePresence#enterClient on a single connection, and checks for PRESENT events to be emitted for each member, and once sync is complete, all members should be @@ -1064,6 +1102,8 @@ CLOSE_CLIENT(client) ## RTP4 - 50 members via enterClient (different connections) +**Test ID**: `realtime/unit/RTP4/bulk-enterclient-diff-connections-1` + **Spec requirement:** Same as above, but the original intent: one connection enters members, a different connection observes the ENTER events and verifies all members via get(). This is the more realistic scenario where one client populates presence diff --git a/uts/realtime/unit/presence/realtime_presence_get.md b/uts/realtime/unit/presence/realtime_presence_get.md index a85d81287..6ecc72873 100644 --- a/uts/realtime/unit/presence/realtime_presence_get.md +++ b/uts/realtime/unit/presence/realtime_presence_get.md @@ -16,6 +16,8 @@ error behaviour for SUSPENDED channels. ## RTP11a - get returns current members (single-message sync) +**Test ID**: `realtime/unit/RTP11a/get-returns-members-single-sync-0` + **Spec requirement:** Returns the list of current members on the channel. By default, will wait for the SYNC to be completed. @@ -86,6 +88,8 @@ CLOSE_CLIENT(client) ## RTP11a, RTP11c1 - get waits for multi-message sync +**Test ID**: `realtime/unit/RTP11a/get-waits-for-multi-sync-1` + **Spec requirement:** When waitForSync is true (default), the method will wait until SYNC is complete before returning a list of members. A multi-message sync has a non-empty cursor in the first message and an empty cursor in the final message. @@ -166,6 +170,8 @@ CLOSE_CLIENT(client) ## RTP11c1 - get with waitForSync=false returns immediately +**Test ID**: `realtime/unit/RTP11c1/get-no-wait-returns-immediately-0` + **Spec requirement:** When waitForSync is false, the known set of presence members is returned immediately, which may be incomplete if the SYNC is not finished. @@ -224,6 +230,8 @@ CLOSE_CLIENT(client) ## RTP11c2 - get filtered by clientId +**Test ID**: `realtime/unit/RTP11c2/get-filtered-by-clientid-0` + **Spec requirement:** clientId param filters members by the provided clientId. ### Setup @@ -279,6 +287,8 @@ CLOSE_CLIENT(client) ## RTP11c3 - get filtered by connectionId +**Test ID**: `realtime/unit/RTP11c3/get-filtered-by-connectionid-0` + **Spec requirement:** connectionId param filters members by the provided connectionId. ### Setup @@ -334,6 +344,8 @@ CLOSE_CLIENT(client) ## RTP11b - get implicitly attaches channel +**Test ID**: `realtime/unit/RTP11b/get-implicitly-attaches-0` + **Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the INITIALIZED state. If the channel enters DETACHED or FAILED before the operation succeeds, error. @@ -380,6 +392,8 @@ CLOSE_CLIENT(client) ## RTP11d - get on SUSPENDED channel errors by default +**Test ID**: `realtime/unit/RTP11d/get-suspended-errors-default-0` + > **Reaching SUSPENDED state:** To transition a channel to SUSPENDED, the connection > must first reach SUSPENDED state (by exhausting all reconnection attempts within > `connectionStateTtl`). RTL3c then transitions ATTACHED channels to SUSPENDED. @@ -453,6 +467,8 @@ CLOSE_CLIENT(client) ## RTP11d - get on SUSPENDED channel with waitForSync=false returns members +**Test ID**: `realtime/unit/RTP11d/get-suspended-no-wait-returns-1` + **Spec requirement:** If waitForSync is false on a SUSPENDED channel, return the members currently in the PresenceMap. diff --git a/uts/realtime/unit/presence/realtime_presence_history.md b/uts/realtime/unit/presence/realtime_presence_history.md index 5bf1823d6..61909e7cc 100644 --- a/uts/realtime/unit/presence/realtime_presence_history.md +++ b/uts/realtime/unit/presence/realtime_presence_history.md @@ -14,6 +14,8 @@ It supports the same parameters as `RestPresence#history` and returns a `Paginat ## RTP12a - history supports same params as RestPresence#history +**Test ID**: `realtime/unit/RTP12a/history-supports-rest-params-0` + **Spec requirement:** Supports all the same params as RestPresence#history. ### Setup @@ -75,6 +77,8 @@ CLOSE_CLIENT(client) ## RTP12c - history returns PaginatedResult +**Test ID**: `realtime/unit/RTP12c/history-returns-paginated-result-0` + **Spec requirement:** Returns a PaginatedResult page containing the first page of messages in the PaginatedResult#items attribute. diff --git a/uts/realtime/unit/presence/realtime_presence_reentry.md b/uts/realtime/unit/presence/realtime_presence_reentry.md index b8a0fb360..dc5238baf 100644 --- a/uts/realtime/unit/presence/realtime_presence_reentry.md +++ b/uts/realtime/unit/presence/realtime_presence_reentry.md @@ -22,6 +22,8 @@ for the LocalPresenceMap to contain any members for re-entry. ## RTP17i - Automatic re-entry on ATTACHED (non-RESUMED) +**Test ID**: `realtime/unit/RTP17i/auto-reentry-on-attached-0` + **Spec requirement:** The RealtimePresence object should perform automatic re-entry whenever the channel receives an ATTACHED ProtocolMessage, except in the case where the channel is already attached and the ProtocolMessage has the RESUMED bit flag set. @@ -111,6 +113,8 @@ CLOSE_CLIENT(client) ## RTP17g - Re-entry publishes ENTER with stored clientId and data +**Test ID**: `realtime/unit/RTP17g/reentry-publishes-enter-with-data-0` + **Spec requirement:** For each member of the RTP17 internal PresenceMap, publish a PresenceMessage with an ENTER action using the clientId, data, and id attributes from that member. @@ -213,6 +217,8 @@ CLOSE_CLIENT(client) ## RTP17g1 - Re-entry omits id when connectionId changed +**Test ID**: `realtime/unit/RTP17g1/reentry-omits-id-new-connid-0` + **Spec requirement:** If the current connection id is different from the connectionId attribute of the stored member, the published PresenceMessage must not have its id set. @@ -301,6 +307,8 @@ CLOSE_CLIENT(client) ## RTP17i - No re-entry when ATTACHED with RESUMED flag +**Test ID**: `realtime/unit/RTP17i/no-reentry-with-resumed-flag-1` + **Spec requirement:** Automatic re-entry is NOT performed when the channel is already attached and the ProtocolMessage has the RESUMED bit flag set. @@ -376,6 +384,8 @@ CLOSE_CLIENT(client) ## RTP17e - Failed re-entry emits UPDATE with error +**Test ID**: `realtime/unit/RTP17e/failed-reentry-emits-update-error-0` + **Spec requirement:** If an automatic presence ENTER fails (e.g., NACK), emit an UPDATE event on the channel with resumed=true and reason set to ErrorInfo with code 91004, message indicating the failure and clientId, and cause set to the NACK error. @@ -480,6 +490,8 @@ CLOSE_CLIENT(client) ## RTP17a - Server publishes member regardless of subscribe capability +**Test ID**: `realtime/unit/RTP17a/server-publishes-without-subscribe-0` + **Spec requirement:** All members belonging to the current connection are published as a PresenceMessage on the channel by the server irrespective of whether the client has permission to subscribe. The member should be present in both the internal and public diff --git a/uts/realtime/unit/presence/realtime_presence_subscribe.md b/uts/realtime/unit/presence/realtime_presence_subscribe.md index 29418565c..e4aa90071 100644 --- a/uts/realtime/unit/presence/realtime_presence_subscribe.md +++ b/uts/realtime/unit/presence/realtime_presence_subscribe.md @@ -16,6 +16,8 @@ channel depending on the `attachOnSubscribe` channel option. ## RTP6a - Subscribe to all presence events +**Test ID**: `realtime/unit/RTP6a/subscribe-all-presence-events-0` + **Spec requirement:** Subscribe with a single listener argument subscribes a listener to all presence messages. @@ -94,6 +96,8 @@ CLOSE_CLIENT(client) ## RTP6b - Subscribe filtered by action +**Test ID**: `realtime/unit/RTP6b/subscribe-filtered-by-action-0` + **Spec requirement:** Subscribe with an action argument and a listener subscribes the listener to receive only presence messages with that action. @@ -162,6 +166,8 @@ CLOSE_CLIENT(client) ## RTP6b - Subscribe filtered by multiple actions +**Test ID**: `realtime/unit/RTP6b/subscribe-filtered-multiple-actions-1` + **Spec requirement:** The action argument may also be an array of actions. ### Setup @@ -217,6 +223,8 @@ CLOSE_CLIENT(client) ## RTP6d - Subscribe implicitly attaches channel +**Test ID**: `realtime/unit/RTP6d/subscribe-implicitly-attaches-0` + **Spec requirement:** If the `attachOnSubscribe` channel option is true (default), implicitly attach the RealtimeChannel if the channel is in the INITIALIZED, DETACHING, or DETACHED states. @@ -265,6 +273,8 @@ CLOSE_CLIENT(client) ## RTP6e - Subscribe with attachOnSubscribe=false does not attach +**Test ID**: `realtime/unit/RTP6e/subscribe-no-attach-option-0` + **Spec requirement:** If the `attachOnSubscribe` channel option is false, do not implicitly attach. @@ -309,6 +319,8 @@ CLOSE_CLIENT(client) ## RTP7c - Unsubscribe all listeners +**Test ID**: `realtime/unit/RTP7c/unsubscribe-all-listeners-0` + **Spec requirement:** Unsubscribe with no arguments unsubscribes all listeners. ### Setup @@ -377,6 +389,8 @@ CLOSE_CLIENT(client) ## RTP7a - Unsubscribe specific listener +**Test ID**: `realtime/unit/RTP7a/unsubscribe-specific-listener-0` + **Spec requirement:** Unsubscribe with a single listener argument unsubscribes that specific listener. @@ -436,6 +450,8 @@ CLOSE_CLIENT(client) ## RTP7b - Unsubscribe listener for specific action +**Test ID**: `realtime/unit/RTP7b/unsubscribe-for-specific-action-0` + **Spec requirement:** Unsubscribe with an action argument and a listener unsubscribes the listener for that action only. @@ -495,6 +511,8 @@ CLOSE_CLIENT(client) ## RTP6 - Presence events update the PresenceMap +**Test ID**: `realtime/unit/RTP6/presence-events-update-map-0` + **Spec requirement:** Incoming presence messages are applied to the PresenceMap (RTP2) before being emitted to subscribers. @@ -549,6 +567,8 @@ CLOSE_CLIENT(client) ## RTP6 - Multiple presence messages in single ProtocolMessage +**Test ID**: `realtime/unit/RTP6/multiple-presence-in-single-message-1` + **Spec requirement:** A PRESENCE ProtocolMessage may contain multiple PresenceMessages. ### Setup diff --git a/uts/rest/integration/auth.md b/uts/rest/integration/auth.md index 393dd9580..20b93dde9 100644 --- a/uts/rest/integration/auth.md +++ b/uts/rest/integration/auth.md @@ -15,13 +15,13 @@ JWT should be the primary token format. See README for details. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -29,7 +29,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -37,6 +37,8 @@ AFTER ALL TESTS: ## RSA4 - Basic auth with API key +**Test ID**: `rest/integration/RSA4/basic-auth-key-0` + **Spec requirement:** RSA4 - Client can authenticate using an API key via HTTP Basic Auth. Tests that API key authentication works against real server. @@ -46,7 +48,7 @@ Tests that API key authentication works against real server. channel_name = "test-RSA4-" + random_id() client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -66,6 +68,8 @@ ASSERT result.statusCode >= 200 AND result.statusCode < 300 ## RSA8 - Token auth with JWT +**Test ID**: `rest/integration/RSA8/token-auth-jwt-0` + **Spec requirement:** RSA8 - Client can authenticate using a JWT token. Tests authentication using a JWT token. @@ -82,7 +86,7 @@ jwt = generate_jwt( channel_name = "test-RSA8-jwt-" + random_id() client = Rest(options: ClientOptions( token: jwt, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -100,6 +104,8 @@ ASSERT result.statusCode >= 200 AND result.statusCode < 300 ## RSA8 - Token auth with native token +**Test ID**: `rest/integration/RSA8/token-auth-native-1` + **Spec requirement:** RSA8 - Client can authenticate using an Ably native token obtained via `requestToken()`. Tests obtaining a native token and using it for authentication. @@ -109,7 +115,7 @@ Tests obtaining a native token and using it for authentication. # First client with API key to obtain token key_client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -122,7 +128,7 @@ token_details = AWAIT key_client.auth.requestToken() channel_name = "test-RSA8-native-" + random_id() token_client = Rest(options: ClientOptions( token: token_details.token, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Verify token works @@ -141,6 +147,8 @@ ASSERT result.statusCode >= 200 AND result.statusCode < 300 ## RSA8 - authCallback with TokenRequest +**Test ID**: `rest/integration/RSA8/auth-callback-token-request-2` + **Spec requirement:** RSA8 - Client can use `authCallback` to obtain authentication via `TokenRequest`. Tests using an `authCallback` that returns a `TokenRequest`, which is then exchanged for a token. @@ -150,7 +158,7 @@ Tests using an `authCallback` that returns a `TokenRequest`, which is then excha # Client that generates token requests token_request_client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # authCallback that creates and returns a TokenRequest @@ -160,7 +168,7 @@ auth_callback = FUNCTION(params): channel_name = "test-RSA8-callback-" + random_id() client = Rest(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -178,6 +186,8 @@ ASSERT result.statusCode >= 200 AND result.statusCode < 300 ## RSA8 - authCallback with JWT +**Test ID**: `rest/integration/RSA8/auth-callback-jwt-3` + **Spec requirement:** RSA8 - Client can use `authCallback` to obtain JWT tokens dynamically. Tests using an `authCallback` that returns a JWT. @@ -195,7 +205,7 @@ auth_callback = FUNCTION(params): channel_name = "test-RSA8-jwt-callback-" + random_id() client = Rest(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -213,6 +223,8 @@ ASSERT result.statusCode >= 200 AND result.statusCode < 300 ## RSA4 - Invalid credentials rejected +**Test ID**: `rest/integration/RSA4/invalid-credentials-rejected-1` + **Spec requirement:** RSA4 - Server rejects requests with invalid API key credentials. Tests that invalid API keys are rejected by the server. @@ -227,7 +239,7 @@ invalid_key = app_id + ".invalidKey:invalidSecret" client = Rest(options: ClientOptions( key: invalid_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -242,6 +254,8 @@ ASSERT result.errorCode == 40400 ## RSC10 - Token renewal with expired JWT +**Test ID**: `rest/integration/RSC10/token-renewal-expired-jwt-0` + **Spec requirement:** RSC10 - When a REST request fails with a token error (40140-40149), the client should automatically renew the token and retry the request. Tests that an expired JWT triggers automatic token renewal via authCallback. @@ -271,7 +285,7 @@ auth_callback = FUNCTION(params): channel_name = "test-RSC10-renewal-" + random_id() client = Rest(options: ClientOptions( authCallback: auth_callback, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -294,6 +308,8 @@ ASSERT callback_count == 2 ## RSA8 - Capability restriction +**Test ID**: `rest/integration/RSA8/capability-restriction-4` + **Spec requirement:** RSA8 - Tokens with restricted capabilities should only allow the permitted operations. Tests that a JWT with restricted capability is enforced by the server. @@ -313,7 +329,7 @@ jwt = generate_jwt( client = Rest(options: ClientOptions( token: jwt, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` diff --git a/uts/rest/integration/batch_presence.md b/uts/rest/integration/batch_presence.md index a26907066..85d1d6ba9 100644 --- a/uts/rest/integration/batch_presence.md +++ b/uts/rest/integration/batch_presence.md @@ -5,6 +5,13 @@ Spec points: `RSC24`, `BGR2`, `BGF2` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Purpose End-to-end verification of `RestClient#batchPresence` against the Ably sandbox. @@ -17,21 +24,31 @@ and failure results. ## Server Response Format -The Ably server returns batch presence in two formats depending on success: - -- **All success (HTTP 200):** Body is a **plain array** of per-channel results: - `[{"channel": "ch1", "presence": [...]}, {"channel": "ch2", "presence": [...]}]` +With `X-Ably-Version >= 3` (sent by all current SDKs), the Ably server returns a +`BatchResult` envelope for all batch presence responses: + +```json +{ + "successCount": 2, + "failureCount": 0, + "results": [ + {"channel": "ch1", "presence": [...]}, + {"channel": "ch2", "presence": [...]} + ] +} +``` -- **Mixed success/failure (HTTP 400):** Body is an object with an `error` field - and a `batchResponse` array: - `{"error": {"code": 40020, ...}, "batchResponse": [{"channel": "ch1", "presence": [...]}, {"channel": "ch2", "error": {...}}]}` +Both all-success and mixed success/failure responses return HTTP 200 with this +format. The `successCount`, `failureCount`, and `results` fields are provided by +the server — no client-side computation is needed. -The `successCount` and `failureCount` fields (BAR2a, BAR2b) are computed -client-side from the per-channel results, not returned by the server. +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success (HTTP 200) and `{error, batchResponse}` +for mixed results (HTTP 400). This format is not used by current SDKs. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning @@ -45,7 +62,7 @@ batch presence endpoint. ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -54,7 +71,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {full_access_key} ``` @@ -62,6 +79,8 @@ AFTER ALL TESTS: ## RSC24, BGR2 - batchPresence returns members across multiple channels +**Test ID**: `rest/integration/RSC24/batch-presence-multiple-channels-0` + **Spec requirement:** `batchPresence` sends a GET to `/presence` with a `channels` query parameter and returns a `BatchResult` containing per-channel presence data. Each successful result contains the channel name and an array of `PresenceMessage`. @@ -76,8 +95,8 @@ channel_b_name = "batch-presence-b-" + random_id() realtime = Realtime(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) ``` @@ -99,8 +118,8 @@ AWAIT ch_b.presence.enterClient("user-3", data: "data-b1") # Query via REST batchPresence (keep realtime open so presence persists) rest = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) result = AWAIT rest.batchPresence([channel_a_name, channel_b_name]) @@ -141,13 +160,14 @@ AWAIT realtime.close() ## RSC24, BGF2 - Restricted key returns per-channel failure for unauthorized channels +**Test ID**: `rest/integration/RSC24/restricted-key-channel-failure-1` + **Spec requirement:** When a key lacks capability for a channel, the per-channel result is a `BatchPresenceFailureResult` containing an `ErrorInfo`. Channels the key does have access to return success results in the same batch response. -The server returns HTTP 400 with `{"error": {"code": 40020, ...}, "batchResponse": [...]}` -when the batch contains any per-channel errors. The client extracts the `batchResponse` -array and builds results from it. +The server returns HTTP 200 with `{"successCount": N, "failureCount": M, "results": [...]}` +for all batch responses, including those with per-channel errors. ### Setup ```pseudo @@ -158,8 +178,8 @@ denied_channel = "denied-batch-" + random_id() # Enter members on both channels using the full-access key realtime = Realtime(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) realtime.connect() @@ -181,8 +201,8 @@ AWAIT realtime.close() # Query with restricted key (only has access to "batch-allowed" channel) restricted_rest = Rest(options: ClientOptions( key: restricted_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) result = AWAIT restricted_rest.batchPresence([allowed_channel, denied_channel]) @@ -219,6 +239,8 @@ and the REST client has no persistent connection to close. ## RSC24 - batchPresence with empty channel returns empty presence array +**Test ID**: `rest/integration/RSC24/empty-channel-presence-2` + **Spec requirement:** A channel with no presence members returns a success result with an empty `presence` array. @@ -230,8 +252,8 @@ populated_channel = "batch-populated-" + random_id() # Enter a member on only the populated channel realtime = Realtime(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) realtime.connect() @@ -250,8 +272,8 @@ AWAIT ch.presence.enterClient("someone", data: "here") ```pseudo rest = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) result = AWAIT rest.batchPresence([empty_channel, populated_channel]) diff --git a/uts/rest/integration/history.md b/uts/rest/integration/history.md index ed45afa30..c516d9fd5 100644 --- a/uts/rest/integration/history.md +++ b/uts/rest/integration/history.md @@ -5,15 +5,22 @@ Spec points: `RSL2a`, `RSL2b`, `RSL2b1`, `RSL2b2`, `RSL2b3` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -21,7 +28,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -29,6 +36,8 @@ AFTER ALL TESTS: ## RSL2a - History returns published messages +**Test ID**: `rest/integration/RSL2a/history-returns-messages-0` + **Spec requirement:** RSL2a - `history` returns a `PaginatedResult` containing messages for the channel. Tests that published messages appear in channel history. @@ -37,7 +46,7 @@ Tests that published messages appear in channel history. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "history-test-RSL2a-" + random_id() channel = client.channels.get(channel_name) @@ -82,6 +91,8 @@ ASSERT ALL msg IN history.items: msg.timestamp IS NOT null ## RSL2b1 - History direction forwards +**Test ID**: `rest/integration/RSL2b1/history-direction-forwards-0` + **Spec requirement:** RSL2b1 - `direction` param controls message ordering (forwards = oldest first). Tests that `direction: forwards` returns messages oldest-first. @@ -90,7 +101,7 @@ Tests that `direction: forwards` returns messages oldest-first. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "history-direction-" + random_id() channel = client.channels.get(channel_name) @@ -127,6 +138,8 @@ ASSERT history.items[2].name == "third" ## RSL2b2 - History limit parameter +**Test ID**: `rest/integration/RSL2b2/history-limit-parameter-0` + **Spec requirement:** RSL2b2 - `limit` param restricts the number of messages returned. Tests that `limit` parameter restricts number of returned messages. @@ -135,7 +148,7 @@ Tests that `limit` parameter restricts number of returned messages. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "history-limit-" + random_id() channel = client.channels.get(channel_name) @@ -172,6 +185,8 @@ ASSERT history.items[4].name == "event-6" ## RSL2b3 - History time range parameters +**Test ID**: `rest/integration/RSL2b3/history-time-range-0` + **Spec requirement:** RSL2b3 - `start` and `end` params filter messages by timestamp range. Tests that `start` and `end` parameters filter messages by time. @@ -180,7 +195,7 @@ Tests that `start` and `end` parameters filter messages by time. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "history-timerange-" + random_id() channel = client.channels.get(channel_name) @@ -247,6 +262,8 @@ ASSERT ANY msg IN late_history.items: msg.name STARTS WITH "late" ## RSL2 - History on channel with no messages +**Test ID**: `rest/integration/RSL2/history-empty-channel-0` + **Spec requirement:** RSL2a - `history` returns empty `PaginatedResult` when channel has no messages. Tests that history on an empty channel returns empty result. @@ -255,7 +272,7 @@ Tests that history on an empty channel returns empty result. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Use a fresh channel with no messages channel_name = "history-empty-" + random_id() diff --git a/uts/rest/integration/mutable_messages.md b/uts/rest/integration/mutable_messages.md index c5e5f4715..aee3eebe2 100644 --- a/uts/rest/integration/mutable_messages.md +++ b/uts/rest/integration/mutable_messages.md @@ -5,9 +5,16 @@ Spec points: `RSL1n`, `RSL11`, `RSL14`, `RSL15`, `RSAN1`, `RSAN2`, `RSAN3` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning @@ -16,7 +23,7 @@ Uses `ably-common/test-resources/test-app-setup.json` which provides: ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -24,13 +31,13 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {full_access_key} ``` ### Notes -- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) -- All clients use `endpoint: "sandbox"` +- All clients use `useBinaryProtocol: PROTOCOL == "msgpack"` (see Protocol Variants) +- All clients use `endpoint: "nonprod:sandbox"` - All channel names use the `mutable:` namespace prefix — the test app setup configures the `mutable` namespace with `mutableMessages: true`, which is required for getMessage, updateMessage, deleteMessage, appendMessage, and annotations ### Annotation HTTP Body Format @@ -55,6 +62,8 @@ The SDK's `annotations.publish()` and `annotations.delete()` methods must set th ## RSL1n — publish returns serials from sandbox +**Test ID**: `rest/integration/RSL1n/publish-returns-serials-0` + **Spec requirement:** RSL1n — On success, returns a `PublishResult` containing message serials. Tests that publish returns real serials from the Ably sandbox. @@ -63,8 +72,8 @@ Tests that publish returns real serials from the Ably sandbox. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSL1n-serials-" + random_id() channel = client.channels.get(channel_name) @@ -98,6 +107,8 @@ ASSERT result2.serials[1] != result2.serials[2] ## RSL11 — getMessage retrieves published message +**Test ID**: `rest/integration/RSL11/get-message-by-serial-0` + **Spec requirement:** RSL11 — `getMessage()` retrieves a message by serial. Tests that a published message can be retrieved by its serial. @@ -106,8 +117,8 @@ Tests that a published message can be retrieved by its serial. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSL11-getMessage-" + random_id() channel = client.channels.get(channel_name) @@ -137,6 +148,8 @@ ASSERT msg.timestamp IS NOT null ## RSL15 — updateMessage updates a published message +**Test ID**: `rest/integration/RSL15/update-message-0` + **Spec requirement:** RSL15 — `updateMessage()` sends a PATCH that updates a message. Tests that a published message can be updated and the update is visible via `getMessage()`. @@ -145,8 +158,8 @@ Tests that a published message can be updated and the update is visible via `get ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSL15-update-" + random_id() channel = client.channels.get(channel_name) @@ -190,6 +203,8 @@ ASSERT updated_msg.version.description == "edited content" ## RSL15 — deleteMessage deletes a published message +**Test ID**: `rest/integration/RSL15/delete-message-1` + **Spec requirement:** RSL15 — `deleteMessage()` sends a PATCH that marks a message as deleted. Tests that a published message can be deleted. @@ -198,8 +213,8 @@ Tests that a published message can be deleted. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSL15-delete-" + random_id() channel = client.channels.get(channel_name) @@ -238,6 +253,8 @@ ASSERT deleted_msg.action == MessageAction.MESSAGE_DELETE ## RSL14 — getMessageVersions returns version history +**Test ID**: `rest/integration/RSL14/get-message-versions-0` + **Spec requirement:** RSL14 — `getMessageVersions()` retrieves all versions of a message. Tests that version history contains the original and all updates. @@ -246,8 +263,8 @@ Tests that version history contains the original and all updates. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSL14-versions-" + random_id() channel = client.channels.get(channel_name) @@ -294,6 +311,8 @@ FOR item IN versions.items: ## RSL15 — appendMessage appends to a published message +**Test ID**: `rest/integration/RSL15/append-message-2` + **Spec requirement:** RSL15 — `appendMessage()` sends a PATCH with `MESSAGE_APPEND` action. Tests that a message can be appended to. @@ -302,8 +321,8 @@ Tests that a message can be appended to. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSL15-append-" + random_id() channel = client.channels.get(channel_name) @@ -333,6 +352,8 @@ ASSERT append_result.versionSerial.length > 0 ## RSAN1, RSAN2 — publish and delete annotations on a message +**Test ID**: `rest/integration/RSAN1/annotation-lifecycle-0` + | Spec | Requirement | |------|-------------| | RSAN1 | `RestAnnotations#publish` creates an annotation on a message | @@ -345,8 +366,8 @@ Tests the full annotation lifecycle: create, verify, delete. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSAN-lifecycle-" + random_id() channel = client.channels.get(channel_name) @@ -393,6 +414,8 @@ AWAIT channel.annotations.delete(serial, Annotation( ## RSAN3 — get annotations returns PaginatedResult +**Test ID**: `rest/integration/RSAN3/get-annotations-paginated-0` + **Spec requirement:** RSAN3c — Returns a `PaginatedResult` containing decoded annotations. Tests that multiple annotations can be retrieved as a paginated result. @@ -401,8 +424,8 @@ Tests that multiple annotations can be retrieved as a paginated result. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", - useBinaryProtocol: false + endpoint: "nonprod:sandbox", + useBinaryProtocol: PROTOCOL == "msgpack" )) channel_name = "mutable:test-RSAN3-paginated-" + random_id() channel = client.channels.get(channel_name) diff --git a/uts/rest/integration/pagination.md b/uts/rest/integration/pagination.md index 05cd6d78e..112f10170 100644 --- a/uts/rest/integration/pagination.md +++ b/uts/rest/integration/pagination.md @@ -7,13 +7,13 @@ Integration test against Ably sandbox ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -21,7 +21,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -29,6 +29,8 @@ AFTER ALL TESTS: ## TG1, TG2 - PaginatedResult items and navigation +**Test ID**: `rest/integration/TG1/items-and-navigation-0` + | Spec ID | Requirement | |---------|-------------| | TG1 | `items` property contains array of results for current page | @@ -40,7 +42,7 @@ Tests that `PaginatedResult` contains items and provides navigation methods. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "pagination-basic-" + random_id() channel = client.channels.get(channel_name) @@ -80,6 +82,8 @@ ASSERT page1.isLast() == false ## TG3 - next() retrieves subsequent page +**Test ID**: `rest/integration/TG3/next-retrieves-page-0` + **Spec requirement:** TG3 - `next()` returns a new `PaginatedResult` for the next page of results. Tests that `next()` retrieves the next page of results. @@ -88,7 +92,7 @@ Tests that `next()` retrieves the next page of results. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "pagination-next-" + random_id() channel = client.channels.get(channel_name) @@ -134,6 +138,8 @@ ASSERT all_ids.length == 12 ## TG4 - first() retrieves first page +**Test ID**: `rest/integration/TG4/first-retrieves-page-0` + **Spec requirement:** TG4 - `first()` returns a new `PaginatedResult` for the first page of results. Tests that `first()` returns to the first page of results. @@ -142,7 +148,7 @@ Tests that `first()` returns to the first page of results. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "pagination-first-" + random_id() channel = client.channels.get(channel_name) @@ -180,6 +186,8 @@ FOR i IN 0..first_page.items.length: ## TG5 - Iterate through all pages +**Test ID**: `rest/integration/TG5/iterate-all-pages-0` + **Spec requirement:** TG5 - Pagination methods enable iteration through complete result set. Tests iteration through entire result set using pagination. @@ -188,7 +196,7 @@ Tests iteration through entire result set using pagination. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "pagination-iterate-" + random_id() channel = client.channels.get(channel_name) @@ -236,6 +244,8 @@ FOR i IN 1..message_count: ## TG - next() on last page returns null +**Test ID**: `rest/integration/TG3/next-last-page-null-1` + **Spec requirement:** TG3 - `next()` returns null when called on the last page. Tests behavior when calling `next()` on the last page. @@ -244,7 +254,7 @@ Tests behavior when calling `next()` on the last page. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "pagination-lastnext-" + random_id() channel = client.channels.get(channel_name) diff --git a/uts/rest/integration/presence.md b/uts/rest/integration/presence.md index 03bb2f36d..590430393 100644 --- a/uts/rest/integration/presence.md +++ b/uts/rest/integration/presence.md @@ -5,9 +5,16 @@ Spec points: `RSP1`, `RSP3`, `RSP3a`, `RSP4`, `RSP4b`, `RSP5` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning @@ -19,7 +26,7 @@ Uses `ably-common/test-resources/test-app-setup.json` which provides: ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -27,7 +34,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -57,12 +64,14 @@ The `ably-common/test-resources/test-app-setup.json` includes pre-populated pres ### RSP1_Integration - Access presence from channel +**Test ID**: `rest/integration/RSP1/access-presence-from-channel-0` + **Spec requirement:** RSP1 - `RestPresence` object is accessible via `channel.presence`. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -78,6 +87,8 @@ ASSERT presence IS RestPresence ### RSP3_Integration_1 - Get presence members from fixture channel +**Test ID**: `rest/integration/RSP3/get-presence-members-0` + **Spec requirement:** RSP3 - `get()` returns a `PaginatedResult` containing current presence members. Retrieves the pre-populated presence members from the sandbox fixture channel. @@ -85,7 +96,7 @@ Retrieves the pre-populated presence members from the sandbox fixture channel. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -103,12 +114,14 @@ ASSERT "client_json" IN client_ids ### RSP3_Integration_2 - Get returns PresenceMessage with correct fields +**Test ID**: `rest/integration/RSP3/presence-message-fields-1` + **Spec requirement:** RSP3 - Each item in the result is a `PresenceMessage` with action, clientId, data, and connectionId. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -127,12 +140,14 @@ ASSERT member.connectionId IS NOT null ### RSP3a1_Integration - Get with limit parameter +**Test ID**: `rest/integration/RSP3a1/get-with-limit-0` + **Spec requirement:** RSP3a1 - `limit` param restricts the number of presence members returned. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -148,12 +163,14 @@ IF result.hasNext(): ### RSP3a2_Integration - Get with clientId filter +**Test ID**: `rest/integration/RSP3a2/get-with-clientid-filter-0` + **Spec requirement:** RSP3a2 - `clientId` param filters results to specified client. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -168,12 +185,14 @@ ASSERT result.items[0].data == "{ \"test\": \"This is a JSONObject clientData pa ### RSP3_Integration_Empty - Get on channel with no presence +**Test ID**: `rest/integration/RSP3/get-empty-channel-2` + **Spec requirement:** RSP3 - `get()` returns empty `PaginatedResult` when no members are present. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Use a unique channel name that has no presence members @@ -193,6 +212,8 @@ ASSERT result.hasNext() == false ### RSP4_Integration_1 - History returns presence events +**Test ID**: `rest/integration/RSP4/history-returns-events-0` + **Spec requirement:** RSP4 - `history()` returns a `PaginatedResult` containing presence event history. This test creates presence history by entering and leaving a channel. @@ -200,7 +221,7 @@ This test creates presence history by entering and leaving a channel. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "presence-history-" + random_id() @@ -208,7 +229,7 @@ channel_name = "presence-history-" + random_id() # Use realtime client to generate presence history realtime = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "test-client" )) @@ -240,12 +261,14 @@ ASSERT PresenceAction.leave IN actions ### RSP4b1_Integration - History with start/end time range +**Test ID**: `rest/integration/RSP4b1/history-time-range-0` + **Spec requirement:** RSP4b1 - `start` and `end` params filter history by timestamp range. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "test-client" )) @@ -257,7 +280,7 @@ time_before = now_millis() # Generate presence events via realtime realtime = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "time-test-client" )) @@ -289,12 +312,14 @@ ASSERT history.items.length >= 2 ### RSP4b2_Integration - History direction forwards +**Test ID**: `rest/integration/RSP4b2/history-direction-forwards-0` + **Spec requirement:** RSP4b2 - `direction` param controls event ordering (forwards = oldest first). ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "presence-direction-" + random_id() @@ -302,7 +327,7 @@ channel_name = "presence-direction-" + random_id() # Generate ordered presence events realtime = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "direction-client" )) @@ -336,12 +361,14 @@ ASSERT history_backwards.items[0].data == "third" ### RSP4b3_Integration - History with limit and pagination +**Test ID**: `rest/integration/RSP4b3/history-limit-pagination-0` + **Spec requirement:** RSP4b3 - `limit` param restricts history results and enables pagination. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "presence-limit-" + random_id() @@ -349,7 +376,7 @@ channel_name = "presence-limit-" + random_id() # Generate multiple presence events realtime = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "limit-client" )) @@ -387,12 +414,14 @@ ASSERT page2.items.length >= 1 ### RSP5_Integration_1 - String data decoded correctly +**Test ID**: `rest/integration/RSP5/decode-string-data-0` + **Spec requirement:** RSP5 - Presence message `data` is decoded according to its encoding. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -405,12 +434,14 @@ ASSERT result.items[0].data == "This is a string clientData payload" ### RSP5_Integration_2 - JSON data decoded to object +**Test ID**: `rest/integration/RSP5/decode-json-data-1` + **Spec requirement:** RSP5 - JSON-encoded presence data is decoded to native objects. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel = client.channels.get("persisted:presence_fixtures") @@ -423,12 +454,14 @@ ASSERT result.items[0].data["example"]["json"] == "Object" ### RSP5_Integration_3 - Encrypted data decoded with cipher +**Test ID**: `rest/integration/RSP5/decode-encrypted-data-2` + **Spec requirement:** RSP5 - Encrypted presence data is automatically decrypted when cipher is configured. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) cipher_key = base64_decode("WUP6u0K7MXI5Zeo0VppPwg==") @@ -452,12 +485,14 @@ ASSERT result.items[0].data IS NOT null ### RSP5_Integration_4 - History messages also decoded +**Test ID**: `rest/integration/RSP5/decode-history-messages-3` + **Spec requirement:** RSP5 - Presence history messages are decoded the same way as current presence. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "presence-decode-history-" + random_id() @@ -465,7 +500,7 @@ channel_name = "presence-decode-history-" + random_id() # Generate presence event with JSON data realtime = Realtime(options: ClientOptions( key: api_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", clientId: "decode-client" )) @@ -495,12 +530,14 @@ ASSERT history.items[0].data["number"] == 123 ### RSP_Pagination_Integration - Full pagination through presence members +**Test ID**: `rest/integration/RSP3/full-pagination-3` + **Spec requirement:** RSP3 - Presence `get()` supports pagination through all members. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # The fixture channel has multiple members @@ -531,12 +568,14 @@ ASSERT len(set(client_ids)) == len(client_ids) ### RSP_Error_Integration_1 - Invalid credentials rejected +**Test ID**: `rest/integration/RSP3/invalid-credentials-rejected-4` + **Spec requirement:** RSP3 - Presence operations with invalid credentials return authentication errors. ```pseudo client = Rest(options: ClientOptions( key: "invalid.key:secret", - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) AWAIT client.channels.get("test").presence.get() FAILS WITH error @@ -546,6 +585,8 @@ ASSERT error.code >= 40100 AND error.code < 40200 ### RSP_Error_Integration_2 - Insufficient permissions rejected +**Test ID**: `rest/integration/RSP3/subscribe-capability-sufficient-5` + **Spec requirement:** RSP3 - Presence operations succeed with appropriate capabilities. ```pseudo @@ -554,7 +595,7 @@ restricted_key = app_config.keys[3].key_str client = Rest(options: ClientOptions( key: restricted_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # This should work - subscribe capability is sufficient for presence.get diff --git a/uts/rest/integration/proxy/rest_fallback.md b/uts/rest/integration/proxy/rest_fallback.md new file mode 100644 index 000000000..bd3b13232 --- /dev/null +++ b/uts/rest/integration/proxy/rest_fallback.md @@ -0,0 +1,556 @@ +# REST Fallback Proxy Integration Tests + +Spec points: `RSC15l`, `RSC15l2`, `RSC15l4`, `RSL1k4` + +## Test Type + +Proxy integration test against Ably Sandbox endpoint + +## Proxy Infrastructure + +See `uts/realtime/integration/helpers/proxy.md` for the full proxy infrastructure specification. + +## Corresponding Unit Tests + +- `uts/rest/unit/fallback.md` -- RSC15l/RSC15l4 (unit test verifies fallback logic with mocked HTTP) +- `uts/rest/unit/publish.md` -- RSL1k (unit test verifies idempotent publish logic with mocked HTTP) + +## Purpose + +These tests verify fallback host retry behaviour and HTTP error handling that +cannot be fully tested with mocked HTTP because the `shouldFallback` +classification and error surfacing vary by platform. By exercising the SDK's +real HTTP client through the proxy (or directly against an unreachable +endpoint), we confirm the actual retry and error-parsing behaviour end-to-end. + +## Sandbox Setup + +Tests run against the Ably Sandbox via a programmable proxy. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Common Cleanup + +```pseudo +AFTER EACH TEST: + IF session IS NOT null: + session.close() +``` + +### Token Auth Helper + +```pseudo +function token_auth_callback(api_key): + RETURN (params, cb) => { + # Create a temporary Rest client pointed directly at the sandbox (bypassing the proxy) + # and use it to obtain a TokenDetails object + inner_rest = Rest(options: ClientOptions( + key: api_key, + endpoint: SANDBOX_ENDPOINT + )) + inner_rest.auth.requestToken().then( + (token) => cb(null, token), + (err) => cb(err, null) + ) + } +``` + +Note: The sandbox endpoint is used directly (not through the proxy) so that token requests are never intercepted by proxy fault-injection rules. + +### Fallback Host Configuration + +These tests need fallback hosts enabled. `endpoint: "localhost"` would normally +disable automatic fallback host selection (REC2c2), but explicitly providing +`fallbackHosts: ["localhost"]` overrides this. Both the primary and fallback +requests route through the same proxy, with `times: 1` rules ensuring only the +first request is faulted. + +--- + +## RSC15l2 - Request timeout triggers fallback via proxy + +**Test ID**: `rest/proxy/RSC15l2/timeout-triggers-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l | Errors that necessitate use of an alternative host | +| RSC15l2 | Request timeout triggers fallback | + +Tests that when an HTTP request times out after the connection is established, +the SDK retries on a fallback host. The proxy delays the first HTTP response +beyond the SDK's `httpRequestTimeout`, causing a timeout. The retry goes to +a fallback host (also routed through the proxy) and succeeds. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_delay", + "delayMs": 20000 + }, + "times": 1, + "comment": "RSC15l2: Delay first /time request beyond httpRequestTimeout" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + httpRequestTimeout: 3000 +)) +``` + +### Test Steps + +```pseudo +result = AWAIT client.time() +``` + +### Assertions + +```pseudo +# The request should succeed (retried on fallback after timeout) +ASSERT result IS number + +# Proxy event log shows at least two HTTP requests to /time +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length >= 2 +``` + +--- + +## RSC15l4 - CloudFront Server header triggers fallback via proxy + +**Test ID**: `rest/proxy/RSC15l4/cloudfront-header-fallback-0` + +| Spec | Requirement | +|------|-------------| +| RSC15l4 | A response with a `Server: CloudFront` header and HTTP status >= 400 should trigger fallback | + +Tests that when the proxy returns an HTTP 403 with a `Server: CloudFront` +header, the SDK treats it as a retryable server error and retries on a +fallback host. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 403, + "body": { "error": { "message": "Forbidden", "code": 40300, "statusCode": 403 } }, + "headers": { "Server": "CloudFront" } + }, + "times": 1, + "comment": "RSC15l4: CloudFront 403 on first /time request" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +result = AWAIT client.time() +``` + +### Assertions + +```pseudo +# The request should succeed (retried on fallback after CloudFront error) +ASSERT result IS number + +# Proxy event log shows at least two HTTP requests to /time +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length >= 2 + +# First response was the injected 403 with CloudFront header +http_responses = log.filter(e => e.type == "http_response") +ASSERT http_responses[0].status == 403 +``` + +--- + +## Unreachable endpoint surfaces correct error (no proxy) + +**Test ID**: `rest/proxy/RSC15l/unreachable-endpoint-error-0` + +Tests that when the SDK's HTTP client cannot connect to the target host at all +(ECONNREFUSED), the error is surfaced as a usable ErrorInfo-like object with +status/code information. This test does NOT use the proxy -- it points the SDK +at a port where nothing is listening. + +### Setup + +```pseudo +# No proxy session needed for this test. + +# Pick a port that is not listening (e.g. 19999). +non_listening_port = 19999 + +# Use token auth via authCallback so the SDK can authenticate without +# contacting the dead endpoint. The inner Rest client talks directly to the +# sandbox to obtain a token. +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + port: non_listening_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The error is an ErrorInfo-like object with a statusCode or code +# (the exact code/statusCode depends on the SDK's HTTP layer, but it must +# be present and non-null so callers can programmatically handle it) +ASSERT error IS NOT null +ASSERT error.statusCode IS NOT null OR error.code IS NOT null +``` + +--- + +## Connection drop mid-response retried on fallback (http_drop) + +**Test ID**: `rest/proxy/RSC15l/connection-drop-fallback-1` + +| Spec | Requirement | +|------|-------------| +| RSC15l | Errors that necessitate use of an alternative host | + +Tests that when the proxy drops the TCP connection mid-request (simulating +ECONNRESET), the SDK classifies this as a retryable error and retries on a +fallback host. The proxy drops the first `/time` request, then passes through +on the retry. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_drop" + }, + "times": 1, + "comment": "Drop TCP connection on first /time request (ECONNRESET)" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +result = AWAIT client.time() +``` + +### Assertions + +```pseudo +# The request should succeed (retried on fallback after connection drop) +ASSERT result IS number + +# Proxy event log shows at least two HTTP requests to /time +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length >= 2 +``` + +--- + +## HTTP 5xx with JSON error body -- error parsed correctly (http_respond 503) + +**Test ID**: `rest/proxy/RSC15l/http-5xx-json-error-parsed-0` + +Tests that when the proxy returns an HTTP 503 with a well-formed JSON error +body (containing an `error` object with `code`, `statusCode`, and `message`), +the SDK parses the ErrorInfo fields from the response body. No fallback hosts +are configured, so the error propagates directly to the caller. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 503, + "body": { "error": { "code": 50300, "statusCode": 503, "message": "Service temporarily unavailable" } } + }, + "times": 1, + "comment": "Return 503 with JSON error body on first /time request" + }] +) + +# No fallbackHosts -- endpoint="localhost" disables fallback (REC2c2) +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The SDK parsed the error fields from the JSON response body +ASSERT error.code == 50300 +ASSERT error.statusCode == 503 +ASSERT error.message CONTAINS "Service temporarily unavailable" +``` + +--- + +## HTTP 5xx without JSON error body -- error synthesized (http_respond 503) + +**Test ID**: `rest/proxy/RSC15l/http-5xx-no-json-synthesized-1` + +Tests that when the proxy returns an HTTP 503 with a JSON body that does NOT +contain an `error` field (e.g. `{}`), the SDK still produces a usable error +from the HTTP status code alone. This is the closest the proxy can get to a +non-parseable body while still returning valid JSON. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 503, + "body": {} + }, + "times": 1, + "comment": "Return 503 with empty JSON body (no error field) on first /time request" + }] +) + +# No fallbackHosts -- endpoint="localhost" disables fallback (REC2c2) +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The SDK synthesized an error from the HTTP status code +ASSERT error.statusCode == 503 +``` + +--- + +## HTTP 4xx with JSON error body -- not retried, error parsed (http_respond 403) + +**Test ID**: `rest/proxy/RSC15l/http-4xx-not-retried-0` + +Tests that when the proxy returns an HTTP 403 (a 4xx client error) with a +well-formed JSON error body, the SDK does NOT retry on fallback hosts -- even +when fallback hosts are configured -- and instead propagates the parsed error +directly to the caller. Only 5xx and certain special cases (RSC15l4 CloudFront) +should trigger fallback; 4xx errors indicate a client-side problem. + +### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "pathContains": "/time" }, + "action": { + "type": "http_respond", + "status": 403, + "body": { "error": { "code": 40300, "statusCode": 403, "message": "Forbidden" } } + }, + "times": 1, + "comment": "Return 403 with JSON error body on first /time request" + }] +) + +# Fallback hosts ARE configured -- but 403 should NOT trigger fallback +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false +)) +``` + +### Test Steps + +```pseudo +AWAIT client.time() FAILS WITH error +``` + +### Assertions + +```pseudo +# The SDK parsed the error fields from the JSON response body +ASSERT error.code == 40300 +ASSERT error.statusCode == 403 + +# Proxy event log shows exactly 1 HTTP request to /time (no fallback retry) +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.path CONTAINS "/time") +ASSERT http_requests.length == 1 +``` + +--- + +## RSL1k4 - Idempotent publish retry deduplication + +**Test ID**: `rest/proxy/RSL1k4/idempotent-retry-dedup-0` + +| Spec | Requirement | +|------|-------------| +| RSL1k4 | An explicit test for idempotency of publishes with library-generated ids shall exist that simulates an error response to a successful publish, expects an automatic retry by the library, and verifies that the batch is published only once | + +### Proxy Action + +This test uses the `http_replace_response` proxy action, which forwards the +request to the upstream server (so the publish actually succeeds), discards +the real response, and returns a fake 5xx error response to the client. This +causes the SDK to believe the publish failed and retry it, while the server +already persisted the message. The server then deduplicates the retry based +on the library-generated message `id`. + +#### Setup + +```pseudo +session = create_proxy_session( + endpoint: "nonprod:sandbox", + port: allocated_port, + rules: [{ + "match": { "type": "http_request", "method": "POST", "pathContains": "/channels/" }, + "action": { + "type": "http_replace_response", + "status": 503, + "body": { "error": { "code": 50300, "statusCode": 503, "message": "Service temporarily unavailable" } } + }, + "times": 1, + "comment": "RSL1k4: Forward first publish to server, then return fake 503 to client" + }] +) + +client = Rest(options: ClientOptions( + authCallback: token_auth_callback(api_key), + endpoint: "localhost", + fallbackHosts: ["localhost"], + port: session.proxy_port, + tls: false, + useBinaryProtocol: false, + idempotentRestPublishing: true +)) + +channel_name = "test-RSL1k4-idempotent-" + random_string() +channel = client.channels.get(channel_name) +``` + +#### Test Steps + +```pseudo +# Publish a message -- first attempt succeeds server-side but client sees 503, +# SDK retries, server deduplicates the retry +AWAIT channel.publish("test", "data") +``` + +#### Assertions + +```pseudo +# The publish completed successfully (SDK retried after the fake 503) +# No error thrown + +# Verify via history that only one copy of the message exists +# (server deduplicated the retry based on the library-generated message id) +history = AWAIT channel.history() +matching = history.items.filter(m => m.name == "test" AND m.data == "data") +ASSERT matching.length == 1 + +# Proxy event log shows at least two POST requests to /channels/ +log = session.get_log() +http_requests = log.filter(e => e.type == "http_request" AND e.method == "POST" AND e.path CONTAINS "/channels/") +ASSERT http_requests.length >= 2 +``` diff --git a/uts/rest/integration/publish.md b/uts/rest/integration/publish.md index ca0b3e7ec..612c305e2 100644 --- a/uts/rest/integration/publish.md +++ b/uts/rest/integration/publish.md @@ -5,9 +5,16 @@ Spec points: `RSL1d`, `RSL1l1`, `RSL1m4`, `RSL1n` ## Test Type Integration test against Ably sandbox +## Protocol Variants +json, msgpack + +Each test in this file runs once per protocol variant. The `PROTOCOL` variable +is set to `"json"` or `"msgpack"` for the current run. Client options should set +`useBinaryProtocol: PROTOCOL == "msgpack"`. + ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning @@ -17,7 +24,7 @@ Uses `ably-common/test-resources/test-app-setup.json` which provides: ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -26,7 +33,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {full_access_key} ``` @@ -34,6 +41,8 @@ AFTER ALL TESTS: ## RSL1d - Error indication on publish failure +**Test ID**: `rest/integration/RSL1d/publish-failure-error-0` + **Spec requirement:** RSL1d - Failed publish operations must indicate the error to the caller. Tests that errors are properly indicated when a publish fails due to insufficient permissions. @@ -44,7 +53,7 @@ channel_name = "forbidden-channel-" + random_id() # Not in restricted key's cap restricted_client = Rest(options: ClientOptions( key: restricted_key, # Key without publish capability for this channel - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) restricted_channel = restricted_client.channels.get(channel_name) ``` @@ -60,6 +69,8 @@ ASSERT error.statusCode == 401 ## RSL1n - PublishResult contains serials +**Test ID**: `rest/integration/RSL1n/publish-result-serials-0` + **Spec requirement:** RSL1n - Successful publish returns a `PublishResult` containing message serials. Tests that successful publish returns a result with message serials. @@ -68,7 +79,7 @@ Tests that successful publish returns a result with message serials. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "test-serials-" + random_id() channel = client.channels.get(channel_name) @@ -101,6 +112,8 @@ ASSERT result2.serials ARE all unique ## RSL1k5 - Idempotent publish with client-supplied IDs +**Test ID**: `rest/integration/RSL1k5/idempotent-client-ids-0` + **Spec requirement:** RSL1k5 - Messages with client-supplied IDs are idempotent (duplicate IDs don't create duplicate messages). Tests that multiple publishes with the same client-supplied ID result in single message. @@ -109,7 +122,7 @@ Tests that multiple publishes with the same client-supplied ID result in single ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "idempotent-explicit-" + random_id() channel = client.channels.get(channel_name) @@ -145,6 +158,8 @@ ASSERT history.items[0].data == "data-1" ## RSL1l1 - Publish params with _forceNack +**Test ID**: `rest/integration/RSL1l1/publish-params-force-nack-0` + **Spec requirement:** RSL1l1 - Additional publish params can be supplied and are transmitted to the server. Tests that publish params are correctly transmitted by using the `_forceNack` test param. @@ -153,7 +168,7 @@ Tests that publish params are correctly transmitted by using the `_forceNack` te ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "force-nack-test-" + random_id() channel = client.channels.get(channel_name) @@ -172,6 +187,8 @@ ASSERT error.code == 40099 # Specific code for forced nack ## RSL1m4 - ClientId mismatch rejection +**Test ID**: `rest/integration/RSL1m4/clientid-mismatch-rejected-0` + **Spec requirement:** RSL1m4 - Server rejects messages where clientId doesn't match the authenticated client. Tests that server rejects message with clientId different from authenticated client. @@ -181,7 +198,7 @@ Tests that server rejects message with clientId different from authenticated cli # Create a token with a specific clientId key_client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) token_details = AWAIT key_client.auth.requestToken( @@ -191,7 +208,7 @@ token_details = AWAIT key_client.auth.requestToken( # Client using token with clientId token_client = Rest(options: ClientOptions( token: token_details.token, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) channel_name = "clientid-mismatch-" + random_id() diff --git a/uts/rest/integration/push_admin.md b/uts/rest/integration/push_admin.md index de5cf80aa..c4e3db4ad 100644 --- a/uts/rest/integration/push_admin.md +++ b/uts/rest/integration/push_admin.md @@ -7,7 +7,7 @@ Integration test against Ably sandbox ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning @@ -17,7 +17,7 @@ Uses `ably-common/test-resources/test-app-setup.json` which provides: ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -26,13 +26,13 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {full_access_key} ``` ### Notes - All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) -- All clients use `endpoint: "sandbox"` +- All clients use `endpoint: "nonprod:sandbox"` - Push admin operations require the `push-admin` capability — use `push_admin_key` or `full_access_key` - Device registrations created during tests must be cleaned up to avoid polluting the sandbox @@ -40,6 +40,8 @@ AFTER ALL TESTS: ## RSH1a — publish sends push notification to clientId +**Test ID**: `rest/integration/RSH1a/push-publish-clientid-0` + **Spec requirement:** RSH1a — `publish(recipient, data)` performs an HTTP request to `/push/publish`. Tests that a push notification can be published to a `clientId` recipient. The sandbox accepts the request even though no real device receives it. @@ -48,7 +50,7 @@ Tests that a push notification can be published to a `clientId` recipient. The s ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) ``` @@ -71,6 +73,8 @@ AWAIT client.push.admin.publish( ## RSH1a — publish rejects invalid recipient +**Test ID**: `rest/integration/RSH1a/push-publish-invalid-recipient-1` + **Spec requirement:** RSH1a — Tests should exist with invalid recipient details. Tests that the sandbox returns an error for an empty recipient. @@ -79,7 +83,7 @@ Tests that the sandbox returns an error for an empty recipient. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) ``` @@ -97,6 +101,8 @@ ASSERT error.code IS NOT null ## RSH1b3, RSH1b1 — save and get device registration +**Test ID**: `rest/integration/RSH1b3/save-and-get-device-0` + | Spec | Requirement | |------|-------------| | RSH1b3 | `#save(device)` issues a PUT to register a device | @@ -108,7 +114,7 @@ Tests the full device registration lifecycle: save, then retrieve. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) device_id = "test-device-" + random_id() @@ -151,6 +157,8 @@ AWAIT client.push.admin.deviceRegistrations.remove(device_id) ## RSH1b3 — save updates existing device registration +**Test ID**: `rest/integration/RSH1b3/update-device-registration-1` + **Spec requirement:** RSH1b3 — A test should exist for a successful subsequent save with an update. Tests that saving a device with the same ID updates the existing registration. @@ -159,7 +167,7 @@ Tests that saving a device with the same ID updates the existing registration. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) device_id = "test-device-update-" + random_id() @@ -207,6 +215,8 @@ AWAIT client.push.admin.deviceRegistrations.remove(device_id) ## RSH1b1 — get returns error for unknown device +**Test ID**: `rest/integration/RSH1b1/get-unknown-device-error-0` + **Spec requirement:** RSH1b1 — Results in a not found error if the device cannot be found. Tests that retrieving a nonexistent device returns a not-found error. @@ -215,7 +225,7 @@ Tests that retrieving a nonexistent device returns a not-found error. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) ``` @@ -230,6 +240,8 @@ ASSERT error.statusCode == 404 ## RSH1b2 — list device registrations with filters +**Test ID**: `rest/integration/RSH1b2/list-devices-filtered-0` + **Spec requirement:** RSH1b2 — `#list(params)` returns a paginated result with `DeviceDetails` filtered by params. Tests listing device registrations filtered by `deviceId`. @@ -238,7 +250,7 @@ Tests listing device registrations filtered by `deviceId`. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) device_id = "test-device-list-" + random_id() @@ -276,6 +288,8 @@ AWAIT client.push.admin.deviceRegistrations.remove(device_id) ## RSH1b2 — list supports pagination with limit +**Test ID**: `rest/integration/RSH1b2/list-devices-pagination-1` + **Spec requirement:** RSH1b2 — A test should exist controlling the pagination with the `limit` attribute. Tests that the `limit` parameter restricts the number of results. @@ -284,7 +298,7 @@ Tests that the `limit` parameter restricts the number of results. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) client_id = "test-client-list-" + random_id() @@ -329,6 +343,8 @@ FOR device_id IN device_ids: ## RSH1b4 — remove deletes device registration +**Test ID**: `rest/integration/RSH1b4/remove-device-0` + **Spec requirement:** RSH1b4 — `#remove(deviceId)` deletes the registered device. Tests that a registered device can be removed and is no longer retrievable. @@ -337,7 +353,7 @@ Tests that a registered device can be removed and is no longer retrievable. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) device_id = "test-device-remove-" + random_id() @@ -367,13 +383,15 @@ ASSERT error.statusCode == 404 ## RSH1b4 — remove succeeds for nonexistent device +**Test ID**: `rest/integration/RSH1b4/remove-nonexistent-device-1` + **Spec requirement:** RSH1b4 — Deleting a device that does not exist still succeeds. ### Setup ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) ``` @@ -388,6 +406,8 @@ AWAIT client.push.admin.deviceRegistrations.remove("nonexistent-device-" + rando ## RSH1b5 — removeWhere deletes devices by clientId +**Test ID**: `rest/integration/RSH1b5/remove-where-clientid-0` + **Spec requirement:** RSH1b5 — `#removeWhere(params)` deletes registered devices matching params. Tests that devices can be bulk-removed by `clientId`. @@ -396,7 +416,7 @@ Tests that devices can be bulk-removed by `clientId`. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) client_id = "test-client-removeWhere-" + random_id() @@ -431,6 +451,8 @@ ASSERT result.items.length == 0 ## RSH1c3, RSH1c1 — save and list channel subscriptions +**Test ID**: `rest/integration/RSH1c3/save-and-list-subscriptions-0` + | Spec | Requirement | |------|-------------| | RSH1c3 | `#save(subscription)` creates a channel subscription | @@ -442,7 +464,7 @@ Tests the channel subscription lifecycle: save then list. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) device_id = "test-device-sub-" + random_id() @@ -500,13 +522,15 @@ AWAIT client.push.admin.deviceRegistrations.remove(device_id) ## RSH1c3 — save channel subscription with clientId +**Test ID**: `rest/integration/RSH1c3/save-subscription-clientid-1` + **Spec requirement:** RSH1c3 — A test should exist for saving a `clientId`-based subscription. ### Setup ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) client_id = "test-client-sub-" + random_id() @@ -539,6 +563,8 @@ AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( ## RSH1c2 — listChannels returns channel names with subscriptions +**Test ID**: `rest/integration/RSH1c2/list-channels-with-subscriptions-0` + **Spec requirement:** RSH1c2 — `#listChannels(params)` returns a paginated result with `String` objects. Tests that channels with active subscriptions appear in listChannels. @@ -547,7 +573,7 @@ Tests that channels with active subscriptions appear in listChannels. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) client_id = "test-client-lc-" + random_id() @@ -584,6 +610,8 @@ AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( ## RSH1c4 — remove deletes channel subscription +**Test ID**: `rest/integration/RSH1c4/remove-channel-subscription-0` + **Spec requirement:** RSH1c4 — `#remove(subscription)` deletes a channel subscription using subscription attributes as params. Tests that a subscription can be removed and no longer appears in list results. @@ -592,7 +620,7 @@ Tests that a subscription can be removed and no longer appears in list results. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) client_id = "test-client-rm-" + random_id() @@ -625,13 +653,15 @@ ASSERT result.items.length == 0 ## RSH1c4 — remove succeeds for nonexistent subscription +**Test ID**: `rest/integration/RSH1c4/remove-nonexistent-subscription-1` + **Spec requirement:** RSH1c4 — Deleting a subscription that does not exist still succeeds. ### Setup ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) ``` @@ -649,6 +679,8 @@ AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( ## RSH1c5 — removeWhere deletes subscriptions by clientId +**Test ID**: `rest/integration/RSH1c5/remove-where-subscriptions-0` + **Spec requirement:** RSH1c5 — `#removeWhere(params)` deletes matching channel subscriptions. Tests that subscriptions can be bulk-removed by `clientId`. @@ -657,7 +689,7 @@ Tests that subscriptions can be bulk-removed by `clientId`. ```pseudo client = Rest(options: ClientOptions( key: full_access_key, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) client_id = "test-client-rwsub-" + random_id() diff --git a/uts/rest/integration/push_channels.md b/uts/rest/integration/push_channels.md new file mode 100644 index 000000000..52a152a66 --- /dev/null +++ b/uts/rest/integration/push_channels.md @@ -0,0 +1,187 @@ +# PushChannel Integration Tests + +Spec points: `RSH7a`, `RSH7b`, `RSH7c`, `RSH7d` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox.realtime.ably-nonprod.net/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +### Notes +- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) +- All clients use `endpoint: "nonprod:sandbox"` +- These tests require the platform to support push notifications and the local device to be configurable for push registration. If the sandbox or platform does not support push device registration, these tests should be skipped. +- A device must be registered (via `push.admin.deviceRegistrations.save`) before device-based channel subscriptions can be created +- The `PushChannel` methods operate on behalf of the local device — the `LocalDevice` must be configured to simulate a registered push target device + +--- + +## RSH7a, RSH7c — subscribeDevice and unsubscribeDevice round-trip + +**Test ID**: `rest/integration/RSH7a/subscribe-unsubscribe-device-0` + +| Spec | Requirement | +|------|-------------| +| RSH7a | subscribeDevice() subscribes the local device to push on a channel | +| RSH7a2 | Performs a POST to /push/channelSubscriptions with device id and channel name | +| RSH7c | unsubscribeDevice() unsubscribes the local device from push on a channel | +| RSH7c2 | Performs a DELETE to /push/channelSubscriptions with device id and channel name | + +Tests the full device subscription lifecycle: register a device, subscribe it to a channel via `PushChannel.subscribeDevice()`, verify the subscription exists, then unsubscribe via `PushChannel.unsubscribeDevice()` and verify it is removed. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) + +device_id = "test-device-pushchan-" + random_id() +channel_name = "pushenabled:test-rsh7a-" + random_id() +device_token = "test-apns-token-" + random_id() + +# Register a device via admin API (required before device subscriptions work) +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": device_token } + ) +)) + +# Configure the local device to match the registered device +# The deviceIdentityToken is obtained from the registration response +# For integration testing, we use the admin API to register and then +# configure the LocalDevice with values that allow push device auth +client.device = LocalDevice( + id: device_id, + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Subscribe the device to push on this channel +AWAIT channel.push.subscribeDevice() + +# Verify subscription exists via admin API +result = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "deviceId": device_id +}) +ASSERT result.items.length >= 1 + +found = false +FOR sub IN result.items: + IF sub.deviceId == device_id AND sub.channel == channel_name: + found = true +ASSERT found == true + +# Unsubscribe the device +AWAIT channel.push.unsubscribeDevice() + +# Verify subscription is removed +result_after = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "deviceId": device_id +}) +ASSERT result_after.items.length == 0 +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH7b, RSH7d — subscribeClient and unsubscribeClient round-trip + +**Test ID**: `rest/integration/RSH7b/subscribe-unsubscribe-client-0` + +| Spec | Requirement | +|------|-------------| +| RSH7b | subscribeClient() subscribes the local device's clientId to push on a channel | +| RSH7b2 | Performs a POST to /push/channelSubscriptions with device clientId and channel name | +| RSH7d | unsubscribeClient() unsubscribes the local device's clientId from push on a channel | +| RSH7d2 | Performs a DELETE to /push/channelSubscriptions with device clientId and channel name | + +Tests the full client subscription lifecycle: configure a local device with a `clientId`, subscribe via `PushChannel.subscribeClient()`, verify the subscription exists, then unsubscribe via `PushChannel.unsubscribeClient()` and verify it is removed. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "nonprod:sandbox", + useBinaryProtocol: false +)) + +client_id = "test-client-pushchan-" + random_id() +channel_name = "pushenabled:test-rsh7b-" + random_id() + +# Configure the local device with a clientId +# subscribeClient does not require device registration — it subscribes +# by clientId, not by deviceId +client.device = LocalDevice( + id: "test-device-" + random_id(), + deviceIdentityToken: "test-device-identity-token", + clientId: client_id +) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Subscribe the client to push on this channel +AWAIT channel.push.subscribeClient() + +# Verify subscription exists via admin API +result = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "clientId": client_id +}) +ASSERT result.items.length >= 1 + +found = false +FOR sub IN result.items: + IF sub.clientId == client_id AND sub.channel == channel_name: + found = true +ASSERT found == true + +# Unsubscribe the client +AWAIT channel.push.unsubscribeClient() + +# Verify subscription is removed +result_after = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "clientId": client_id +}) +ASSERT result_after.items.length == 0 +``` diff --git a/uts/rest/integration/revoke_tokens.md b/uts/rest/integration/revoke_tokens.md index c069f0ce9..fcb574965 100644 --- a/uts/rest/integration/revoke_tokens.md +++ b/uts/rest/integration/revoke_tokens.md @@ -24,27 +24,31 @@ to avoid missing the state change. ## Server Response Format -The Ably server returns token revocation results as a **plain JSON array** of -per-target results: +With `X-Ably-Version >= 3` (sent by all current SDKs), the Ably server returns a +`BatchResult` envelope for all token revocation responses: ```json -[{"target": "clientId:xxx", "appliesAt": 1234567890, "issuedBefore": 1234567890}] +{ + "successCount": 1, + "failureCount": 1, + "results": [ + {"target": "clientId:xxx", "appliesAt": 1234567890, "issuedBefore": 1234567890}, + {"target": "invalidType:abc", "error": {"code": 40000, "statusCode": 400, "message": "..."}} + ] +} ``` -On failure for a specific target, the element contains an `error` field instead: +Both all-success and mixed success/failure responses return HTTP 201 with this +format. The `successCount`, `failureCount`, and `results` fields are provided by +the server — no client-side computation is needed. -```json -[{"target": "invalidType:abc", "error": {"code": 40000, "statusCode": 400, "message": "..."}}] -``` - -There is no `BatchResult` envelope — the `successCount` and `failureCount` fields -(RSA17c) must be computed **client-side** by counting elements with and without an -`error` field. This is consistent with how batch presence responses work (see -`batch_presence.md`). +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success and `{error, batchResponse}` for mixed +results (HTTP 400). This format is not used by current SDKs. ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning @@ -54,7 +58,7 @@ Uses `ably-common/test-resources/test-app-setup.json` which provides: ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -63,7 +67,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {full_access_key} ``` @@ -71,6 +75,8 @@ AFTER ALL TESTS: ## RSA17g, RSA17b, RSA17c, TRS2 - Token revocation prevents subsequent use +**Test ID**: `rest/integration/RSA17g/revoke-token-prevents-use-0` + **Spec requirement:** `Auth#revokeTokens` sends a POST to `/keys/{keyName}/revokeTokens` with `targets` as `type:value` strings, and returns a result containing per-target success information. After revocation, @@ -92,7 +98,7 @@ client_id = "revoke-client-" + random_id() # Create a key-auth REST client (using the revocable key) for revoking and token issuance key_client = Rest(options: ClientOptions( key: revocable_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Request a native token for the clientId @@ -101,7 +107,7 @@ token_details = AWAIT key_client.auth.requestToken(clientId: client_id) # Create a Realtime client using the token, and wait for it to connect realtime_client = Realtime(options: ClientOptions( token: token_details, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) AWAIT realtime_client.connection.once("connected") ``` @@ -136,6 +142,8 @@ ASSERT state_change.reason.code == 40141 ## RSA17d - Token auth client rejected +**Test ID**: `rest/integration/RSA17d/token-auth-revoke-rejected-0` + **Spec requirement:** If called from a client using token authentication, should raise an error with code `40162` and status code `401`. This is a client-side check — no HTTP request is made to the server. @@ -152,7 +160,7 @@ jwt = generate_jwt( # Create a client using token auth (JWT) token_rest = Rest(options: ClientOptions( token: jwt, - endpoint: "sandbox", + endpoint: "nonprod:sandbox", useBinaryProtocol: false )) ``` @@ -174,6 +182,8 @@ ASSERT error.statusCode == 401 ## RSA17e, RSA17f - issuedBefore and allowReauthMargin +**Test ID**: `rest/integration/RSA17e/issued-before-reauth-margin-0` + | Spec | Requirement | |------|-------------| | RSA17e | Optional `issuedBefore` timestamp in milliseconds | @@ -189,7 +199,7 @@ client_id = "revoke-margin-client-" + random_id() key_client = Rest(options: ClientOptions( key: revocable_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -220,6 +230,8 @@ ASSERT revoke_result.results[0].appliesAt > server_time + (30 * 1000) ## RSA17c, TRF2 - Mixed success and failure (invalid specifier type) +**Test ID**: `rest/integration/RSA17c/mixed-success-failure-0` + **Spec requirement:** The response can contain both successful and failed per-target results. An invalid target type produces a failure result with an `ErrorInfo`. @@ -241,7 +253,7 @@ client_id = "revoke-mixed-client-" + random_id() # Create a key-auth REST client for revoking and token issuance key_client = Rest(options: ClientOptions( key: revocable_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) # Request a native token for the clientId @@ -250,7 +262,7 @@ token_details = AWAIT key_client.auth.requestToken(clientId: client_id) # Create a Realtime client using the token, and wait for it to connect realtime_client = Realtime(options: ClientOptions( token: token_details, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) AWAIT realtime_client.connection.once("connected") ``` diff --git a/uts/rest/integration/time_stats.md b/uts/rest/integration/time_stats.md index cb08a8b7e..aa48d5518 100644 --- a/uts/rest/integration/time_stats.md +++ b/uts/rest/integration/time_stats.md @@ -7,13 +7,13 @@ Integration test against Ably sandbox ## Sandbox Setup -Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. +Tests run against the Ably Sandbox at `https://sandbox.realtime.ably-nonprod.net`. ### App Provisioning ```pseudo BEFORE ALL TESTS: - response = POST https://sandbox-rest.ably.io/apps + response = POST https://sandbox.realtime.ably-nonprod.net/apps WITH body from ably-common/test-resources/test-app-setup.json app_config = parse_json(response.body) @@ -21,7 +21,7 @@ BEFORE ALL TESTS: app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox-rest.ably.io/apps/{app_id} + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} WITH Authorization: Basic {api_key} ``` @@ -29,6 +29,8 @@ AFTER ALL TESTS: ## RSC16 - time() returns server time +**Test ID**: `rest/integration/RSC16/time-returns-server-time-0` + **Spec requirement:** RSC16 - `time()` obtains the current server time. Tests that `time()` returns the current server time. @@ -37,7 +39,7 @@ Tests that `time()` returns the current server time. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -63,6 +65,8 @@ ASSERT server_time <= after_request + 5000ms ## RSC6 - stats() returns application statistics +**Test ID**: `rest/integration/RSC6/stats-returns-result-0` + **Spec requirement:** RSC6 - `stats()` returns a `PaginatedResult` containing application statistics. Tests that `stats()` returns stats for the application. @@ -71,7 +75,7 @@ Tests that `stats()` returns stats for the application. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` @@ -97,6 +101,8 @@ IF result.items.length > 0: ## RSC6 - stats() with parameters +**Test ID**: `rest/integration/RSC6/stats-with-parameters-1` + **Spec requirement:** RSC6 - `stats()` supports `limit`, `direction`, and `unit` parameters. Tests that `stats()` correctly applies query parameters. @@ -105,7 +111,7 @@ Tests that `stats()` correctly applies query parameters. ```pseudo client = Rest(options: ClientOptions( key: api_key, - endpoint: "sandbox" + endpoint: "nonprod:sandbox" )) ``` diff --git a/uts/rest/unit/auth/auth_callback.md b/uts/rest/unit/auth/auth_callback.md index ac6464463..470411a72 100644 --- a/uts/rest/unit/auth/auth_callback.md +++ b/uts/rest/unit/auth/auth_callback.md @@ -20,6 +20,8 @@ These tests verify that the library correctly invokes `authCallback` and `authUr ## RSA8d - authCallback invoked for authentication +**Test ID**: `rest/unit/RSA8d/callback-invoked-for-auth-0` + **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. @@ -72,6 +74,8 @@ ASSERT captured_requests[0].headers["Authorization"] == "Bearer callback-token" ## RSA8d - authCallback returning JWT string +**Test ID**: `rest/unit/RSA8d/callback-returns-jwt-1` + **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). @@ -113,6 +117,8 @@ ASSERT captured_requests[0].headers["Authorization"] == "Bearer eyJhbGciOiJIUzI1 ## RSA8d - authCallback returning TokenRequest +**Test ID**: `rest/unit/RSA8d/callback-returns-token-request-2` + **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. @@ -177,6 +183,8 @@ ASSERT second_request.headers["Authorization"] == "Bearer exchanged-token" ## RSA8d - authCallback receives TokenParams +**Test ID**: `rest/unit/RSA8d/callback-receives-token-params-3` + **Spec requirement:** authCallback receives TokenParams when provided to authorize(). Tests that authCallback receives TokenParams when provided to authorize(). @@ -228,6 +236,8 @@ ASSERT received_params.capability == {"channel1": ["publish"]} ## RSA8c - authUrl invoked for authentication +**Test ID**: `rest/unit/RSA8c/authurl-invoked-for-auth-0` + **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. @@ -282,6 +292,8 @@ ASSERT api_request.headers["Authorization"] == "Bearer authurl-token" ## RSA8c - authUrl with POST method +**Test ID**: `rest/unit/RSA8c/authurl-post-method-1` + **Spec requirement:** authMethod can be set to POST for authUrl requests. Tests that authMethod can be set to POST for authUrl. @@ -329,6 +341,8 @@ ASSERT auth_request.method == "POST" ## RSA8c - authUrl with custom headers +**Test ID**: `rest/unit/RSA8c/authurl-custom-headers-2` + **Spec requirement:** authHeaders are sent with authUrl requests. Tests that authHeaders are sent with authUrl requests. @@ -379,6 +393,8 @@ ASSERT auth_request.headers["X-API-Key"] == "my-api-key" ## RSA8c - authUrl with query params +**Test ID**: `rest/unit/RSA8c/authurl-query-params-3` + **Spec requirement:** authParams are sent as query parameters with authUrl GET requests. Tests that authParams are sent as query parameters with authUrl GET requests. @@ -429,6 +445,8 @@ ASSERT auth_request.url.query_params["scope"] == "publish:*" ## RSA8c - authUrl returning JWT string +**Test ID**: `rest/unit/RSA8c/authurl-returns-jwt-4` + **Spec requirement:** authUrl can return a raw JWT string (not JSON). Tests that authUrl can return a raw JWT string. @@ -475,6 +493,8 @@ ASSERT api_request.headers["Authorization"] == "Bearer eyJhbGciOiJIUzI1NiJ9.jwt- ## RSA8d - authCallback error propagated +**Test ID**: `rest/unit/RSA8d/callback-error-propagated-4` + **Spec requirement:** Errors from authCallback are properly propagated to the caller. Tests that errors from authCallback are properly propagated. @@ -517,6 +537,8 @@ ASSERT captured_requests.length == 0 ## RSA8c - authUrl error propagated +**Test ID**: `rest/unit/RSA8c/authurl-error-propagated-5` + **Spec requirement:** HTTP errors from authUrl are properly propagated to the caller. Tests that HTTP errors from authUrl are properly propagated. diff --git a/uts/rest/unit/auth/auth_scheme.md b/uts/rest/unit/auth/auth_scheme.md index 7250e3bcd..1e444902f 100644 --- a/uts/rest/unit/auth/auth_scheme.md +++ b/uts/rest/unit/auth/auth_scheme.md @@ -22,6 +22,8 @@ These tests verify that the library correctly selects between Basic authenticati ## RSA4 - Basic auth with API key only +**Test ID**: `rest/unit/RSA4/basic-auth-key-only-0` + **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. @@ -62,6 +64,8 @@ ASSERT request.headers["Authorization"] == expected_auth ## RSA3 - Token auth with explicit token +**Test ID**: `rest/unit/RSA3/token-auth-explicit-token-0` + **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. @@ -99,6 +103,8 @@ ASSERT request.headers["Authorization"] == "Bearer explicit-token-string" ## RSA3 - Token auth with TokenDetails +**Test ID**: `rest/unit/RSA3/token-auth-token-details-1` + **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. @@ -141,6 +147,8 @@ ASSERT request.headers["Authorization"] == "Bearer token-from-details" ## RSA4 - useTokenAuth forces token auth +**Test ID**: `rest/unit/RSA4/use-token-auth-forced-1` + **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. @@ -190,6 +198,8 @@ ASSERT api_request.headers["Authorization"] == "Bearer obtained-token" ## RSA4 - authCallback triggers token auth +**Test ID**: `rest/unit/RSA4/auth-callback-triggers-token-2` + **Spec requirement:** Presence of authCallback triggers token auth. Tests that presence of authCallback triggers token auth. @@ -233,6 +243,8 @@ ASSERT request.headers["Authorization"] == "Bearer callback-token" ## RSA4 - authUrl triggers token auth +**Test ID**: `rest/unit/RSA4/authurl-triggers-token-3` + **Spec requirement:** Presence of authUrl triggers token auth. Tests that presence of authUrl triggers token auth. @@ -280,6 +292,8 @@ ASSERT api_request.headers["Authorization"] == "Bearer authurl-token" ## RSC1b - Error when no auth method available +**Test ID**: `rest/unit/RSC1b/no-auth-method-error-0` + **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. @@ -318,6 +332,8 @@ ASSERT captured_requests.length == 0 ## RSA4a2 - Error when token expired and no renewal method +**Test ID**: `rest/unit/RSA4a2/expired-token-no-renewal-0` + **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. @@ -362,6 +378,8 @@ ASSERT captured_requests.length == 0 ## RSA1 - Auth method priority +**Test ID**: `rest/unit/RSA1/token-auth-takes-precedence-0` + **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. @@ -410,6 +428,8 @@ ASSERT request.headers["Authorization"] == "Bearer callback-token" ## RSA2, RSA11 - Basic auth header format +**Test ID**: `rest/unit/RSA2/basic-auth-header-format-0` + **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. @@ -454,6 +474,8 @@ ASSERT request.headers["Authorization"] CONTAINS "Basic " ## RSC18 - Token auth allowed over non-TLS +**Test ID**: `rest/unit/RSC18/token-auth-over-non-tls-0` + **Spec requirement:** Token auth is allowed over non-TLS connections. Tests that token auth is allowed over non-TLS connections. diff --git a/uts/rest/unit/auth/authorize.md b/uts/rest/unit/auth/authorize.md index ff300d8dd..26693d45a 100644 --- a/uts/rest/unit/auth/authorize.md +++ b/uts/rest/unit/auth/authorize.md @@ -13,6 +13,8 @@ See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure sp ## RSA10a - authorize() with default tokenParams +**Test ID**: `rest/unit/RSA10a/authorize-default-params-0` + **Spec requirement:** `authorize()` obtains a token using configured defaults. Tests that `authorize()` obtains a token using configured defaults. @@ -61,6 +63,8 @@ ASSERT captured_requests.last.headers["Authorization"] == "Bearer obtained-token ## RSA10b - authorize() with explicit tokenParams +**Test ID**: `rest/unit/RSA10b/authorize-explicit-params-0` + **Spec requirement:** Provided `tokenParams` override defaults in authorize(). Tests that provided `tokenParams` override defaults. @@ -109,6 +113,8 @@ ASSERT params.ttl == 7200000 ## RSA10e - authorize() saves tokenParams for reuse +**Test ID**: `rest/unit/RSA10e/authorize-saves-params-0` + **Spec requirement:** `tokenParams` provided to `authorize()` are saved and reused on subsequent token requests. Tests that `tokenParams` provided to `authorize()` are saved and reused. @@ -161,6 +167,8 @@ ASSERT callback_invocations[1].ttl == 3600000 ## RSA10g - authorize() updates Auth.tokenDetails +**Test ID**: `rest/unit/RSA10g/authorize-updates-token-details-0` + **Spec requirement:** After `authorize()`, `auth.tokenDetails` reflects the new token. Tests that after `authorize()`, `auth.tokenDetails` reflects the new token. @@ -202,6 +210,8 @@ ASSERT client.auth.tokenDetails == result # Same object ## RSA10h - authorize() with authOptions replaces defaults +**Test ID**: `rest/unit/RSA10h/authorize-replaces-auth-options-0` + **Spec requirement:** `authOptions` in `authorize()` replaces stored auth options. Tests that `authOptions` in `authorize()` replaces stored auth options. @@ -249,6 +259,8 @@ ASSERT new_callback_called == true ## RSA10i - authorize() preserves key from constructor +**Test ID**: `rest/unit/RSA10i/authorize-preserves-key-0` + **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. @@ -301,6 +313,8 @@ AWAIT client.auth.authorize( ## RSA10j - authorize() when already authorized +**Test ID**: `rest/unit/RSA10j/authorize-replaces-existing-token-0` + **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. @@ -345,6 +359,8 @@ ASSERT client.auth.tokenDetails.token == "token-2" ## RSA10k - authorize() with queryTime option +**Test ID**: `rest/unit/RSA10k/authorize-query-time-0` + **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. @@ -391,6 +407,8 @@ ASSERT time_request IS NOT null ## RSA10l - authorize() error handling +**Test ID**: `rest/unit/RSA10l/authorize-error-propagated-0` + **Spec requirement:** Errors during authorization are properly propagated to the caller. Tests that errors during authorization are properly propagated. diff --git a/uts/rest/unit/auth/client_id.md b/uts/rest/unit/auth/client_id.md index 7feb90b6a..5a832c3f4 100644 --- a/uts/rest/unit/auth/client_id.md +++ b/uts/rest/unit/auth/client_id.md @@ -13,6 +13,8 @@ See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure sp ## RSA7a - clientId from ClientOptions +**Test ID**: `rest/unit/RSA7a/clientid-from-options-0` + **Spec requirement:** `clientId` from `ClientOptions` is accessible via `auth.clientId`. Tests that `clientId` from `ClientOptions` is accessible via `auth.clientId`. @@ -34,6 +36,8 @@ ASSERT client.auth.clientId == "my-client-id" ## RSA7b - clientId from TokenDetails +**Test ID**: `rest/unit/RSA7b/clientid-from-token-details-0` + **Spec requirement:** `clientId` is derived from `TokenDetails` when token auth is used. Tests that `clientId` is derived from `TokenDetails` when token auth is used. @@ -66,6 +70,8 @@ ASSERT client.auth.clientId == "token-client-id" ## RSA7b - clientId from authCallback TokenDetails +**Test ID**: `rest/unit/RSA7b/clientid-from-callback-token-1` + **Spec requirement:** `clientId` is extracted from `TokenDetails` returned by `authCallback`. Tests that `clientId` is extracted from `TokenDetails` returned by `authCallback`. @@ -104,6 +110,8 @@ ASSERT client.auth.clientId == "callback-client-id" ## RSA7c - clientId null when unidentified +**Test ID**: `rest/unit/RSA7c/clientid-null-unidentified-0` + **Spec requirement:** `auth.clientId` is null when no client identity is established. Tests that `auth.clientId` is null when no client identity is established. @@ -123,6 +131,8 @@ ASSERT client.auth.clientId IS null ## RSA7c - clientId null with unidentified token +**Test ID**: `rest/unit/RSA7c/clientid-null-unidentified-token-1` + **Spec requirement:** `auth.clientId` is null when token has no `clientId`. Tests that `auth.clientId` is null when token has no `clientId`. @@ -155,6 +165,8 @@ ASSERT client.auth.clientId IS null ## RSA12a - clientId passed to authCallback in TokenParams +**Test ID**: `rest/unit/RSA12a/clientid-passed-to-callback-0` + **Spec requirement:** `clientId` is passed to `authCallback` via `TokenParams`. Tests that `clientId` is passed to `authCallback` via `TokenParams`. @@ -198,6 +210,8 @@ ASSERT received_params[0].clientId == "library-client-id" ## RSA12b - clientId sent to authUrl +**Test ID**: `rest/unit/RSA12b/clientid-sent-to-authurl-0` + **Spec requirement:** `clientId` is sent as a parameter when using `authUrl`. Tests that `clientId` is sent as a parameter when using `authUrl`. @@ -249,6 +263,8 @@ ELSE: ## RSA7 - clientId updated after authorize() +**Test ID**: `rest/unit/RSA7/clientid-updated-after-authorize-0` + **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`. @@ -294,6 +310,8 @@ ASSERT client.auth.clientId == "client-2" ## RSA12 - Wildcard clientId +**Test ID**: `rest/unit/RSA12/wildcard-clientid-0` + **Spec requirement:** Wildcard `*` clientId allows the token to be used with any client identity. Tests handling of wildcard `*` clientId. @@ -330,6 +348,8 @@ The wildcard `*` clientId allows the token to be used with any client identity. ## RSA7 - clientId consistency between ClientOptions and token +**Test ID**: `rest/unit/RSA7/clientid-mismatch-error-1` + **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`. @@ -377,6 +397,8 @@ The exact timing of mismatch detection (constructor vs first use) may vary by im ## RSA15a - Token clientId must match ClientOptions clientId +**Test ID**: `rest/unit/RSA15a/token-clientid-must-match-0` + **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. @@ -422,6 +444,8 @@ ASSERT error.code == 40102 ## RSA15b - Wildcard token clientId permits any ClientOptions clientId +**Test ID**: `rest/unit/RSA15b/wildcard-token-permits-any-0` + **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 @@ -455,6 +479,8 @@ ASSERT client.auth.clientId == "any-client" ## RSA15c - Incompatible clientId results in error (REST) or FAILED (Realtime) +**Test ID**: `rest/unit/RSA15c/incompatible-clientid-error-0` + **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 diff --git a/uts/rest/unit/auth/revoke_tokens.md b/uts/rest/unit/auth/revoke_tokens.md index 8d63b66e1..39d718f60 100644 --- a/uts/rest/unit/auth/revoke_tokens.md +++ b/uts/rest/unit/auth/revoke_tokens.md @@ -11,32 +11,30 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ## 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`). +With `X-Ably-Version >= 3` (sent by all current SDKs), the server returns a +`BatchResult` envelope for all batch responses: -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": [ + "successCount": 1, + "failureCount": 1, + "results": [ { "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. +- **All success** returns HTTP 201 with this format. +- **Mixed success/failure** returns HTTP 201 with this format (not HTTP 400). +- **Server-level errors** (HTTP 500, 401, etc.) return an error object: `{"error": {...}}`. -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. +The SDK passes through this response directly — no client-side normalisation +is needed because the server provides `successCount`, `failureCount`, and `results`. + +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success and `{error, batchResponse}` for mixed +results (HTTP 400). This format is not used by current SDKs. --- @@ -49,6 +47,8 @@ obtained by reading `AuthOptions#key` up until the first `:` character. ### RSA17g_1 - Sends POST request to correct path +**Test ID**: `rest/unit/RSA17g/sends-post-correct-path-0` + **Spec requirement:** revokeTokens sends a POST request to `/keys/{keyName}/revokeTokens`. ### Setup @@ -93,6 +93,8 @@ strings by joining the `type` and `value` with a `:` character and sent in the ### RSA17b_1 - Single specifier sent as targets array +**Test ID**: `rest/unit/RSA17b/single-specifier-targets-0` + ### Setup ```pseudo captured_requests = [] @@ -126,6 +128,8 @@ ASSERT request_body["targets"] == ["clientId:alice"] ### RSA17b_2 - Multiple specifiers with different types +**Test ID**: `rest/unit/RSA17b/multiple-specifier-types-1` + ### Setup ```pseudo captured_requests = [] @@ -174,6 +178,8 @@ ASSERT request_body["targets"] == ["clientId:alice", "revocationKey:group-1", "c ### RSA17c_1 - All success result +**Test ID**: `rest/unit/RSA17c/all-success-result-0` + ### Setup ```pseudo mock_http = MockHttpClient( @@ -207,17 +213,20 @@ ASSERT result.results.length == 2 ### RSA17c_2 - Mixed success and failure result +**Test ID**: `rest/unit/RSA17c/mixed-success-failure-1` + **Spec requirement:** When the server returns a mix of successes and failures, -the response is HTTP 400 with a `batchResponse` array. +the response is HTTP 200 with a `BatchResult` envelope. ### 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": [ + req.respond_with(200, { + "successCount": 1, + "failureCount": 1, + "results": [ { "target": "clientId:alice", "issuedBefore": 1700000000000, "appliesAt": 1700000001000 }, { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } ] @@ -246,14 +255,17 @@ ASSERT result.results.length == 2 ### RSA17c_3 - All failure result +**Test ID**: `rest/unit/RSA17c/all-failure-result-2` + ### 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": [ + req.respond_with(200, { + "successCount": 0, + "failureCount": 2, + "results": [ { "target": "invalidType:foo", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } }, { "target": "invalidType:bar", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } ] @@ -292,6 +304,8 @@ ASSERT result.results.length == 2 ### TRS2_1 - Success result contains target, appliesAt, and issuedBefore +**Test ID**: `rest/unit/TRS2/success-result-attributes-0` + ### Setup ```pseudo mock_http = MockHttpClient( @@ -334,14 +348,17 @@ ASSERT success.appliesAt == 1700000001000 ### TRF2_1 - Failure result contains target and error +**Test ID**: `rest/unit/TRF2/failure-result-attributes-0` + ### 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": [ + req.respond_with(200, { + "successCount": 0, + "failureCount": 1, + "results": [ { "target": "invalidType:abc", "error": { "code": 40000, "statusCode": 400, "message": "Invalid target type" } } ] }) @@ -380,6 +397,8 @@ client-side check — no HTTP request is made. ### RSA17d_1 - Token auth client fails with 40162 +**Test ID**: `rest/unit/RSA17d/token-auth-revoke-rejected-0` + ### Setup ```pseudo captured_requests = [] @@ -414,6 +433,8 @@ ASSERT captured_requests.length == 0 ### RSA17d_2 - Token auth via useTokenAuth flag fails with 40162 +**Test ID**: `rest/unit/RSA17d/use-token-auth-revoke-rejected-1` + ### Setup ```pseudo captured_requests = [] @@ -453,6 +474,8 @@ milliseconds since the epoch, which is included in the request body. ### RSA17e_1 - issuedBefore included in request body +**Test ID**: `rest/unit/RSA17e/issued-before-included-0` + ### Setup ```pseudo captured_requests = [] @@ -487,6 +510,8 @@ ASSERT request_body["issuedBefore"] == 1699999000000 ### RSA17e_2 - issuedBefore omitted when not provided +**Test ID**: `rest/unit/RSA17e/issued-before-omitted-1` + ### Setup ```pseudo captured_requests = [] @@ -527,6 +552,8 @@ included in the `allowReauthMargin` field of the request body. ### RSA17f_1 - allowReauthMargin included when true +**Test ID**: `rest/unit/RSA17f/reauth-margin-included-0` + ### Setup ```pseudo captured_requests = [] @@ -561,6 +588,8 @@ ASSERT request_body["allowReauthMargin"] == true ### RSA17f_2 - allowReauthMargin omitted when not provided +**Test ID**: `rest/unit/RSA17f/reauth-margin-omitted-1` + ### Setup ```pseudo captured_requests = [] @@ -594,6 +623,8 @@ ASSERT "allowReauthMargin" NOT IN request_body ### RSA17f_3 - Both issuedBefore and allowReauthMargin together +**Test ID**: `rest/unit/RSA17f/both-options-together-2` + ### Setup ```pseudo captured_requests = [] @@ -634,6 +665,8 @@ ASSERT request_body["allowReauthMargin"] == true ### RSA17_Error_1 - Server error is propagated as an error +**Test ID**: `rest/unit/RSA17/server-error-propagated-0` + **Spec requirement:** A server-level error (e.g. 500) for the entire request is propagated as an error, not a per-target failure. @@ -671,6 +704,8 @@ ASSERT error.statusCode == 500 ### RSA17_Auth_1 - Request uses Basic authentication +**Test ID**: `rest/unit/RSA17/request-uses-basic-auth-0` + **Spec requirement:** revokeTokens requires key-based auth (RSA17d rejects token auth). The POST request uses the client's configured Basic authentication. diff --git a/uts/rest/unit/auth/token_details.md b/uts/rest/unit/auth/token_details.md index 9d01dba99..74add4919 100644 --- a/uts/rest/unit/auth/token_details.md +++ b/uts/rest/unit/auth/token_details.md @@ -25,6 +25,8 @@ See `uts/test/rest/unit/rest_client.md` for the full Mock HTTP Infrastructure sp ### Test: tokenDetails reflects token from authCallback +**Test ID**: `rest/unit/RSA16a/token-from-callback-0` + #### Setup ```pseudo mock_http = MockHttpClient( @@ -62,6 +64,8 @@ ASSERT client.auth.tokenDetails.issued IS NOT null ### Test: tokenDetails reflects token from requestToken +**Test ID**: `rest/unit/RSA16a/token-from-request-token-1` + #### Setup ```pseudo mock_http = MockHttpClient( @@ -105,6 +109,8 @@ ASSERT client.auth.tokenDetails.clientId == "token-client" ### Test: tokenDetails created from token string in ClientOptions +**Test ID**: `rest/unit/RSA16b/token-string-in-options-0` + #### Setup ```pseudo mock_http = MockHttpClient( @@ -138,6 +144,8 @@ ASSERT token_details.capability IS null ### Test: tokenDetails created from token string in authCallback +**Test ID**: `rest/unit/RSA16b/token-string-from-callback-1` + #### Setup ```pseudo mock_http = MockHttpClient( @@ -175,6 +183,8 @@ ASSERT client.auth.tokenDetails.issued IS null ### Test: tokenDetails set on instantiation with tokenDetails option +**Test ID**: `rest/unit/RSA16c/set-on-instantiation-0` + #### Setup ```pseudo initial_token = TokenDetails( @@ -204,6 +214,8 @@ ASSERT token_details.clientId == "initial-client" ### Test: tokenDetails updated after explicit authorize() +**Test ID**: `rest/unit/RSA16c/updated-after-authorize-1` + #### Setup ```pseudo token_count = 0 @@ -253,6 +265,8 @@ ASSERT first_token.token != second_token.token ### Test: tokenDetails updated after library-initiated renewal on expiry +**Test ID**: `rest/unit/RSA16c/updated-after-expiry-renewal-2` + #### Setup ```pseudo test_clock = TestClock() @@ -302,6 +316,8 @@ ASSERT second_token.token == "token-v2" ### Test: tokenDetails updated after library-initiated renewal on 40142 error +**Test ID**: `rest/unit/RSA16c/updated-after-40142-renewal-3` + #### Setup ```pseudo request_count = 0 @@ -365,6 +381,8 @@ ASSERT second_token.token == "token-v2" ### Test: tokenDetails is null when using basic auth +**Test ID**: `rest/unit/RSA16d/null-with-basic-auth-0` + #### Setup ```pseudo mock_http = MockHttpClient( @@ -393,6 +411,8 @@ ASSERT client.auth.tokenDetails IS null ### Test: tokenDetails is null before any token is obtained +**Test ID**: `rest/unit/RSA16d/null-before-token-obtained-1` + #### Setup ```pseudo # Client configured for token auth but no request made yet @@ -420,6 +440,8 @@ ASSERT token_details IS null ### Test: tokenDetails is null after token invalidation +**Test ID**: `rest/unit/RSA16d/null-after-invalidation-2` + **Note:** This test verifies behavior when a token error occurs and cannot be renewed (e.g., authCallback fails). #### Setup @@ -477,6 +499,8 @@ ASSERT client.auth.tokenDetails IS null ### Test: tokenDetails is null after switching from token to basic auth +**Test ID**: `rest/unit/RSA16d/null-after-switch-to-basic-3` + **Note:** This tests the case where a client is reconfigured to use basic auth after having used token auth. #### Setup @@ -522,6 +546,8 @@ ASSERT client.auth.tokenDetails IS null ### Test: tokenDetails preserved across multiple successful requests +**Test ID**: `rest/unit/RSA16a/preserved-across-requests-0` + #### Setup ```pseudo mock_http = MockHttpClient( @@ -564,6 +590,8 @@ ASSERT third_check.token == "stable-token" ### Test: tokenDetails reflects capability from token +**Test ID**: `rest/unit/RSA16a/reflects-capability-1` + #### Setup ```pseudo mock_http = MockHttpClient( diff --git a/uts/rest/unit/auth/token_renewal.md b/uts/rest/unit/auth/token_renewal.md index c8c422da2..996a8b297 100644 --- a/uts/rest/unit/auth/token_renewal.md +++ b/uts/rest/unit/auth/token_renewal.md @@ -19,6 +19,8 @@ These tests verify that the library correctly handles token expiry and triggers ## RSA4b - Token renewal on expiry rejection +**Test ID**: `rest/unit/RSA4b/renewal-on-40142-0` + **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. @@ -91,6 +93,8 @@ ASSERT result.items IS List ## RSA4b - Token renewal on 40140 error +**Test ID**: `rest/unit/RSA4b/renewal-on-40140-1` + **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). @@ -147,6 +151,8 @@ ASSERT request_count == 2 ## RSA4b1 - Pre-emptive token renewal +**Test ID**: `rest/unit/RSA4b1/preemptive-renewal-0` + **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. @@ -212,6 +218,8 @@ ASSERT requests_to_history[0].headers["Authorization"] == "Bearer fresh-token" ## RSA4a2 - No renewal without authCallback +**Test ID**: `rest/unit/RSA4a2/no-renewal-without-callback-0` + **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. @@ -257,6 +265,8 @@ ASSERT request_count == 1 ## RSA4b - Renewal with authUrl +**Test ID**: `rest/unit/RSA4b/renewal-via-authurl-2` + **Spec requirement:** Token renewal must work via authUrl when a request is rejected with error code 40142. Tests that token renewal works via authUrl. @@ -333,6 +343,8 @@ ASSERT api_requests[1].headers["Authorization"] == "Bearer second-token" ## RSA4b - Renewal limit +**Test ID**: `rest/unit/RSA4b/renewal-limit-no-loop-3` + **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. @@ -386,6 +398,8 @@ ASSERT request_count == 2 # Original request + one retry ## RSC10 - REST request retried after token renewal +**Test ID**: `rest/unit/RSC10/request-retried-after-renewal-0` + **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. @@ -459,6 +473,8 @@ ASSERT channel_requests[1].headers["Authorization"] == "Bearer token-2" ## RSC10b - Non-token 401 errors MUST NOT trigger token renewal +**Test ID**: `rest/unit/RSC10b/non-token-401-no-renewal-0` + **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 @@ -513,6 +529,8 @@ ASSERT callback_count == 1 ## RSA4b - Token renewal with MessagePack error response +**Test ID**: `rest/unit/RSA4b/renewal-msgpack-response-4` + **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 diff --git a/uts/rest/unit/auth/token_request_params.md b/uts/rest/unit/auth/token_request_params.md index ea9f89db6..8116d84eb 100644 --- a/uts/rest/unit/auth/token_request_params.md +++ b/uts/rest/unit/auth/token_request_params.md @@ -25,6 +25,8 @@ nullable types (e.g. `int?` / `String?` in Dart, `Integer` / `String` in Java, ## RSA5 - TTL is null when not specified +**Test ID**: `rest/unit/RSA5/ttl-null-when-unspecified-0` + **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 @@ -50,6 +52,8 @@ ASSERT token_request.ttl IS null ## RSA5b - Explicit TTL is preserved +**Test ID**: `rest/unit/RSA5b/explicit-ttl-preserved-0` + **Spec requirement:** When `tokenParams` specifies a TTL, it must be included in the token request. ### Setup @@ -73,6 +77,8 @@ ASSERT token_request.ttl == 7200000 ## RSA5c - TTL from defaultTokenParams is used +**Test ID**: `rest/unit/RSA5c/ttl-from-default-params-0` + **Spec requirement:** TTL from `ClientOptions.defaultTokenParams` should be used when no explicit TTL is provided to `createTokenRequest()`. ### Setup @@ -97,6 +103,8 @@ ASSERT token_request.ttl == 1800000 ## RSA5d - Explicit TTL overrides defaultTokenParams +**Test ID**: `rest/unit/RSA5d/explicit-ttl-overrides-default-0` + **Spec requirement:** An explicit TTL in `tokenParams` takes precedence over `defaultTokenParams`. ### Setup @@ -123,6 +131,8 @@ ASSERT token_request.ttl == 600000 ## RSA6 - Capability is null when not specified +**Test ID**: `rest/unit/RSA6/capability-null-when-unspecified-0` + **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 @@ -148,6 +158,8 @@ ASSERT token_request.capability IS null ## RSA6b - Explicit capability is preserved +**Test ID**: `rest/unit/RSA6b/explicit-capability-preserved-0` + **Spec requirement:** When `tokenParams` specifies a capability, it must be included in the token request as a JSON string. ### Setup @@ -171,6 +183,8 @@ ASSERT token_request.capability == '{"channel-a":["publish","subscribe"]}' ## RSA6c - Capability from defaultTokenParams is used +**Test ID**: `rest/unit/RSA6c/capability-from-default-params-0` + **Spec requirement:** Capability from `ClientOptions.defaultTokenParams` should be used when no explicit capability is provided to `createTokenRequest()`. ### Setup @@ -195,6 +209,8 @@ ASSERT token_request.capability == '{"*":["subscribe"]}' ## RSA6d - Explicit capability overrides defaultTokenParams +**Test ID**: `rest/unit/RSA6d/explicit-capability-overrides-default-0` + **Spec requirement:** An explicit capability in `tokenParams` takes precedence over `defaultTokenParams`. ### Setup diff --git a/uts/rest/unit/batch_presence.md b/uts/rest/unit/batch_presence.md index 523df822f..cd089efe8 100644 --- a/uts/rest/unit/batch_presence.md +++ b/uts/rest/unit/batch_presence.md @@ -16,14 +16,29 @@ 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, ...}}` +With `X-Ably-Version >= 3` (sent by all current SDKs), the server returns a +`BatchResult` envelope for all batch responses: + +```json +{ + "successCount": 2, + "failureCount": 1, + "results": [ + {"channel": "ch-a", "presence": [...]}, + {"channel": "ch-b", "error": {"code": 40160, "statusCode": 401, ...}} + ] +} +``` + +- **All success, mixed, and all failure** return HTTP 200 with this format. +- **Server-level errors** (HTTP 500, 401, etc.) return an error object: `{"error": {...}}`. + +The SDK passes through this response directly — no client-side normalisation +is needed because the server provides `successCount`, `failureCount`, and `results`. -The SDK normalises both success and mixed/failure formats into a -`BatchPresenceResponse` with computed `successCount`, `failureCount`, and `results`. +**Legacy format (no version header):** Without `X-Ably-Version`, the server +returns a plain array for all-success (HTTP 200) and `{error, batchResponse}` +for mixed results (HTTP 400). This format is not used by current SDKs. --- @@ -35,6 +50,8 @@ request to `/presence`, returning a `BatchPresenceResponse` containing per-chann ### RSC24_1 - Sends GET request to /presence with channels query param +**Test ID**: `rest/unit/RSC24/get-presence-channels-param-0` + **Spec requirement:** batchPresence sends a GET request to `/presence` with channel names joined as a comma-separated `channels` query parameter. @@ -43,10 +60,14 @@ captured_requests = [] mock_http = MockHTTP( onRequest: (request) => { captured_requests.append(request) - RETURN HttpResponse(status: 200, body: [ - { "channel": "channel-a", "presence": [] }, - { "channel": "channel-b", "presence": [] } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 2, + "failureCount": 0, + "results": [ + { "channel": "channel-a", "presence": [] }, + { "channel": "channel-b", "presence": [] } + ] + }) } ) @@ -62,6 +83,8 @@ ASSERT captured_requests[0].url.queryParameters["channels"] == "channel-a,channe ### RSC24_2 - Single channel sends GET with single channel name +**Test ID**: `rest/unit/RSC24/single-channel-param-0` + **Spec requirement:** batchPresence with a single channel sends the channel name in the `channels` query parameter (no trailing comma). @@ -70,9 +93,13 @@ captured_requests = [] mock_http = MockHTTP( onRequest: (request) => { captured_requests.append(request) - RETURN HttpResponse(status: 200, body: [ - { "channel": "my-channel", "presence": [] } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { "channel": "my-channel", "presence": [] } + ] + }) } ) @@ -85,6 +112,8 @@ ASSERT captured_requests[0].url.queryParameters["channels"] == "my-channel" ### RSC24_3 - Channel names with special characters are comma-joined +**Test ID**: `rest/unit/RSC24/special-chars-comma-joined-0` + **Spec requirement:** Channel names containing special characters are joined with commas as-is (the server handles parsing). @@ -93,10 +122,14 @@ captured_requests = [] mock_http = MockHTTP( onRequest: (request) => { captured_requests.append(request) - RETURN HttpResponse(status: 200, body: [ - { "channel": "foo:bar", "presence": [] }, - { "channel": "baz/qux", "presence": [] } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 2, + "failureCount": 0, + "results": [ + { "channel": "foo:bar", "presence": [] }, + { "channel": "baz/qux", "presence": [] } + ] + }) } ) @@ -114,17 +147,20 @@ ASSERT captured_requests[0].url.queryParameters["channels"] == "foo:bar,baz/qux" **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 +### BAR2_1 - successCount and failureCount from mixed response + +**Test ID**: `rest/unit/BAR2/mixed-success-failure-counts-0` -The server returns HTTP 400 with `batchResponse` for mixed results. The SDK -computes `successCount` and `failureCount` from the per-channel results. +The server returns HTTP 200 with a `BatchResult` envelope containing per-channel +results, including both successes and failures. ```pseudo mock_http = MockHTTP( onRequest: (request) => { - RETURN HttpResponse(status: 400, body: { - "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, - "batchResponse": [ + RETURN HttpResponse(status: 200, body: { + "successCount": 3, + "failureCount": 1, + "results": [ { "channel": "ch-1", "presence": [] }, { "channel": "ch-2", "presence": [] }, { "channel": "ch-3", "presence": [] }, @@ -145,15 +181,19 @@ ASSERT result.results.length == 4 ### BAR2_2 - All success -The server returns HTTP 200 with a plain array when all channels succeed. +**Test ID**: `rest/unit/BAR2/all-success-counts-0` ```pseudo mock_http = MockHTTP( onRequest: (request) => { - RETURN HttpResponse(status: 200, body: [ - { "channel": "ch-a", "presence": [] }, - { "channel": "ch-b", "presence": [] } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 2, + "failureCount": 0, + "results": [ + { "channel": "ch-a", "presence": [] }, + { "channel": "ch-b", "presence": [] } + ] + }) } ) @@ -168,14 +208,15 @@ ASSERT result.results.length == 2 ### BAR2_3 - All failure -The server returns HTTP 400 with `batchResponse` when all channels fail. +**Test ID**: `rest/unit/BAR2/all-failure-counts-0` ```pseudo mock_http = MockHTTP( onRequest: (request) => { - RETURN HttpResponse(status: 400, body: { - "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, - "batchResponse": [ + RETURN HttpResponse(status: 200, body: { + "successCount": 0, + "failureCount": 2, + "results": [ { "channel": "ch-a", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } }, { "channel": "ch-b", "error": { "code": 40160, "statusCode": 401, "message": "Not permitted" } } ] @@ -201,32 +242,38 @@ ASSERT result.results.length == 2 ### BGR2_1 - Success result with members present +**Test ID**: `rest/unit/BGR2/success-with-members-0` + ```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" } - } - ] - } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { + "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" } + } + ] + } + ] + }) } ) @@ -253,12 +300,18 @@ ASSERT success.presence[1].data["key"] == "value" ### BGR2_2 - Success result with empty presence (no members) +**Test ID**: `rest/unit/BGR2/success-empty-presence-0` + ```pseudo mock_http = MockHTTP( onRequest: (request) => { - RETURN HttpResponse(status: 200, body: [ - { "channel": "empty-channel", "presence": [] } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { "channel": "empty-channel", "presence": [] } + ] + }) } ) @@ -281,12 +334,15 @@ ASSERT success.presence.length == 0 ### BGF2_1 - Failure result with error details +**Test ID**: `rest/unit/BGF2/failure-error-details-0` + ```pseudo mock_http = MockHTTP( onRequest: (request) => { - RETURN HttpResponse(status: 400, body: { - "error": { "code": 40020, "statusCode": 400, "message": "Batched response includes errors" }, - "batchResponse": [ + RETURN HttpResponse(status: 200, body: { + "successCount": 0, + "failureCount": 1, + "results": [ { "channel": "restricted-channel", "error": { @@ -321,16 +377,19 @@ ASSERT failure.error.message CONTAINS "not permitted" ### RSC24_Mixed_1 - Mixed success and failure results +**Test ID**: `rest/unit/RSC24/mixed-success-failure-results-0` + **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 +for others. The server returns HTTP 200 with a `BatchResult` 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": [ + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 1, + "results": [ { "channel": "allowed-channel", "presence": [ @@ -380,6 +439,8 @@ ASSERT result.results[1].error.code == 40160 ### RSC24_Error_1 - Server error is propagated as an error +**Test ID**: `rest/unit/RSC24/server-error-propagated-0` + **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`. @@ -402,6 +463,8 @@ ASSERT error.statusCode == 500 ### RSC24_Error_2 - Authentication error is propagated as an error +**Test ID**: `rest/unit/RSC24/auth-error-propagated-0` + **Spec requirement:** An authentication error (401) for the entire request is propagated as an error. @@ -427,6 +490,8 @@ ASSERT error.statusCode == 401 ### RSC24_Auth_1 - Request uses configured authentication +**Test ID**: `rest/unit/RSC24/uses-configured-auth-0` + **Spec requirement:** batchPresence requests use the client's configured authentication mechanism (Basic or Token auth). @@ -435,9 +500,13 @@ captured_requests = [] mock_http = MockHTTP( onRequest: (request) => { captured_requests.append(request) - RETURN HttpResponse(status: 200, body: [ - { "channel": "ch", "presence": [] } - ]) + RETURN HttpResponse(status: 200, body: { + "successCount": 1, + "failureCount": 0, + "results": [ + { "channel": "ch", "presence": [] } + ] + }) } ) diff --git a/uts/rest/unit/batch_publish.md b/uts/rest/unit/batch_publish.md index 2dda737e5..a0b86701b 100644 --- a/uts/rest/unit/batch_publish.md +++ b/uts/rest/unit/batch_publish.md @@ -20,6 +20,8 @@ See `rest_client.md` for detailed mock interface documentation. ### RSC22c1 - Single BatchPublishSpec sends POST to /messages +**Test ID**: `rest/unit/RSC22c/single-spec-post-messages-0` + **Spec requirement:** A single BatchPublishSpec is sent as a POST to `/messages` with the spec in the request body. ```pseudo @@ -39,6 +41,8 @@ And the captured request body contains: ### RSC22c2 - Array of BatchPublishSpecs sends POST to /messages +**Test ID**: `rest/unit/RSC22c/array-specs-post-messages-0` + **Spec requirement:** An array of BatchPublishSpecs is sent as a POST to `/messages` with an array of specs in the request body. ```pseudo @@ -56,6 +60,8 @@ And the captured request body is an array containing both specs ### RSC22c3 - Single spec returns single BatchResult +**Test ID**: `rest/unit/RSC22c/single-spec-single-result-0` + **Spec requirement:** When a single BatchPublishSpec is sent, the response is a single BatchResult (not an array). ```pseudo @@ -75,6 +81,8 @@ And the result contains the success result for channel_name ### RSC22c4 - Array of specs returns array of BatchResults +**Test ID**: `rest/unit/RSC22c/array-specs-array-results-0` + **Spec requirement:** When an array of BatchPublishSpecs is sent, the response is an array of BatchResults. ```pseudo @@ -94,6 +102,8 @@ And each result corresponds to the respective spec ### RSC22c5 - Multiple channels in spec produces multiple results +**Test ID**: `rest/unit/RSC22c/multiple-channels-multiple-results-0` + **Spec requirement:** A BatchPublishSpec with multiple channels produces multiple results in the response, one per channel. ```pseudo @@ -110,6 +120,8 @@ Then the BatchResult contains results for all three channels ### RSC22c6 - Messages are encoded according to RSL4 +**Test ID**: `rest/unit/RSC22c/messages-encoded-per-rsl4-0` + **Spec requirement:** Messages must be encoded according to RSL4 (String, Binary base64, JSON stringified). ```pseudo @@ -129,6 +141,8 @@ Then the captured request shows each message is encoded per RSL4: ### RSC22c7 - Request uses correct authentication +**Test ID**: `rest/unit/RSC22c/uses-configured-auth-0` + **Spec requirement:** Batch publish requests must use the configured authentication mechanism. ```pseudo @@ -155,6 +169,8 @@ Then the captured POST request includes Authorization: Basic ### RSC22d - Idempotent IDs generated when enabled +**Test ID**: `rest/unit/RSC22d/idempotent-ids-generated-0` + **Spec requirement:** With idempotentRestPublishing enabled, messages without IDs get unique IDs generated in baseId:serial format per RSL1k1, applied to each BatchPublishSpec separately. ```pseudo @@ -168,6 +184,8 @@ And each BatchPublishSpec gets a separate base ID ### RSC22d - Explicit message IDs preserved +**Test ID**: `rest/unit/RSC22d/explicit-ids-preserved-0` + **Spec requirement:** Per RSL1k3, messages with explicit IDs must have those IDs preserved as-is, even when idempotent publishing is enabled. ```pseudo @@ -179,6 +197,8 @@ Then the captured request shows the explicit ids are preserved (not overwritten) ### RSC22d - Idempotent IDs not generated when disabled +**Test ID**: `rest/unit/RSC22d/ids-not-generated-disabled-0` + **Spec requirement:** When idempotent REST publishing is disabled, no IDs are generated for messages without IDs. ```pseudo @@ -194,6 +214,8 @@ Then the captured request shows messages are sent without id fields ### BSP2a - channels is array of strings +**Test ID**: `rest/unit/BSP2a/channels-array-strings-0` + **Spec requirement:** The channels field must be an array of channel name strings. ```pseudo @@ -208,6 +230,8 @@ Then the serialized spec in the captured request contains channels as a string a ### BSP2b - messages is array of Message objects +**Test ID**: `rest/unit/BSP2b/messages-array-objects-0` + **Spec requirement:** The messages field must be an array of Message objects, each serialized according to TM* rules. ```pseudo @@ -228,6 +252,8 @@ And each message is serialized according to TM* rules ### BPR2a - channel field contains channel name +**Test ID**: `rest/unit/BPR2a/success-channel-name-0` + **Spec requirement:** The channel field contains the name of the channel where messages were published. ```pseudo @@ -242,6 +268,8 @@ Then result.channel equals channel_name ### BPR2b - messageId contains the message ID prefix +**Test ID**: `rest/unit/BPR2b/success-message-id-prefix-0` + **Spec requirement:** The messageId field contains the unique ID prefix for the published messages. ```pseudo @@ -256,6 +284,8 @@ Then result.messageId equals "unique-id-prefix" ### BPR2c - serials contains array of message serials +**Test ID**: `rest/unit/BPR2c/serials-array-0` + **Spec requirement:** The serials field contains an array of serial numbers, one per published message. ```pseudo @@ -271,6 +301,8 @@ And serials.length matches the number of messages published ### BPR2c1 - serials may contain null for conflated messages +**Test ID**: `rest/unit/BPR2c/serials-null-conflated-0` + **Spec requirement:** The serials array may contain null values for messages that were conflated (deduplicated). ```pseudo @@ -290,6 +322,8 @@ And the null indicates the second message was discarded due to conflation ### BPF2a - channel field contains failed channel name +**Test ID**: `rest/unit/BPF2a/failure-channel-name-0` + **Spec requirement:** The channel field contains the name of the channel that failed. ```pseudo @@ -307,6 +341,8 @@ Then result.channel equals channel_name ### BPF2b - error contains ErrorInfo for failure reason +**Test ID**: `rest/unit/BPF2b/failure-error-info-0` + **Spec requirement:** The error field contains an ErrorInfo object with code, statusCode, and message. ```pseudo @@ -336,6 +372,8 @@ And result.error.message contains "not permitted" ### BatchResult1 - Partial success with mixed results +**Test ID**: `rest/unit/RSC22c/partial-success-mixed-results-0` + **Spec requirement:** A batch publish can succeed for some channels and fail for others. ```pseudo @@ -357,6 +395,8 @@ And result[1] is a BatchPublishFailureResult ### BatchResult2 - Distinguishing success from failure results +**Test ID**: `rest/unit/RSC22c/distinguish-success-failure-0` + **Spec requirement:** Success and failure results can be distinguished by the presence of messageId/serials vs error fields. ```pseudo @@ -375,6 +415,8 @@ Then each result can be identified as success or failure: ### RSC22_Error1 - Invalid BatchPublishSpec rejected +**Test ID**: `rest/unit/RSC22/empty-channels-rejected-0` + **Spec requirement:** Empty channels array must be rejected with a validation error. ```pseudo @@ -386,6 +428,8 @@ And the error indicates invalid request ### RSC22_Error2 - Empty messages array rejected +**Test ID**: `rest/unit/RSC22/empty-messages-rejected-0` + **Spec requirement:** Empty messages array must be rejected with a validation error. ```pseudo @@ -399,6 +443,8 @@ And the error indicates invalid request ### RSC22_Error3 - Server error returns AblyException +**Test ID**: `rest/unit/RSC22/server-error-propagated-0` + **Spec requirement:** Server errors (5xx) must be propagated as AblyException with the error code and status. ```pseudo @@ -415,6 +461,8 @@ And exception.statusCode equals 500 ### RSC22_Error4 - Authentication error returns AblyException +**Test ID**: `rest/unit/RSC22/auth-error-propagated-0` + **Spec requirement:** Authentication errors (401) must be propagated as AblyException with the error code and status. ```pseudo @@ -435,6 +483,8 @@ And exception.statusCode equals 401 ### RSC22_Headers1 - Standard headers included +**Test ID**: `rest/unit/RSC22/standard-headers-included-0` + **Spec requirement:** All batch publish requests must include standard Ably protocol headers. ```pseudo @@ -451,6 +501,8 @@ Then the captured request includes: ### RSC22_Headers2 - Request ID included when enabled +**Test ID**: `rest/unit/RSC22/request-id-included-0` + **Spec requirement:** When addRequestIds is enabled, a unique request_id query parameter must be included. ```pseudo @@ -469,6 +521,8 @@ And the request_id is a unique identifier ### RSC22_Batch1 - Multiple messages per channel +**Test ID**: `rest/unit/RSC22/multiple-messages-per-channel-0` + **Spec requirement:** A batch can include many messages to be published to a single channel. ```pseudo @@ -486,6 +540,8 @@ And the mock response confirms all messages were processed ### RSC22_Batch2 - Multiple channels with multiple messages +**Test ID**: `rest/unit/RSC22/multiple-channels-multiple-messages-0` + **Spec requirement:** A batch can publish multiple messages to multiple channels (cartesian product). ```pseudo diff --git a/uts/rest/unit/channel/annotations.md b/uts/rest/unit/channel/annotations.md index 4189bb4c9..a82162235 100644 --- a/uts/rest/unit/channel/annotations.md +++ b/uts/rest/unit/channel/annotations.md @@ -13,6 +13,8 @@ These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/help ## RSL10 — channel.annotations returns RestAnnotations +**Test ID**: `rest/unit/RSL10/annotations-attribute-type-0` + **Spec requirement:** RSL10 — `RestChannel#annotations` attribute contains the `RestAnnotations` object for this channel. Tests that the channel exposes an `annotations` attribute of type `RestAnnotations`. @@ -38,6 +40,8 @@ ASSERT channel.annotations IS RestAnnotations ## RSAN1c6, RSAN1c1, RSAN1c2 — publish sends POST with ANNOTATION_CREATE to correct endpoint +**Test ID**: `rest/unit/RSAN1c6/publish-post-annotation-create-0` + | Spec | Requirement | |------|-------------| | RSAN1c6 | Body sent as POST to `/channels/{channelName}/messages/{messageSerial}/annotations` | @@ -95,6 +99,8 @@ ASSERT annotation["name"] == "like" ## RSAN1a3 — publish validates type is required +**Test ID**: `rest/unit/RSAN1a3/publish-type-required-0` + **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. @@ -126,6 +132,8 @@ ASSERT error.code == 40003 ## RSAN1c3 — annotation data encoded per RSL4 +**Test ID**: `rest/unit/RSAN1c3/annotation-data-encoded-0` + **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. @@ -171,6 +179,8 @@ ASSERT parse_json(annotation["data"]) == { "key": "value", "nested": { "a": 1 } ## RSAN1c4 — idempotent ID generated when enabled +**Test ID**: `rest/unit/RSAN1c4/idempotent-id-generated-0` + **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. @@ -223,6 +233,8 @@ ASSERT parts[1] == "0" ## RSAN1c4 — idempotent ID not generated when disabled +**Test ID**: `rest/unit/RSAN1c4/idempotent-id-not-generated-1` + **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. diff --git a/uts/rest/unit/channel/get_message.md b/uts/rest/unit/channel/get_message.md index 29b716a5b..e03aaca31 100644 --- a/uts/rest/unit/channel/get_message.md +++ b/uts/rest/unit/channel/get_message.md @@ -13,6 +13,8 @@ These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/help ## RSL11b — getMessage sends GET to correct endpoint +**Test ID**: `rest/unit/RSL11b/get-correct-endpoint-0` + **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. @@ -59,6 +61,8 @@ ASSERT request.body IS null OR request.body IS empty ## RSL11c — getMessage returns decoded Message +**Test ID**: `rest/unit/RSL11c/returns-decoded-message-0` + **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. @@ -113,6 +117,8 @@ ASSERT msg.version.serial == "version-serial-1" ## RSL11b — getMessage URL-encodes serial in path +**Test ID**: `rest/unit/RSL11b/url-encodes-serial-1` + **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. @@ -155,6 +161,8 @@ ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + " ## RSL11a — getMessage with missing serial throws error +**Test ID**: `rest/unit/RSL11a/missing-serial-error-0` + **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. diff --git a/uts/rest/unit/channel/history.md b/uts/rest/unit/channel/history.md index ac07f675f..e615bca4a 100644 --- a/uts/rest/unit/channel/history.md +++ b/uts/rest/unit/channel/history.md @@ -21,6 +21,8 @@ See rest_client.md for the complete `MockHttpClient` interface specification. ## RSL2a - History returns PaginatedResult +**Test ID**: `rest/unit/RSL2a/returns-paginated-result-0` + **Spec requirement:** The `history()` method must return a `PaginatedResult` object containing an array of `Message` objects. Tests that `history()` returns a `PaginatedResult` containing messages. @@ -67,6 +69,8 @@ ASSERT result.items[0].data == "data1" ## RSL2b - History query parameters +**Test ID**: `rest/unit/RSL2b/query-parameters-0` + **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. @@ -117,6 +121,8 @@ FOR EACH test_case IN test_cases: ## RSL2b1 - Default direction is backwards +**Test ID**: `rest/unit/RSL2b1/default-direction-backwards-0` + **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). @@ -158,6 +164,8 @@ IF "direction" IN request.url.query_params: ## RSL2b2 - Limit parameter +**Test ID**: `rest/unit/RSL2b2/limit-parameter-0` + **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. @@ -196,6 +204,8 @@ ASSERT request.url.query_params["limit"] == "10" ## RSL2b3 - Default limit is 100 +**Test ID**: `rest/unit/RSL2b3/default-limit-hundred-0` + **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. @@ -237,6 +247,8 @@ IF "limit" IN request.url.query_params: ## RSL2 - History request URL format +**Test ID**: `rest/unit/RSL2/request-url-format-0` + **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. @@ -287,6 +299,8 @@ FOR EACH test_case IN test_cases: ## RSL2 - History with time range +**Test ID**: `rest/unit/RSL2/history-time-range-1` + **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. diff --git a/uts/rest/unit/channel/idempotency.md b/uts/rest/unit/channel/idempotency.md index e0c9917c6..9bf00c891 100644 --- a/uts/rest/unit/channel/idempotency.md +++ b/uts/rest/unit/channel/idempotency.md @@ -21,6 +21,8 @@ See rest_client.md for the complete `MockHttpClient` interface specification. ## RSL1k1 - idempotentRestPublishing default +**Test ID**: `rest/unit/RSL1k1/idempotent-default-true-0` + **Spec requirement:** The `idempotentRestPublishing` client option must default to `true` for library versions >= 1.2. Tests the default value of `idempotentRestPublishing` option. @@ -43,6 +45,8 @@ ASSERT client.options.idempotentRestPublishing == true ## RSL1k2 - Message ID format when idempotent publishing enabled +**Test ID**: `rest/unit/RSL1k2/message-id-format-0` + **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. @@ -97,6 +101,8 @@ ASSERT parts[1] == "0" ## RSL1k2 - Serial increments for batch publish +**Test ID**: `rest/unit/RSL1k2/serial-increments-batch-1` + **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. @@ -157,6 +163,8 @@ ASSERT serials == [0, 1, 2] ## RSL1k3 - Separate publishes get unique base IDs +**Test ID**: `rest/unit/RSL1k3/unique-base-ids-0` + **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. @@ -204,6 +212,8 @@ ASSERT base1 != base2 ## RSL1k3 - No ID generated when idempotent publishing disabled +**Test ID**: `rest/unit/RSL1k3/no-id-when-disabled-1` + **Spec requirement:** When `idempotentRestPublishing` is false, the library must not automatically generate message IDs. Tests that message IDs are not automatically generated when disabled. @@ -247,6 +257,8 @@ ASSERT "id" NOT IN body ## RSL1k - Client-supplied ID preserved +**Test ID**: `rest/unit/RSL1k/client-id-preserved-0` + **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. @@ -292,6 +304,8 @@ ASSERT body["id"] == "my-custom-id" ## RSL1k2 - Same ID used on retry +**Test ID**: `rest/unit/RSL1k2/same-id-on-retry-2` + **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. @@ -345,6 +359,8 @@ ASSERT body1["id"] == body2["id"] ## RSL1k - Mixed client and library IDs in batch +**Test ID**: `rest/unit/RSL1k/mixed-ids-in-batch-1` + **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. diff --git a/uts/rest/unit/channel/message_versions.md b/uts/rest/unit/channel/message_versions.md index caa8aa4b9..6025ee72c 100644 --- a/uts/rest/unit/channel/message_versions.md +++ b/uts/rest/unit/channel/message_versions.md @@ -13,6 +13,8 @@ These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/help ## RSL14b — getMessageVersions sends GET to correct endpoint +**Test ID**: `rest/unit/RSL14b/get-correct-endpoint-0` + **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. @@ -68,6 +70,8 @@ ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + " ## RSL14c — getMessageVersions returns PaginatedResult of Messages +**Test ID**: `rest/unit/RSL14c/returns-paginated-result-0` + **Spec requirement:** RSL14c — Returns a `PaginatedResult`. Tests that the response is parsed into a paginated result of decoded messages. @@ -135,6 +139,8 @@ ASSERT result.items[1].action == MessageAction.MESSAGE_CREATE ## RSL14a — getMessageVersions passes params as querystring +**Test ID**: `rest/unit/RSL14a/params-as-querystring-0` + **Spec requirement:** RSL14a — Takes an optional second argument of `Dict` params. Tests that optional params are sent as query parameters. diff --git a/uts/rest/unit/channel/publish.md b/uts/rest/unit/channel/publish.md index d51a1c62f..72017cbc4 100644 --- a/uts/rest/unit/channel/publish.md +++ b/uts/rest/unit/channel/publish.md @@ -21,6 +21,8 @@ See rest_client.md for the complete `MockHttpClient` interface specification. ## RSL1a, RSL1b - Publish with name and data +**Test ID**: `rest/unit/RSL1a/publish-name-and-data-0` + | Spec | Requirement | |------|-------------| | RSL1a | Channel publish method must support publishing a single message with name and data | @@ -73,6 +75,8 @@ ASSERT body[0]["data"] == "hello" ## RSL1a, RSL1c - Publish with Message array +**Test ID**: `rest/unit/RSL1a/publish-message-array-1` + | Spec | Requirement | |------|-------------| | RSL1a | Channel publish method must support publishing an array of Message objects | @@ -130,6 +134,8 @@ ASSERT body[1]["data"] == { "key": "value" } ## RSL1e - Null name and data +**Test ID**: `rest/unit/RSL1e/null-name-and-data-0` + **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. @@ -177,6 +183,8 @@ FOR EACH test_case IN test_cases: ## RSL1h - publish(name, data) signature +**Test ID**: `rest/unit/RSL1h/publish-signature-0` + **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. @@ -221,6 +229,8 @@ ASSERT body[0]["data"] == "payload" ## RSL1i - Message size limit +**Test ID**: `rest/unit/RSL1i/message-size-limit-0` + **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. @@ -278,6 +288,8 @@ FOR EACH test_case IN test_cases: ## RSL1j - All Message attributes transmitted +**Test ID**: `rest/unit/RSL1j/all-attributes-transmitted-0` + **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. @@ -328,6 +340,8 @@ ASSERT body["extras"]["push"]["notification"]["title"] == "Test" ## RSL1l - Publish params as querystring +**Test ID**: `rest/unit/RSL1l/params-as-querystring-0` + **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. @@ -375,6 +389,8 @@ ASSERT request.url.query_params["anotherParam"] == "123" ## RSL1m - ClientId not set from library clientId +**Test ID**: `rest/unit/RSL1m/clientid-not-injected-0` + | Spec | Requirement | |------|-------------| | RSL1m1 | Library must not automatically inject its clientId into messages that don't have one | diff --git a/uts/rest/unit/channel/publish_result.md b/uts/rest/unit/channel/publish_result.md index d454d0010..e28498b63 100644 --- a/uts/rest/unit/channel/publish_result.md +++ b/uts/rest/unit/channel/publish_result.md @@ -13,6 +13,8 @@ These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/help ## RSL1n — publish() returns PublishResult with serials (single message) +**Test ID**: `rest/unit/RSL1n/publish-result-single-message-0` + | Spec | Requirement | |------|-------------| | RSL1n | On success, returns a `PublishResult` containing the serials of the published messages | @@ -55,6 +57,8 @@ ASSERT result.serials[0] == "serial-abc" ## RSL1n — publish() returns PublishResult with serials (batch) +**Test ID**: `rest/unit/RSL1n/publish-result-batch-serials-1` + **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. @@ -100,6 +104,8 @@ ASSERT result.serials[2] == "s3" ## RSL1n — publish() returns PublishResult with null serial (conflated message) +**Test ID**: `rest/unit/RSL1n/publish-result-null-serial-2` + | Spec | Requirement | |------|-------------| | PBR2a | A serial may be null if the message was discarded due to a configured conflation rule | diff --git a/uts/rest/unit/channel/rest_channel_attributes.md b/uts/rest/unit/channel/rest_channel_attributes.md index ba70406fc..f7f9b8671 100644 --- a/uts/rest/unit/channel/rest_channel_attributes.md +++ b/uts/rest/unit/channel/rest_channel_attributes.md @@ -1,6 +1,6 @@ # REST Channel Attributes and Methods -Spec points: `RSL7`, `RSL8`, `RSL8a`, `RSL9` +Spec points: `RSL7`, `RSL8`, `RSL8a`, `RSL9`, `CHD2`, `CHD2a`, `CHD2b`, `CHS2`, `CHS2a`, `CHS2b`, `CHO2`, `CHO2a`, `CHM2`, `CHM2a`, `CHM2b`, `CHM2c`, `CHM2d`, `CHM2e`, `CHM2f`, `CHM2g`, `CHM2h` ## Test Type Unit test with mocked HTTP client @@ -13,6 +13,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ## RSL9 - RestChannel name attribute +**Test ID**: `rest/unit/RSL9/channel-name-attribute-0` + **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. @@ -38,6 +40,8 @@ ASSERT channel2.name == "namespace:channel-name" ## RSL7 - setOptions updates channel options +**Test ID**: `rest/unit/RSL7/setoptions-updates-options-0` + **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. @@ -66,6 +70,8 @@ AWAIT channel.setOptions(RestChannelOptions()) ## RSL7 - setOptions stores new options +**Test ID**: `rest/unit/RSL7/setoptions-stores-options-1` + **Spec requirement:** `RestChannel#setOptions` sets or updates the stored channel options. Tests that options set via setOptions are retained and accessible. @@ -107,6 +113,8 @@ AWAIT channel.setOptions(RestChannelOptions()) ## RSL8 - status makes GET request to correct endpoint +**Test ID**: `rest/unit/RSL8/status-get-correct-endpoint-0` + **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. @@ -163,6 +171,8 @@ ASSERT captured_request.url.path ENDS_WITH "/channels/test-RSL8" ## RSL8 - status with special characters in channel name +**Test ID**: `rest/unit/RSL8/status-special-chars-encoded-1` + **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. @@ -219,6 +229,8 @@ ASSERT captured_request.url.path ENDS_WITH "/channels/" + encode_uri_component(" ## RSL8a - status returns ChannelDetails object +**Test ID**: `rest/unit/RSL8a/status-returns-channel-details-0` + **Spec requirement:** `RestChannel#status` returns a `ChannelDetails` object. Tests that the status() response is parsed into a ChannelDetails object with correct attributes. @@ -278,3 +290,170 @@ ASSERT result.status.occupancy.metrics.connections == 5 ASSERT result.status.occupancy.metrics.publishers == 2 ASSERT result.status.occupancy.metrics.subscribers == 3 ``` + +--- + +## CHD2, CHS2, CHO2, CHM2 - status() response parses all ChannelMetrics fields + +**Test ID**: `rest/unit/CHM2/parses-all-metrics-fields-0` + +| Spec | Requirement | +|------|-------------| +| CHD2 | ChannelDetails attributes: channelId (CHD2a), status (CHD2b) | +| CHS2 | ChannelStatus attributes: isActive (CHS2a), occupancy (CHS2b) | +| CHO2 | ChannelOccupancy attributes: metrics (CHO2a) | +| CHM2 | ChannelMetrics attributes: connections (CHM2a), presenceConnections (CHM2b), presenceMembers (CHM2c), presenceSubscribers (CHM2d), publishers (CHM2e), subscribers (CHM2f), objectPublishers (CHM2g), objectSubscribers (CHM2h) | + +Tests that status() parses the complete set of ChannelMetrics fields from the response, including the newer objectPublishers and objectSubscribers fields. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + "channelId": "test-CHM2-all-fields", + "status": { + "isActive": true, + "occupancy": { + "metrics": { + "connections": 10, + "presenceConnections": 7, + "presenceMembers": 4, + "presenceSubscribers": 3, + "publishers": 6, + "subscribers": 8, + "objectPublishers": 2, + "objectSubscribers": 5 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-CHM2-all-fields") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# CHD2a: channelId +ASSERT result.channelId == "test-CHM2-all-fields" + +# CHD2b + CHS2a: status.isActive +ASSERT result.status IS NOT null +ASSERT result.status.isActive == true + +# CHS2b + CHO2a: occupancy.metrics +ASSERT result.status.occupancy IS NOT null +ASSERT result.status.occupancy.metrics IS NOT null + +metrics = result.status.occupancy.metrics + +# CHM2a: connections +ASSERT metrics.connections == 10 + +# CHM2b: presenceConnections +ASSERT metrics.presenceConnections == 7 + +# CHM2c: presenceMembers +ASSERT metrics.presenceMembers == 4 + +# CHM2d: presenceSubscribers +ASSERT metrics.presenceSubscribers == 3 + +# CHM2e: publishers +ASSERT metrics.publishers == 6 + +# CHM2f: subscribers +ASSERT metrics.subscribers == 8 + +# CHM2g: objectPublishers +ASSERT metrics.objectPublishers == 2 + +# CHM2h: objectSubscribers +ASSERT metrics.objectSubscribers == 5 +``` + +--- + +## CHM2 - status() response with zero and missing metric fields + +**Test ID**: `rest/unit/CHM2/zero-and-missing-metrics-1` + +**Spec requirement:** ChannelMetrics fields (CHM2a-h) are integers. When the server response contains zero values or omits newer fields, the parsed result should default missing fields to 0. + +Tests that status() handles zero-valued and absent metric fields gracefully, defaulting missing fields to 0. + +### Setup +```pseudo +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + # Response omits objectPublishers and objectSubscribers (CHM2g, CHM2h) + # to simulate an older server that does not include these fields. + # All other metrics are explicitly zero. + req.respond_with(200, { + "channelId": "test-CHM2-defaults", + "status": { + "isActive": false, + "occupancy": { + "metrics": { + "connections": 0, + "presenceConnections": 0, + "presenceMembers": 0, + "presenceSubscribers": 0, + "publishers": 0, + "subscribers": 0 + } + } + } + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +channel = client.channels.get("test-CHM2-defaults") +``` + +### Test Steps +```pseudo +result = AWAIT channel.status() +``` + +### Assertions +```pseudo +# CHD2a: channelId +ASSERT result.channelId == "test-CHM2-defaults" + +# CHS2a: isActive can be false +ASSERT result.status.isActive == false + +metrics = result.status.occupancy.metrics + +# CHM2a-f: explicit zero values are parsed correctly +ASSERT metrics.connections == 0 +ASSERT metrics.presenceConnections == 0 +ASSERT metrics.presenceMembers == 0 +ASSERT metrics.presenceSubscribers == 0 +ASSERT metrics.publishers == 0 +ASSERT metrics.subscribers == 0 + +# CHM2g-h: missing fields default to 0 +ASSERT metrics.objectPublishers == 0 +ASSERT metrics.objectSubscribers == 0 +``` diff --git a/uts/rest/unit/channel/update_delete_message.md b/uts/rest/unit/channel/update_delete_message.md index fdab5a448..31428e156 100644 --- a/uts/rest/unit/channel/update_delete_message.md +++ b/uts/rest/unit/channel/update_delete_message.md @@ -13,6 +13,8 @@ These tests use the mock HTTP infrastructure defined in `uts/test/rest/unit/help ## RSL15b, RSL15b1 — updateMessage sends PATCH with action MESSAGE_UPDATE +**Test ID**: `rest/unit/RSL15b/update-sends-patch-update-0` + | Spec | Requirement | |------|-------------| | RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | @@ -63,6 +65,8 @@ ASSERT body["data"] == "new-data" ## RSL15b, RSL15b1 — deleteMessage sends PATCH with action MESSAGE_DELETE +**Test ID**: `rest/unit/RSL15b/delete-sends-patch-delete-1` + | Spec | Requirement | |------|-------------| | RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | @@ -109,6 +113,8 @@ ASSERT body["action"] == 2 # MESSAGE_DELETE numeric value ## RSL15b, RSL15b1 — appendMessage sends PATCH with action MESSAGE_APPEND +**Test ID**: `rest/unit/RSL15b/append-sends-patch-append-2` + | Spec | Requirement | |------|-------------| | RSL15b | The SDK must send a PATCH to `/channels/{channelName}/messages/{serial}` | @@ -156,6 +162,8 @@ ASSERT body["data"] == "appended-data" ## RSL15b7 — version set to MessageOperation when provided +**Test ID**: `rest/unit/RSL15b7/version-set-with-operation-0` + **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. @@ -203,6 +211,8 @@ ASSERT body["version"]["metadata"]["reason"] == "typo" ## RSL15b7 — version absent when no MessageOperation provided +**Test ID**: `rest/unit/RSL15b7/version-absent-no-operation-1` + **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. @@ -242,6 +252,8 @@ ASSERT "version" NOT IN body ## RSL15c — does not mutate user-supplied Message +**Test ID**: `rest/unit/RSL15c/no-mutate-user-message-0` + **Spec requirement:** RSL15c — The SDK must not mutate the user-supplied `Message` object. Tests that the original message object is unchanged after calling `updateMessage()`. @@ -287,6 +299,8 @@ ASSERT body["action"] == 1 # MESSAGE_UPDATE ## RSL15e — returns UpdateDeleteResult on success +**Test ID**: `rest/unit/RSL15e/returns-update-delete-result-0` + | Spec | Requirement | |------|-------------| | RSL15e | On success, returns an `UpdateDeleteResult` object | @@ -327,6 +341,8 @@ ASSERT result.versionSerial == "version-serial-abc" ## RSL15e — UpdateDeleteResult with null versionSerial +**Test ID**: `rest/unit/RSL15e/null-version-serial-1` + **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. @@ -364,6 +380,8 @@ ASSERT result.versionSerial IS null ## RSL15f — params sent as querystring +**Test ID**: `rest/unit/RSL15f/params-sent-as-querystring-0` + **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. @@ -405,6 +423,8 @@ ASSERT request.url.query_params["num"] == "42" ## RSL15a — serial required, throws error if missing +**Test ID**: `rest/unit/RSL15a/serial-required-throws-error-0` + **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. @@ -444,6 +464,8 @@ ASSERT error.code == 40003 ## RSL15d — request body encoded per RSL4 (message data encoding) +**Test ID**: `rest/unit/RSL15d/body-encoded-per-rsl4-0` + | Spec | Requirement | |------|-------------| | RSL15d | The request body must be encoded to the appropriate format per RSC8 | @@ -491,6 +513,8 @@ ASSERT parse_json(body["data"]) == { "key": "value" } ## RSL15b — serial URL-encoded in path +**Test ID**: `rest/unit/RSL15b/serial-url-encoded-path-3` + **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. diff --git a/uts/rest/unit/channels_collection.md b/uts/rest/unit/channels_collection.md index d3ff58456..8c98cb509 100644 --- a/uts/rest/unit/channels_collection.md +++ b/uts/rest/unit/channels_collection.md @@ -11,6 +11,8 @@ These tests verify the REST channels collection management functionality. No moc ## RSN1 - Channels collection accessible via RestClient +**Test ID**: `rest/unit/RSN1/channels-collection-accessible-0` + **Spec requirement:** `Channels` is a collection of `RestChannel` objects accessible through `RestClient#channels`. Tests that the Rest client exposes a channels collection. @@ -30,6 +32,8 @@ ASSERT client.channels IS NOT null ## RSN2 - Check if channel exists +**Test ID**: `rest/unit/RSN2/check-channel-exists-0` + **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. @@ -68,6 +72,8 @@ ASSERT exists_other == false ## RSN2 - Iterate through existing channels +**Test ID**: `rest/unit/RSN2/iterate-channels-1` + **Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. Tests that channels can be iterated. @@ -104,6 +110,8 @@ ASSERT length(channel_names) == 3 ## RSN3a - Get creates new channel if none exists +**Test ID**: `rest/unit/RSN3a/get-creates-new-channel-0` + **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 @@ -129,6 +137,8 @@ ASSERT client.channels.exists(channel_name) == true ## RSN3a - Get returns existing channel +**Test ID**: `rest/unit/RSN3a/get-returns-existing-channel-1` + **Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. ### Setup @@ -154,6 +164,8 @@ ASSERT channel1.name == channel_name ## RSN3a - Operator subscript creates or returns channel +**Test ID**: `rest/unit/RSN3a/subscript-creates-or-returns-2` + **Spec requirement:** Creates a new `RestChannel` object for the specified channel if none exists, or returns the existing channel. ### Setup @@ -181,6 +193,8 @@ ASSERT channel1.name == channel_name ## RSN4a - Release removes channel +**Test ID**: `rest/unit/RSN4a/release-removes-channel-0` + **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 @@ -207,6 +221,8 @@ ASSERT client.channels.exists(channel_name) == false ## RSN4b - Release on non-existent channel is no-op +**Test ID**: `rest/unit/RSN4b/release-nonexistent-noop-0` + **Spec requirement:** Calling `release()` with a channel name that does not correspond to an extant channel entity must return without error. ### Setup @@ -231,6 +247,8 @@ ASSERT client.channels.exists(channel_name) == false ## RSN3a - Get after release creates new channel +**Test ID**: `rest/unit/RSN3a/get-after-release-new-instance-3` + **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. diff --git a/uts/rest/unit/encoding/message_encoding.md b/uts/rest/unit/encoding/message_encoding.md index 6ef793b8e..089e2005a 100644 --- a/uts/rest/unit/encoding/message_encoding.md +++ b/uts/rest/unit/encoding/message_encoding.md @@ -23,6 +23,8 @@ Tests should use the encoding fixtures from `ably-common` where available for cr ## RSL4a - String data encoding +**Test ID**: `rest/unit/RSL4a/string-data-no-encoding-0` + **Spec requirement:** String data must be transmitted without transformation and without an encoding field. ### Setup @@ -64,6 +66,8 @@ ASSERT "encoding" NOT IN body OR body["encoding"] IS null ## RSL4b - JSON object encoding +**Test ID**: `rest/unit/RSL4b/json-object-encoding-0` + **Spec requirement:** JSON objects must be serialized to a JSON string with `encoding: "json"`. ### Setup @@ -107,6 +111,8 @@ ASSERT body["encoding"] == "json" ## RSL4c - Binary data encoding with JSON protocol +**Test ID**: `rest/unit/RSL4c/binary-base64-json-protocol-0` + **Spec requirement:** Binary data must be base64-encoded when using JSON protocol. ### Setup @@ -149,6 +155,8 @@ ASSERT base64_decode(body["data"]) == bytes([0x00, 0x01, 0x02, 0xFF, 0xFE]) ## RSL4c - Binary data with MessagePack protocol +**Test ID**: `rest/unit/RSL4c/binary-direct-msgpack-protocol-1` + **Spec requirement:** Binary data must be transmitted directly (without base64 encoding) when using MessagePack protocol. ### Setup @@ -192,6 +200,8 @@ ASSERT "encoding" NOT IN body OR body["encoding"] IS null ## RSL4d - Array data encoding +**Test ID**: `rest/unit/RSL4d/array-json-encoding-0` + **Spec requirement:** Arrays must be JSON-encoded with `encoding: "json"`. ### Setup @@ -233,6 +243,8 @@ ASSERT parse_json(body["data"]) == [1, 2, "three", { "four": 4 }] ## RSL6a - Decoding base64 data +**Test ID**: `rest/unit/RSL6a/decode-base64-to-binary-0` + **Spec requirement:** Data with `encoding: "base64"` must be decoded to binary, and the encoding field consumed. ### Setup @@ -277,6 +289,8 @@ ASSERT message.encoding IS null # Encoding consumed after decode ## RSL6a - Decoding JSON data +**Test ID**: `rest/unit/RSL6a/decode-json-to-object-1` + **Spec requirement:** Data with `encoding: "json"` must be decoded from JSON string to native object, and the encoding field consumed. ### Setup @@ -321,6 +335,8 @@ ASSERT message.encoding IS null ## RSL6a - Decoding chained encodings +**Test ID**: `rest/unit/RSL6a/decode-chained-encodings-2` + **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 @@ -369,6 +385,8 @@ ASSERT message.encoding IS null ## RSL6b - Unrecognized encoding preserved +**Test ID**: `rest/unit/RSL6b/unrecognized-encoding-preserved-0` + **Spec requirement:** Unrecognized encoding values must be preserved in the encoding field, with only recognized encodings being decoded. ### Setup @@ -415,6 +433,8 @@ ASSERT message.data IS bytes # Result of base64 decode ## RSL6 - Decoding binary data from MessagePack response +**Test ID**: `rest/unit/RSL6/msgpack-binary-stays-binary-0` + **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 @@ -471,6 +491,8 @@ string. ## RSL6 - Decoding string data from MessagePack response +**Test ID**: `rest/unit/RSL6/msgpack-string-stays-string-1` + **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 @@ -514,6 +536,8 @@ ASSERT message.encoding IS null ## RSL4 - Encoding fixtures from ably-common +**Test ID**: `rest/unit/RSL4/encoding-fixtures-ably-common-0` + **Spec requirement:** Implementations must correctly encode data according to standardized test fixtures from `ably-common`. ### Setup @@ -564,6 +588,8 @@ FOR EACH fixture IN encoding_fixtures: ### RSL4 - Null data encoding +**Test ID**: `rest/unit/RSL4/null-data-no-encoding-1` + **Spec requirement:** Null values must be transmitted without transformation. ### Setup @@ -605,6 +631,8 @@ ASSERT "encoding" NOT IN body OR body["encoding"] IS null ### RSL4a - Number data type rejected +**Test ID**: `rest/unit/RSL4a/number-type-rejected-1` + **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 @@ -634,6 +662,8 @@ ASSERT error IS NOT null ### RSL4a - Boolean data type rejected +**Test ID**: `rest/unit/RSL4a/boolean-type-rejected-2` + **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 @@ -663,6 +693,8 @@ ASSERT error IS NOT null ### RSL6 - Decoding UTF-8 encoded data +**Test ID**: `rest/unit/RSL6/decode-utf8-base64-data-2` + **Spec requirement:** Data with `encoding: "utf-8/base64"` must decode base64 first, then interpret as UTF-8 string. ### Setup @@ -708,6 +740,8 @@ ASSERT message.encoding IS null ### RSL6 - Complex chained encoding +**Test ID**: `rest/unit/RSL6/complex-chained-encoding-3` + **Spec requirement:** Multiple encoding layers must be decoded in correct order. ### Setup @@ -761,6 +795,8 @@ ASSERT message.encoding IS null ### RSL4 - JSON protocol uses correct Content-Type +**Test ID**: `rest/unit/RSL4/json-protocol-content-type-2` + **Spec requirement:** When `useBinaryProtocol: false`, requests must use `Content-Type: application/json`. ### Setup @@ -800,6 +836,8 @@ ASSERT request.headers["Accept"] == "application/json" ### RSL4 - MessagePack protocol uses correct Content-Type +**Test ID**: `rest/unit/RSL4/msgpack-protocol-content-type-3` + **Spec requirement:** When `useBinaryProtocol: true`, requests must use `Content-Type: application/x-msgpack`. ### Setup @@ -841,6 +879,8 @@ ASSERT request.headers["Accept"] == "application/x-msgpack" ### RSL4 - Empty string encoding +**Test ID**: `rest/unit/RSL4/empty-string-no-encoding-4` + **Spec requirement:** Empty strings must be transmitted as empty strings without encoding. ### Setup @@ -882,6 +922,8 @@ ASSERT "encoding" NOT IN body OR body["encoding"] IS null ### RSL4 - Empty array encoding +**Test ID**: `rest/unit/RSL4/empty-array-json-encoding-5` + **Spec requirement:** Empty arrays must be JSON-encoded. ### Setup diff --git a/uts/rest/unit/encoding/msgpack_interop.md b/uts/rest/unit/encoding/msgpack_interop.md new file mode 100644 index 000000000..c56484027 --- /dev/null +++ b/uts/rest/unit/encoding/msgpack_interop.md @@ -0,0 +1,111 @@ +# MessagePack Interoperability Tests + +Spec points: `RSL6a3` + +## Test Type +Unit test — no server or mock needed. Operates on static fixture data. + +## Fixtures +Tests use `ably-common/test-resources/msgpack_test_fixtures.json`. + +Each fixture has: +- `name`: human-readable description +- `data`: the expected decoded data value +- `numRepeat`: if > 0, the expected data is `data` repeated `numRepeat` times +- `type`: one of `"string"`, `"binary"`, `"jsonArray"`, `"jsonObject"` +- `encoding`: the encoding field on the wire message (empty string means none) +- `msgpack`: base64-encoded msgpack bytes of an entire ProtocolMessage + +The ProtocolMessage contains a single Message in its `messages` array. The `data` +field in the fixture describes the expected decoded content of that message. + +--- + +## RSL6a3 - Decode binary-encoded protocol messages using interop fixtures + +**Spec requirement:** A set of tests should exist to ensure that the client library +can successfully encode and decode binary encoded protocol messages. + +### Setup +```pseudo +fixtures = load_json("ably-common/test-resources/msgpack_test_fixtures.json") +``` + +### Test: each fixture decodes correctly +```pseudo +FOR EACH fixture IN fixtures: + # 1. Decode the msgpack ProtocolMessage + msgpack_bytes = base64_decode(fixture["msgpack"]) + protocol_message = msgpack_deserialize(msgpack_bytes) + + # 2. Extract the first (only) message + wire_message = protocol_message["messages"][0] + + # 3. Build the expected data + IF fixture["type"] == "string": + IF fixture["numRepeat"] > 0: + expected = fixture["data"] * fixture["numRepeat"] # repeat string + ELSE: + expected = fixture["data"] + END + ELSE IF fixture["type"] == "binary": + raw_string = fixture["data"] * fixture["numRepeat"] + expected = encode_utf8(raw_string) # Uint8List / byte array + ELSE IF fixture["type"] == "jsonArray" OR fixture["type"] == "jsonObject": + expected = fixture["data"] # native array or map + END + + # 4. Decode the wire message using the standard decoding pipeline + message = Message.fromMap(wire_message) + + # 5. Verify + ASSERT message.data == expected + ASSERT message.encoding IS null # all encoding consumed +END +``` + +### Assertions per fixture type + +**String fixtures** (`type == "string"`): +- `message.data` is a String equal to `fixture["data"]` repeated `fixture["numRepeat"]` times + +**Binary fixtures** (`type == "binary"`): +- The wire message has `encoding: "base64"` and base64-encoded `data` +- After decoding, `message.data` is a byte array (Uint8List) +- The byte content equals the UTF-8 encoding of `fixture["data"]` repeated `fixture["numRepeat"]` times + +**JSON fixtures** (`type == "jsonArray"` or `type == "jsonObject"`): +- The wire message has `encoding: "json"` and JSON-encoded `data` +- After decoding, `message.data` is a native List or Map matching `fixture["data"]` + +--- + +## RSL6a3 - Re-encode decoded messages back to msgpack (round-trip) + +### Test: each fixture round-trips through encode/decode +```pseudo +FOR EACH fixture IN fixtures: + # 1. Decode the original + msgpack_bytes = base64_decode(fixture["msgpack"]) + protocol_message = msgpack_deserialize(msgpack_bytes) + wire_message = protocol_message["messages"][0] + + # 2. Decode to a Message + message = Message.fromMap(wire_message) + + # 3. Re-encode the message for msgpack wire format + re_encoded = message.toMap(useBinaryProtocol: true) + + # 4. Wrap in a ProtocolMessage and serialize + re_pm = { "messages": [re_encoded], "msgSerial": 0 } + re_bytes = msgpack_serialize(re_pm) + + # 5. Deserialize and decode again + re_pm2 = msgpack_deserialize(re_bytes) + re_message = Message.fromMap(re_pm2["messages"][0]) + + # 6. Verify round-trip + ASSERT re_message.data == message.data + ASSERT re_message.encoding IS null +END +``` diff --git a/uts/rest/unit/fallback.md b/uts/rest/unit/fallback.md index 998194aa8..c0e98b9ab 100644 --- a/uts/rest/unit/fallback.md +++ b/uts/rest/unit/fallback.md @@ -18,6 +18,8 @@ Fallback tests require the mock to support: ## RSC15m - Fallback only when fallback domains non-empty +**Test ID**: `rest/unit/RSC15m/no-fallback-empty-hosts-0` + **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. @@ -45,6 +47,8 @@ ASSERT error.statusCode == 500 ## RSC15a - Fallback hosts tried in random order +**Test ID**: `rest/unit/RSC15a/fallback-random-order-0` + **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. @@ -97,6 +101,8 @@ ASSERT ALL host IN fallback_hosts_used: host IN expected_fallbacks ## RSC15l - Qualifying errors trigger fallback +**Test ID**: `rest/unit/RSC15l/qualifying-errors-trigger-fallback-0` + | Spec | Requirement | |------|-------------| | RSC15l1 | Host unreachable errors trigger fallback | @@ -170,6 +176,8 @@ ASSERT mock_http.captured_requests.length == 2 ## RSC15l4 - CloudFront errors trigger fallback +**Test ID**: `rest/unit/RSC15l4/cloudfront-error-triggers-fallback-0` + **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. @@ -206,6 +214,8 @@ These tests verify that fallback behavior works correctly for different network ### RSC15l - Connection refused triggers fallback +**Test ID**: `rest/unit/RSC15l/connection-refused-fallback-0` + ```pseudo request_count = 0 @@ -236,6 +246,8 @@ ASSERT request_count == 2 ### RSC15l - DNS error triggers fallback +**Test ID**: `rest/unit/RSC15l/dns-error-fallback-1` + ```pseudo request_count = 0 @@ -265,6 +277,8 @@ ASSERT request_count == 2 ### RSC15l - Connection timeout triggers fallback +**Test ID**: `rest/unit/RSC15l/connection-timeout-fallback-2` + ```pseudo request_count = 0 @@ -297,6 +311,8 @@ ASSERT request_count == 2 ### RSC15l - Request timeout triggers fallback +**Test ID**: `rest/unit/RSC15l/request-timeout-fallback-3` + ```pseudo request_count = 0 captured_hosts = [] @@ -333,6 +349,8 @@ ASSERT captured_hosts[0] != captured_hosts[1] ### RSC15l - HTTP 5xx errors trigger fallback +**Test ID**: `rest/unit/RSC15l/http-5xx-triggers-fallback-4` + ```pseudo FOR EACH status_code IN [500, 501, 502, 503, 504]: request_count = 0 @@ -359,6 +377,8 @@ FOR EACH status_code IN [500, 501, 502, 503, 504]: ### RSC15l - HTTP 4xx errors do NOT trigger fallback +**Test ID**: `rest/unit/RSC15l/http-4xx-no-fallback-5` + ```pseudo FOR EACH status_code IN [400, 401, 404]: request_count = 0 @@ -385,6 +405,8 @@ FOR EACH status_code IN [400, 401, 404]: ## RSC15j - Host header matches request host +**Test ID**: `rest/unit/RSC15j/host-header-matches-request-0` + **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. @@ -418,6 +440,8 @@ ASSERT request_1.headers["Host"] != request_2.headers["Host"] ## RSC15f - Successful fallback host cached +**Test ID**: `rest/unit/RSC15f/successful-fallback-cached-0` + **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. @@ -465,6 +489,8 @@ ASSERT mock_http.captured_requests[2].url.host == "main.a.fallback.ably-realtime ## RSC15f - Cached fallback expires after timeout +**Test ID**: `rest/unit/RSC15f/cached-fallback-expires-1` + **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`. @@ -505,10 +531,96 @@ ASSERT mock_http.captured_requests[2].url.host == "main.realtime.ably.net" --- +## RSC15f - Expired preferred fallback host not resurrected by late in-flight success + +**Test ID**: `rest/unit/RSC15f/expired-not-resurrected-2` + +**Spec requirement:** After `fallbackRetryTimeout` has elapsed the preference must be un-stored and future requests must restart the fallback sequence from the primary host. A late-arriving successful response against the previously-preferred fallback must not re-establish it as the preference. + +Tests that a request that completes successfully against a fallback *after* `fallbackRetryTimeout` has expired does not re-pin that fallback as the preferred host. + +### Setup +```pseudo +mock_http = MockHttpClient() + +# Request handler: primary fails on first attempt, all others succeed. +# Second request (to cached fallback) is NOT responded to immediately — +# we hold the PendingRequest and respond later, after the timeout expires. +held_request = null +request_index = 0 + +mock_http.onRequest = (req) => + request_index += 1 + if request_index == 1 + # First request to primary — fail to trigger fallback + req.respond_with(500, { "error": { "message": "fail", "code": 50000, "statusCode": 500 } }) + else if request_index == 2 + # First fallback — succeed, caches this host + req.respond_with(200, [1000]) + else if request_index == 3 + # Second request goes to cached fallback — hold it (don't respond yet) + held_request = req + else + # All subsequent requests — succeed + req.respond_with(200, [1000]) + +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + fallbackRetryTimeout: 100 # 100ms for testing +)) +``` + +### Test Steps +```pseudo +# Request 1+2: primary fails → fallback succeeds → fallback cached +AWAIT client.time() + +# Request 3: goes to cached fallback, but we hold the response +request_future = client.time() # starts but does not complete + +# Advance time past fallbackRetryTimeout so the cache expires +WAIT 150 milliseconds + +# Request 4: cache expired → should try primary again +AWAIT client.time() + +# Now let the held request (3) complete successfully +held_request.respond_with(200, [1000]) +AWAIT request_future + +# Request 5: the late success from request 3 must NOT have re-pinned +# the fallback — this request should go to primary again +AWAIT client.time() +``` + +### Assertions +```pseudo +ASSERT mock_http.captured_requests.length == 5 + +# Requests 1+2: primary fail → fallback success +ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" +ASSERT mock_http.captured_requests[1].url.host != "main.realtime.ably.net" + +fallback_host = mock_http.captured_requests[1].url.host + +# Request 3: went to cached fallback (held, not yet responded) +ASSERT mock_http.captured_requests[2].url.host == fallback_host + +# Request 4: after timeout expiry, primary is tried again +ASSERT mock_http.captured_requests[3].url.host == "main.realtime.ably.net" + +# Request 5: late success from request 3 did NOT re-pin fallback +ASSERT mock_http.captured_requests[4].url.host == "main.realtime.ably.net" +``` + +--- + # REC1 - Primary Domain Configuration ## REC1a - Default primary domain +**Test ID**: `rest/unit/REC1a/default-primary-domain-0` + **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. @@ -537,6 +649,8 @@ ASSERT mock_http.captured_requests[0].url.host == "main.realtime.ably.net" ## REC1b2 - Endpoint option as explicit hostname (with period) +**Test ID**: `rest/unit/REC1b2/explicit-hostname-with-period-0` + Tests that when `endpoint` contains a period (`.`), it's treated as an explicit hostname. ### Setup @@ -564,6 +678,8 @@ ASSERT mock_http.captured_requests[0].url.host == "custom.ably.example.com" ## REC1b2 - Endpoint option as localhost +**Test ID**: `rest/unit/REC1b2/endpoint-localhost-1` + Tests that `endpoint: "localhost"` is treated as an explicit hostname. ### Setup @@ -591,6 +707,8 @@ ASSERT mock_http.captured_requests[0].url.host == "localhost" ## REC1b2 - Endpoint option as IPv6 address +**Test ID**: `rest/unit/REC1b2/endpoint-ipv6-address-2` + Tests that `endpoint` containing `::` is treated as an explicit hostname (IPv6). ### Setup @@ -620,6 +738,8 @@ ASSERT mock_http.captured_requests[0].url.host == "::1" OR ## REC1b3 - Endpoint option as nonprod routing policy +**Test ID**: `rest/unit/REC1b3/nonprod-routing-policy-0` + Tests that `endpoint: "nonprod:[id]"` resolves to `[id].realtime.ably-nonprod.net`. ### Setup @@ -647,6 +767,8 @@ ASSERT mock_http.captured_requests[0].url.host == "staging.realtime.ably-nonprod ## REC1b4 - Endpoint option as production routing policy +**Test ID**: `rest/unit/REC1b4/production-routing-policy-0` + Tests that `endpoint: "[id]"` (without period or nonprod prefix) resolves to `[id].realtime.ably.net`. ### Setup @@ -656,7 +778,7 @@ mock_http.queue_response(200, { "time": 1234567890000 }) client = Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox" + endpoint: "test" )) ``` @@ -667,13 +789,15 @@ AWAIT client.time() ### Assertions ```pseudo -ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +ASSERT mock_http.captured_requests[0].url.host == "test.realtime.ably.net" ``` --- ## REC1b1 - Endpoint conflicts with deprecated environment option +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-environment-0` + Tests that specifying both `endpoint` and `environment` is invalid. ### Setup @@ -685,7 +809,7 @@ Tests that specifying both `endpoint` and `environment` is invalid. ```pseudo Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox", + endpoint: "test", environment: "production" # Deprecated, conflicts with endpoint )) FAILS WITH error ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" @@ -695,6 +819,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC1b1 - Endpoint conflicts with deprecated restHost option +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-resthost-1` + Tests that specifying both `endpoint` and `restHost` is invalid. ### Setup @@ -706,7 +832,7 @@ Tests that specifying both `endpoint` and `restHost` is invalid. ```pseudo Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox", + endpoint: "test", 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" @@ -716,6 +842,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC1b1 - Endpoint conflicts with deprecated realtimeHost option +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-realtimehost-2` + Tests that specifying both `endpoint` and `realtimeHost` is invalid. ### Setup @@ -727,7 +855,7 @@ Tests that specifying both `endpoint` and `realtimeHost` is invalid. ```pseudo Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox", + endpoint: "test", 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" @@ -737,6 +865,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC1b1 - Endpoint conflicts with deprecated fallbackHostsUseDefault option +**Test ID**: `rest/unit/REC1b1/endpoint-conflicts-fallback-default-3` + Tests that specifying both `endpoint` and `fallbackHostsUseDefault` is invalid. ### Setup @@ -748,7 +878,7 @@ Tests that specifying both `endpoint` and `fallbackHostsUseDefault` is invalid. ```pseudo Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox", + endpoint: "test", fallbackHostsUseDefault: true # Deprecated, conflicts with endpoint )) FAILS WITH error ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message CONTAINS "conflict" @@ -758,6 +888,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC1c2 - Deprecated environment option determines primary domain +**Test ID**: `rest/unit/REC1c2/environment-sets-primary-domain-0` + Tests that the deprecated `environment` option sets primary domain to `[id].realtime.ably.net`. ### Setup @@ -785,6 +917,8 @@ ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" ## REC1c1 - Environment conflicts with restHost +**Test ID**: `rest/unit/REC1c1/environment-conflicts-resthost-0` + Tests that specifying both `environment` and `restHost` is invalid. ### Setup @@ -806,6 +940,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC1c1 - Environment conflicts with realtimeHost +**Test ID**: `rest/unit/REC1c1/environment-conflicts-realtimehost-1` + Tests that specifying both `environment` and `realtimeHost` is invalid. ### Setup @@ -827,6 +963,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC1d1 - Deprecated restHost option determines primary domain +**Test ID**: `rest/unit/REC1d1/resthost-sets-primary-domain-0` + Tests that the deprecated `restHost` option sets the primary domain. ### Setup @@ -854,6 +992,8 @@ ASSERT mock_http.captured_requests[0].url.host == "custom.rest.example.com" ## REC1d2 - Deprecated realtimeHost option determines primary domain (when restHost not set) +**Test ID**: `rest/unit/REC1d2/realtimehost-sets-primary-domain-0` + Tests that `realtimeHost` sets primary domain when `restHost` is not specified. ### Setup @@ -881,6 +1021,8 @@ ASSERT mock_http.captured_requests[0].url.host == "custom.realtime.example.com" ## REC1d - restHost takes precedence over realtimeHost +**Test ID**: `rest/unit/REC1d/resthost-precedence-over-realtimehost-0` + Tests that when both `restHost` and `realtimeHost` are specified, `restHost` is used for REST requests. ### Setup @@ -912,6 +1054,8 @@ ASSERT mock_http.captured_requests[0].url.host == "rest.example.com" ## REC2c1 - Default fallback domains +**Test ID**: `rest/unit/REC2c1/default-fallback-domains-0` + **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. @@ -953,6 +1097,8 @@ ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks ## REC2a2 - Custom fallbackHosts option +**Test ID**: `rest/unit/REC2a2/custom-fallback-hosts-0` + Tests that the `fallbackHosts` option overrides default fallbacks. ### Setup @@ -983,6 +1129,8 @@ ASSERT mock_http.captured_requests[1].url.host IN ["fb1.example.com", "fb2.examp ## REC2a1 - fallbackHosts conflicts with fallbackHostsUseDefault +**Test ID**: `rest/unit/REC2a1/fallback-hosts-conflicts-use-default-0` + Tests that specifying both `fallbackHosts` and `fallbackHostsUseDefault` is invalid. ### Setup @@ -1004,6 +1152,8 @@ ASSERT error.code == 40000 OR error.message CONTAINS "invalid" OR error.message ## REC2b - Deprecated fallbackHostsUseDefault option +**Test ID**: `rest/unit/REC2b/fallback-hosts-use-default-0` + Tests that `fallbackHostsUseDefault: true` uses the default fallback domains. ### Setup @@ -1044,6 +1194,8 @@ ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks ## REC2c2 - Explicit hostname endpoint has no fallbacks +**Test ID**: `rest/unit/REC2c2/explicit-hostname-no-fallbacks-0` + Tests that when `endpoint` is an explicit hostname, fallback domains are empty. ### Setup @@ -1074,6 +1226,8 @@ ASSERT mock_http.captured_requests[0].url.host == "custom.ably.example.com" ## REC2c3 - Nonprod routing policy fallback domains +**Test ID**: `rest/unit/REC2c3/nonprod-fallback-domains-0` + Tests that nonprod routing policy has corresponding nonprod fallback domains. ### Setup @@ -1112,6 +1266,8 @@ ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks ## REC2c4 - Production routing policy fallback domains (via endpoint) +**Test ID**: `rest/unit/REC2c4/production-endpoint-fallback-domains-0` + Tests that production routing policy via `endpoint` has corresponding fallback domains. ### Setup @@ -1122,7 +1278,7 @@ mock_http.queue_response(200, { "time": 1234567890000 }) client = Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox" + endpoint: "test" )) ``` @@ -1134,14 +1290,14 @@ AWAIT client.time() ### Assertions ```pseudo ASSERT mock_http.captured_requests.length == 2 -ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +ASSERT mock_http.captured_requests[0].url.host == "test.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" + "test.a.fallback.ably-realtime.com", + "test.b.fallback.ably-realtime.com", + "test.c.fallback.ably-realtime.com", + "test.d.fallback.ably-realtime.com", + "test.e.fallback.ably-realtime.com" ] ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks ``` @@ -1150,6 +1306,8 @@ ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks ## REC2c5 - Production routing policy fallback domains (via deprecated environment) +**Test ID**: `rest/unit/REC2c5/production-environment-fallback-domains-0` + Tests that production routing policy via deprecated `environment` has corresponding fallback domains. ### Setup @@ -1188,6 +1346,8 @@ ASSERT mock_http.captured_requests[1].url.host IN expected_fallbacks ## REC2c6 - Custom restHost has no fallbacks +**Test ID**: `rest/unit/REC2c6/custom-resthost-no-fallbacks-0` + Tests that deprecated `restHost` option results in no fallback domains. ### Setup @@ -1218,6 +1378,8 @@ ASSERT mock_http.captured_requests[0].url.host == "custom.rest.example.com" ## REC2c6 - Custom realtimeHost has no fallbacks +**Test ID**: `rest/unit/REC2c6/custom-realtimehost-no-fallbacks-1` + Tests that deprecated `realtimeHost` option results in no fallback domains. ### Setup @@ -1250,6 +1412,8 @@ ASSERT mock_http.captured_requests[0].url.host == "custom.realtime.example.com" ## REC3a - Default connectivity check URL +**Test ID**: `rest/unit/REC3a/default-connectivity-check-url-0` + Tests that the default connectivity check URL is `https://internet-up.ably-realtime.com/is-the-internet-up.txt`. ### Note @@ -1291,6 +1455,8 @@ CLOSE_CLIENT(client) ## REC3b - Custom connectivity check URL +**Test ID**: `rest/unit/REC3b/custom-connectivity-check-url-0` + Tests that the `connectivityCheckUrl` option overrides the default. ### Setup @@ -1334,6 +1500,8 @@ CLOSE_CLIENT(client) ## REC3 - Connectivity check response validation +**Test ID**: `rest/unit/REC3/connectivity-check-validation-0` + Tests that the connectivity check expects a specific response. ### Test Cases diff --git a/uts/rest/unit/logging.md b/uts/rest/unit/logging.md index 21377d416..c50a21b21 100644 --- a/uts/rest/unit/logging.md +++ b/uts/rest/unit/logging.md @@ -29,6 +29,8 @@ LogHandler(level: LogLevel, message: String, context: Map) ## RSC2 - Default log level is warn +**Test ID**: `rest/unit/RSC2/default-log-level-warn-0` + **Spec requirement:** The default log level is `warn`. Only `error` and `warn` level events should be emitted when the default level is used. @@ -59,6 +61,8 @@ ASSERT ALL log IN captured_logs: log.level IN [error, warn] ## TO3b - Log level can be changed +**Test ID**: `rest/unit/TO3b/log-level-changeable-0` + **Spec requirement:** The log level can be changed via `ClientOptions.logLevel`. Setting the level to `verbose` should capture all log events. @@ -99,6 +103,8 @@ ASSERT ANY log IN debug_logs: log.message CONTAINS "HTTP request" ## TO3c - Custom log handler receives structured events +**Test ID**: `rest/unit/TO3c/custom-handler-structured-events-0` + **Spec requirement:** A custom log handler provided via `ClientOptions.logHandler` receives structured log events with level, message, and context. @@ -135,6 +141,8 @@ ASSERT ANY log IN captured_logs: log.context IS NOT EMPTY ## TO3c2 - Structured context contains expected keys +**Test ID**: `rest/unit/TO3c2/context-contains-expected-keys-0` + **Spec requirement:** The structured context map contains relevant key-value pairs for the log event. HTTP request logs include method, host, and path. @@ -170,6 +178,8 @@ ASSERT "path" IN http_logs[0].context ## RSC2b - LogLevel.none produces no log events +**Test ID**: `rest/unit/RSC2b/log-level-none-suppresses-all-0` + **Spec requirement:** Setting log level to `none` should suppress all log output. ### Setup diff --git a/uts/rest/unit/presence/rest_presence.md b/uts/rest/unit/presence/rest_presence.md index 3a6fbb550..2eac62e18 100644 --- a/uts/rest/unit/presence/rest_presence.md +++ b/uts/rest/unit/presence/rest_presence.md @@ -22,6 +22,8 @@ The mock supports: ### RSP1a, RSL3 - Presence accessible via RestChannel#presence +**Test ID**: `rest/unit/RSP1a/presence-channel-attribute-0` + **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 @@ -36,6 +38,8 @@ And the presence object is associated with channel_name ### RSP1b - Same presence object returned for same channel +**Test ID**: `rest/unit/RSP1b/same-instance-returned-0` + **Spec requirement:** The same `RestPresence` instance must be returned for multiple accesses to the same channel's presence property. ```pseudo @@ -53,6 +57,8 @@ Then the same RestPresence instance is returned each time ### RSP3a - Get sends GET request to presence endpoint +**Test ID**: `rest/unit/RSP3a/get-request-endpoint-0` + **Spec requirement:** The `get` method sends a GET request to `/channels//presence` and returns a `PaginatedResult`. ### Setup @@ -95,6 +101,8 @@ ASSERT result.items.length == 2 ### RSP3b - Get returns PresenceMessage objects +**Test ID**: `rest/unit/RSP3b/get-returns-presence-messages-0` + **Spec requirement:** The response items must be decoded into `PresenceMessage` objects with all fields correctly populated. ### Setup @@ -143,6 +151,8 @@ ASSERT result.items[0].timestamp == 1234567890000 ### RSP3c - Get with no members returns empty list +**Test ID**: `rest/unit/RSP3c/get-empty-members-0` + **Spec requirement:** When no presence members exist, `get` returns an empty list in the `PaginatedResult`. ### Setup @@ -178,6 +188,8 @@ ASSERT result.hasNext() == false ### RSP3a1a - Get with limit parameter +**Test ID**: `rest/unit/RSP3a1/get-limit-parameter-0` + **Spec requirement:** The `limit` parameter must be included in the query string when specified. ### Setup @@ -214,6 +226,8 @@ ASSERT captured_requests[0].url.query_params["limit"] == "50" ### RSP3a1b - Get limit defaults to 100 +**Test ID**: `rest/unit/RSP3a1/get-limit-default-100-1` + **Spec requirement:** When no limit is specified, the default limit of 100 is used (or not explicitly sent). ### Setup @@ -248,6 +262,8 @@ ASSERT "limit" NOT IN captured_requests[0].url.query_params ### RSP3a1c - Get limit maximum is 1000 +**Test ID**: `rest/unit/RSP3a1/get-limit-max-1000-2` + **Spec requirement:** The maximum allowed limit value is 1000. ### Setup @@ -281,6 +297,8 @@ ASSERT captured_requests[0].url.query_params["limit"] == "1000" ### RSP3a2 - Get with clientId filter +**Test ID**: `rest/unit/RSP3a2/get-clientid-filter-0` + **Spec requirement:** The `clientId` parameter filters presence members by client identifier. ### Setup @@ -316,6 +334,8 @@ ASSERT captured_requests[0].url.query_params["clientId"] == "specific-client" ### RSP3a3 - Get with connectionId filter +**Test ID**: `rest/unit/RSP3a3/get-connectionid-filter-0` + **Spec requirement:** The `connectionId` parameter filters presence members by connection identifier. ### Setup @@ -351,6 +371,8 @@ ASSERT captured_requests[0].url.query_params["connectionId"] == "conn123" ### RSP3 - Get with multiple filters +**Test ID**: `rest/unit/RSP3/get-multiple-filters-0` + **Spec requirement:** Multiple query parameters can be combined in a single request. ### Setup @@ -392,6 +414,8 @@ ASSERT captured_requests[0].url.query_params["connectionId"] == "conn1" ### RSP4a - History sends GET request to presence history endpoint +**Test ID**: `rest/unit/RSP4a/history-request-endpoint-0` + | Spec | Requirement | |------|-------------| | RSP4 | History method fetches presence event history | @@ -433,6 +457,8 @@ ASSERT result IS PaginatedResult ### RSP4a - History returns PaginatedResult of PresenceMessage +**Test ID**: `rest/unit/RSP4a/history-returns-paginated-1` + **Spec requirement:** History responses contain `PresenceMessage` objects with various action types. ### Setup @@ -474,6 +500,8 @@ ASSERT result.items[2].action == PresenceAction.update # action 4 ### RSP4b1a - History with start parameter +**Test ID**: `rest/unit/RSP4b1/history-start-parameter-0` + **Spec requirement:** The `start` parameter filters events from a given timestamp (inclusive). ### Setup @@ -508,6 +536,8 @@ ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" ### RSP4b1b - History with end parameter +**Test ID**: `rest/unit/RSP4b1/history-end-parameter-1` + **Spec requirement:** The `end` parameter filters events up to a given timestamp (inclusive). ### Setup @@ -542,6 +572,8 @@ ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" ### RSP4b1c - History with start and end parameters +**Test ID**: `rest/unit/RSP4b1/history-start-end-params-2` + **Spec requirement:** Start and end parameters can be combined to define a time range. ### Setup @@ -581,6 +613,8 @@ ASSERT captured_requests[0].url.query_params["end"] == "1609545600000" ### RSP4b1d - History accepts DateTime objects for start/end +**Test ID**: `rest/unit/RSP4b1/history-datetime-objects-3` + **Spec requirement:** Language-specific DateTime objects should be accepted and converted to milliseconds since epoch. ### Setup @@ -616,6 +650,8 @@ ASSERT captured_requests[0].url.query_params["start"] == "1609459200000" ### RSP4b2a - History with direction backwards (default) +**Test ID**: `rest/unit/RSP4b2/history-direction-backwards-default-0` + **Spec requirement:** The default direction is `backwards` (newest first). ### Setup @@ -650,6 +686,8 @@ ASSERT "direction" NOT IN captured_requests[0].url.query_params ### RSP4b2b - History with direction forwards +**Test ID**: `rest/unit/RSP4b2/history-direction-forwards-1` + **Spec requirement:** The `direction` parameter can be set to `forwards` (oldest first). ### Setup @@ -683,6 +721,8 @@ ASSERT captured_requests[0].url.query_params["direction"] == "forwards" ### RSP4b2c - History with direction backwards explicit +**Test ID**: `rest/unit/RSP4b2/history-direction-backwards-explicit-2` + **Spec requirement:** The `direction` parameter can be explicitly set to `backwards`. ### Setup @@ -716,6 +756,8 @@ ASSERT captured_requests[0].url.query_params["direction"] == "backwards" ### RSP4b3a - History with limit parameter +**Test ID**: `rest/unit/RSP4b3/history-limit-parameter-0` + **Spec requirement:** The `limit` parameter controls the maximum number of results per page. ### Setup @@ -749,6 +791,8 @@ ASSERT captured_requests[0].url.query_params["limit"] == "50" ### RSP4b3b - History limit defaults to 100 +**Test ID**: `rest/unit/RSP4b3/history-limit-default-100-1` + **Spec requirement:** When no limit is specified, the default is 100. ### Setup @@ -783,6 +827,8 @@ ASSERT "limit" NOT IN captured_requests[0].url.query_params ### RSP4b3c - History limit maximum is 1000 +**Test ID**: `rest/unit/RSP4b3/history-limit-max-1000-2` + **Spec requirement:** The maximum allowed limit is 1000. ### Setup @@ -816,6 +862,8 @@ ASSERT captured_requests[0].url.query_params["limit"] == "1000" ### RSP4 - History with all parameters +**Test ID**: `rest/unit/RSP4/history-all-parameters-0` + **Spec requirement:** All query parameters can be combined in a single request. ### Setup @@ -859,6 +907,8 @@ ASSERT captured_requests[0].url.query_params["limit"] == "50" ### RSP5a - String data decoded as string +**Test ID**: `rest/unit/RSP5/decode-string-data-0` + **Spec requirement:** Plain string data must be decoded without modification. ### Setup @@ -895,6 +945,8 @@ ASSERT result.items[0].data IS String ### RSP5b - JSON encoded data decoded to object +**Test ID**: `rest/unit/RSP5/decode-json-data-1` + **Spec requirement:** Data with `encoding: "json"` must be decoded from JSON string to native object. ### Setup @@ -938,6 +990,8 @@ ASSERT result.items[0].encoding == null # encoding consumed ### RSP5c - Base64 encoded data decoded to binary +**Test ID**: `rest/unit/RSP5/decode-base64-binary-2` + **Spec requirement:** Data with `encoding: "base64"` must be decoded from base64 to binary. ### Setup @@ -980,6 +1034,8 @@ ASSERT result.items[0].encoding == null # encoding consumed ### RSP5 - Binary presence data decoded from MessagePack response +**Test ID**: `rest/unit/RSP5/decode-msgpack-binary-3` + **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 @@ -1025,6 +1081,8 @@ ASSERT result.items[0].encoding IS null ### RSP5d - UTF-8 encoded data decoded correctly +**Test ID**: `rest/unit/RSP5/decode-utf8-data-4` + **Spec requirement:** Data with `encoding: "utf-8/base64"` must be decoded through both layers. ### Setup @@ -1066,6 +1124,8 @@ ASSERT result.items[0].data IS String ### RSP5e - Chained encoding decoded in order +**Test ID**: `rest/unit/RSP5/decode-chained-encoding-5` + **Spec requirement:** Chained encodings (e.g., `json/base64`) must be decoded in reverse order (last applied, first removed). ### Setup @@ -1108,6 +1168,8 @@ ASSERT result.items[0].data["key"] == "value" ### RSP5f - History messages also decoded +**Test ID**: `rest/unit/RSP5/decode-history-messages-6` + **Spec requirement:** Encoding decoding applies to both `get` and `history` methods. ### Setup @@ -1149,6 +1211,8 @@ ASSERT result.items[0].data["event"] == "entered" ### RSP5g - Cipher decoding with channel options +**Test ID**: `rest/unit/RSP5/decode-cipher-channel-7` + **Spec requirement:** Encrypted data with cipher encoding must be decrypted using channel cipher options. ### Setup @@ -1199,6 +1263,8 @@ ASSERT result.items[0].data IS Object/Map ### RSP_Pagination_1 - Get returns paginated result with Link header +**Test ID**: `rest/unit/RSP3/get-pagination-link-header-1` + **Spec requirement:** Responses with Link headers must support pagination via `hasNext()` and `next()`. ### Setup @@ -1241,6 +1307,8 @@ ASSERT result.hasNext() == true ### RSP_Pagination_2 - Get next page fetches from Link URL +**Test ID**: `rest/unit/RSP3/get-pagination-next-page-2` + **Spec requirement:** Calling `next()` must use the URL from the Link header to fetch the next page. ### Setup @@ -1286,6 +1354,8 @@ ASSERT page2.hasNext() == false ### RSP_Pagination_3 - History pagination works the same +**Test ID**: `rest/unit/RSP4/history-pagination-1` + **Spec requirement:** History results must support the same pagination behavior as get. ### Setup @@ -1332,6 +1402,8 @@ ASSERT page2.items[0].action == PresenceAction.leave ### RSP_Error_1 - Get with server error throws AblyException +**Test ID**: `rest/unit/RSP3/get-server-error-3` + **Spec requirement:** Server errors must be raised as `AblyException` with appropriate error code and status. ### Setup @@ -1368,6 +1440,8 @@ ASSERT error.statusCode == 500 ### RSP_Error_2 - History with invalid auth throws AblyException +**Test ID**: `rest/unit/RSP4/history-auth-error-2` + **Spec requirement:** Authentication errors must raise `AblyException` with code 40101. ### Setup @@ -1404,6 +1478,8 @@ ASSERT error.statusCode == 401 ### RSP_Error_3 - Get with channel not found +**Test ID**: `rest/unit/RSP3/get-channel-not-found-4` + **Spec requirement:** 404 responses must raise `AblyException` with code 40400. ### Setup @@ -1442,6 +1518,8 @@ ASSERT error.statusCode == 404 ### RSP_Headers_1 - Get includes standard headers +**Test ID**: `rest/unit/RSP3/get-standard-headers-5` + **Spec requirement:** All REST requests must include standard Ably headers (X-Ably-Version, Ably-Agent, Accept). ### Setup @@ -1477,6 +1555,8 @@ ASSERT "Accept" IN captured_requests[0].headers ### RSP_Headers_2 - History includes authorization header +**Test ID**: `rest/unit/RSP4/history-auth-header-3` + **Spec requirement:** Authenticated requests must include the Authorization header. ### Setup @@ -1511,6 +1591,8 @@ ASSERT captured_requests[0].headers["Authorization"] starts with "Basic " ### RSP_Headers_3 - Request ID included when enabled +**Test ID**: `rest/unit/RSP3/get-request-id-enabled-6` + **Spec requirement:** When `addRequestIds` is enabled, a unique `request_id` query parameter must be included. ### Setup @@ -1550,6 +1632,8 @@ ASSERT captured_requests[0].url.query_params["request_id"] IS NOT empty ### RSP_Action_1 - All presence actions correctly mapped +**Test ID**: `rest/unit/RSP5/presence-action-mapping-8` + **Spec requirement:** All presence action values must be correctly mapped between wire protocol and SDK types. ### Setup diff --git a/uts/rest/unit/push/push_admin_publish.md b/uts/rest/unit/push/push_admin_publish.md index 79232cc7e..7b13c3e22 100644 --- a/uts/rest/unit/push/push_admin_publish.md +++ b/uts/rest/unit/push/push_admin_publish.md @@ -13,6 +13,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ## RSH1 — client.push.admin exposes PushAdmin object +**Test ID**: `rest/unit/RSH1/push-admin-accessible-0` + **Spec requirement:** RSH1 — `Push#admin` object provides the PushAdmin interface. Tests that the REST client exposes a `push.admin` object of the correct type. @@ -40,6 +42,8 @@ ASSERT client.push.admin.channelSubscriptions IS PushChannelSubscriptions ## RSH1a — publish sends POST to /push/publish +**Test ID**: `rest/unit/RSH1a/publish-post-push-publish-0` + **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. @@ -95,6 +99,8 @@ ASSERT body["notification"]["body"] == "Hello" ## RSH1a — publish with clientId recipient +**Test ID**: `rest/unit/RSH1a/publish-clientid-recipient-1` + **Spec requirement:** RSH1a — Tests should exist with valid recipient details. Tests that publish works with a `clientId` recipient. @@ -142,6 +148,8 @@ ASSERT body["data"]["key"] == "value" ## RSH1a — publish with deviceId recipient +**Test ID**: `rest/unit/RSH1a/publish-deviceid-recipient-2` + **Spec requirement:** RSH1a — Tests should exist with valid recipient details. Tests that publish works with a `deviceId` recipient. @@ -189,6 +197,8 @@ ASSERT body["notification"]["title"] == "Device Push" ## RSH1a — publish rejects empty recipient +**Test ID**: `rest/unit/RSH1a/rejects-empty-recipient-3` + **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. @@ -225,6 +235,8 @@ ASSERT captured_requests.length == 0 ## RSH1a — publish rejects empty data +**Test ID**: `rest/unit/RSH1a/rejects-empty-data-4` + **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. @@ -261,6 +273,8 @@ ASSERT captured_requests.length == 0 ## RSH1a — publish rejects null recipient +**Test ID**: `rest/unit/RSH1a/rejects-null-recipient-5` + **Spec requirement:** RSH1a — Empty values for `recipient` should be immediately rejected. Tests that calling publish with a null recipient throws an error. @@ -296,6 +310,8 @@ ASSERT captured_requests.length == 0 ## RSH1a — publish propagates server error +**Test ID**: `rest/unit/RSH1a/server-error-propagated-6` + **Spec requirement:** RSH1a — Tests should exist with invalid recipient details. Tests that a server error response is propagated to the caller. diff --git a/uts/rest/unit/push/push_channel_subscriptions.md b/uts/rest/unit/push/push_channel_subscriptions.md index 588d528b8..3eaf2ef5a 100644 --- a/uts/rest/unit/push/push_channel_subscriptions.md +++ b/uts/rest/unit/push/push_channel_subscriptions.md @@ -13,6 +13,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ## RSH1c1 — list returns paginated PushChannelSubscription filtered by channel +**Test ID**: `rest/unit/RSH1c1/list-filtered-by-channel-0` + **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`. @@ -68,6 +70,8 @@ ASSERT result.items[1].clientId == "client-abc" ## RSH1c1 — list filters by deviceId and clientId +**Test ID**: `rest/unit/RSH1c1/list-filtered-by-device-client-1` + **Spec requirement:** RSH1c1 — A test should exist filtering by `deviceId` and/or `clientId`. Tests that `list()` forwards `deviceId` and `clientId` as query parameters. @@ -112,6 +116,8 @@ ASSERT result.items.length == 1 ## RSH1c1 — list supports limit for pagination +**Test ID**: `rest/unit/RSH1c1/list-with-limit-param-2` + **Spec requirement:** RSH1c1 — A test should exist controlling the pagination with the `limit` attribute. Tests that `list()` forwards the `limit` parameter. @@ -151,6 +157,8 @@ ASSERT captured_requests[0].url.queryParams["limit"] == "5" ## RSH1c2 — listChannels returns paginated channel names +**Test ID**: `rest/unit/RSH1c2/list-channels-paginated-0` + **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. @@ -195,6 +203,8 @@ ASSERT result.items[2] == "channel-3" ## RSH1c2 — listChannels supports limit and pagination +**Test ID**: `rest/unit/RSH1c2/list-channels-with-limit-1` + **Spec requirement:** RSH1c2 — A test should exist using the `limit` attribute and pagination. Tests that `listChannels()` forwards the `limit` parameter. @@ -230,6 +240,8 @@ ASSERT result.items.length == 1 ## RSH1c3 — save issues POST with PushChannelSubscription +**Test ID**: `rest/unit/RSH1c3/save-post-subscription-0` + **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`. @@ -284,6 +296,8 @@ ASSERT result.deviceId == "device-001" ## RSH1c3 — save updates existing subscription +**Test ID**: `rest/unit/RSH1c3/save-updates-existing-1` + **Spec requirement:** RSH1c3 — A test should exist for a successful subsequent save with an update. Tests that saving an existing subscription performs an update. @@ -335,6 +349,8 @@ ASSERT result2.channel == "my-channel" ## RSH1c3 — save propagates server error +**Test ID**: `rest/unit/RSH1c3/save-error-propagated-2` + **Spec requirement:** RSH1c3 — A test should exist for a failed save operation. Tests that a server error during save is propagated to the caller. @@ -374,6 +390,8 @@ ASSERT error.statusCode == 400 ## RSH1c4 — remove issues DELETE with clientId subscription attributes +**Test ID**: `rest/unit/RSH1c4/remove-delete-clientid-0` + **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. @@ -419,6 +437,8 @@ ASSERT request.url.queryParams["clientId"] == "client-abc" ## RSH1c4 — remove issues DELETE with deviceId subscription attributes +**Test ID**: `rest/unit/RSH1c4/remove-delete-deviceid-1` + **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. @@ -461,6 +481,8 @@ ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" ## RSH1c4 — remove succeeds for nonexistent subscription +**Test ID**: `rest/unit/RSH1c4/remove-nonexistent-succeeds-2` + **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. @@ -493,6 +515,8 @@ AWAIT client.push.admin.channelSubscriptions.remove(subscription) ## RSH1c5 — removeWhere issues DELETE with clientId param +**Test ID**: `rest/unit/RSH1c5/remove-where-clientid-0` + **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. @@ -532,6 +556,8 @@ ASSERT request.url.queryParams["clientId"] == "client-abc" ## RSH1c5 — removeWhere issues DELETE with deviceId param +**Test ID**: `rest/unit/RSH1c5/remove-where-deviceid-1` + **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. @@ -568,6 +594,8 @@ ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" ## RSH1c5 — removeWhere succeeds with no matching subscriptions +**Test ID**: `rest/unit/RSH1c5/remove-where-no-match-succeeds-2` + **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. diff --git a/uts/rest/unit/push/push_channels.md b/uts/rest/unit/push/push_channels.md new file mode 100644 index 000000000..6d8c8299d --- /dev/null +++ b/uts/rest/unit/push/push_channels.md @@ -0,0 +1,540 @@ +# PushChannel Tests + +Spec points: `RSH7`, `RSH7a`, `RSH7a1`, `RSH7a2`, `RSH7a3`, `RSH7b`, `RSH7b1`, `RSH7b2`, `RSH7c`, `RSH7c1`, `RSH7c2`, `RSH7c3`, `RSH7d`, `RSH7d1`, `RSH7d2`, `RSH7e` + +## 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. + +## Notes + +These tests cover the `PushChannel` interface (`RSH7`), which is the `push` field on `RestChannel` and `RealtimeChannel`. This is distinct from the `push.admin.channelSubscriptions` API (`RSH1c`) — the `PushChannel` methods operate from the perspective of the local device (the push target), not the admin API. + +The `PushChannel` methods require access to a `LocalDevice` (`RSH8`) which represents the current device's push registration state. In unit tests, the `LocalDevice` is configured with test values to simulate a registered device. + +Push device authentication (`RSH6`) means adding either an `X-Ably-DeviceToken` header (if the device has a `deviceIdentityToken`, per `RSH6a`) or an `X-Ably-DeviceSecret` header (if the device has a `deviceSecret`, per `RSH6b`). + +--- + +## RSH7a1, RSH7a2, RSH7a3 — subscribeDevice sends POST with deviceId, channel name, and device auth + +**Test ID**: `rest/unit/RSH7a2/subscribe-device-post-0` + +| Spec | Requirement | +|------|-------------| +| RSH7a1 | Fails if the LocalDevice doesn't have a deviceIdentityToken | +| RSH7a2 | Performs a POST request to /push/channelSubscriptions with the device's id and the channel name | +| RSH7a3 | The request must include push device authentication | + +Tests that `subscribeDevice()` sends a POST to `/push/channelSubscriptions` with the device's `id` and the channel name in the request body, and includes the `X-Ably-DeviceToken` header for push device authentication. + +### 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": "test-device-001" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device as a registered push target +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.subscribeDevice() +``` + +### 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"] == "test-device-001" + +# RSH7a3 + RSH6a — push device authentication via deviceIdentityToken +ASSERT request.headers["X-Ably-DeviceToken"] == "test-device-identity-token" +``` + +--- + +## RSH7a1 — subscribeDevice fails if no deviceIdentityToken + +**Test ID**: `rest/unit/RSH7a1/subscribe-device-no-token-fails-0` + +**Spec requirement:** RSH7a1 — Fails if the LocalDevice doesn't have a `deviceIdentityToken`, ie. it isn't registered yet. + +Tests that `subscribeDevice()` fails when the local device has no `deviceIdentityToken`. + +### 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")) + +# Configure the local device WITHOUT a deviceIdentityToken (not registered) +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: null, + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.subscribeDevice() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "deviceIdentityToken" +``` + +--- + +## RSH7b1, RSH7b2 — subscribeClient sends POST with clientId and channel name + +**Test ID**: `rest/unit/RSH7b2/subscribe-client-post-0` + +| Spec | Requirement | +|------|-------------| +| RSH7b1 | Fails if the LocalDevice doesn't have a clientId | +| RSH7b2 | Performs a POST request to /push/channelSubscriptions with the device's clientId and the channel name | + +Tests that `subscribeClient()` sends a POST to `/push/channelSubscriptions` with the device's `clientId` and the channel name in the request body. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, { + "channel": "my-channel", + "clientId": "test-client" + }) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device with a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.subscribeClient() +``` + +### 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["clientId"] == "test-client" +``` + +--- + +## RSH7b1 — subscribeClient fails if no clientId + +**Test ID**: `rest/unit/RSH7b1/subscribe-client-no-clientid-fails-0` + +**Spec requirement:** RSH7b1 — Fails if the LocalDevice doesn't have a `clientId`. + +Tests that `subscribeClient()` fails when the local device has no `clientId`. + +### 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")) + +# Configure the local device WITHOUT a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.subscribeClient() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "clientId" +``` + +--- + +## RSH7c1, RSH7c2, RSH7c3 — unsubscribeDevice sends DELETE with deviceId, channel name, and device auth + +**Test ID**: `rest/unit/RSH7c2/unsubscribe-device-delete-0` + +| Spec | Requirement | +|------|-------------| +| RSH7c1 | Fails if the LocalDevice doesn't have a deviceIdentityToken | +| RSH7c2 | Performs a DELETE request to /push/channelSubscriptions with the device's id and the channel name | +| RSH7c3 | The request must include push device authentication | + +Tests that `unsubscribeDevice()` sends a DELETE to `/push/channelSubscriptions` with the device's `id` and the channel name as query parameters, and includes the `X-Ably-DeviceToken` header for push device authentication. + +### 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")) + +# Configure the local device as a registered push target +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.unsubscribeDevice() +``` + +### 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["deviceId"] == "test-device-001" + +# RSH7c3 + RSH6a — push device authentication via deviceIdentityToken +ASSERT request.headers["X-Ably-DeviceToken"] == "test-device-identity-token" +``` + +--- + +## RSH7c1 — unsubscribeDevice fails if no deviceIdentityToken + +**Test ID**: `rest/unit/RSH7c1/unsubscribe-device-no-token-fails-0` + +**Spec requirement:** RSH7c1 — Fails if the LocalDevice doesn't have a `deviceIdentityToken`, ie. it isn't registered yet. + +Tests that `unsubscribeDevice()` fails when the local device has no `deviceIdentityToken`. + +### 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")) + +# Configure the local device WITHOUT a deviceIdentityToken (not registered) +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: null, + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.unsubscribeDevice() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "deviceIdentityToken" +``` + +--- + +## RSH7d1, RSH7d2 — unsubscribeClient sends DELETE with clientId and channel name + +**Test ID**: `rest/unit/RSH7d2/unsubscribe-client-delete-0` + +| Spec | Requirement | +|------|-------------| +| RSH7d1 | Fails if the LocalDevice doesn't have a clientId | +| RSH7d2 | Performs a DELETE request to /push/channelSubscriptions with the device's clientId and the channel name | + +Tests that `unsubscribeClient()` sends a DELETE to `/push/channelSubscriptions` with the device's `clientId` and the channel name 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(204, null) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device with a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +AWAIT channel.push.unsubscribeClient() +``` + +### 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"] == "test-client" +``` + +--- + +## RSH7d1 — unsubscribeClient fails if no clientId + +**Test ID**: `rest/unit/RSH7d1/unsubscribe-client-no-clientid-fails-0` + +**Spec requirement:** RSH7d1 — Fails if the LocalDevice doesn't have a `clientId`. + +Tests that `unsubscribeClient()` fails when the local device has no `clientId`. + +### 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")) + +# Configure the local device WITHOUT a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps and Assertions +```pseudo +AWAIT channel.push.unsubscribeClient() FAILS WITH error +ASSERT error.code IS NOT null +ASSERT error.message CONTAINS "clientId" +``` + +--- + +## RSH7e — listSubscriptions sends GET with channel, deviceId, clientId, and concatFilters + +**Test ID**: `rest/unit/RSH7e/list-subscriptions-with-filters-0` + +**Spec requirement:** RSH7e — `#listSubscriptions(params)` performs a GET request to `/push/channelSubscriptions` and returns a paginated result with `PushChannelSubscription` objects filtered by the provided params, the channel name, the device ID, and the client ID if it exists, as supported by the REST API. A `concatFilters` param needs to be set to `true` as well. + +Tests that `listSubscriptions()` sends a GET to `/push/channelSubscriptions` with the channel name, device ID, client ID (if present), any user-provided params, and `concatFilters=true`. + +### 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": "test-device-001" + }, + { + "channel": "my-channel", + "clientId": "test-client" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device with both deviceId and clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: "test-client" +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +result = AWAIT channel.push.listSubscriptions({"limit": "10"}) +``` + +### Assertions +```pseudo +ASSERT captured_requests.length == 1 + +request = captured_requests[0] +ASSERT request.method == "GET" +ASSERT request.url.path == "/push/channelSubscriptions" + +# Channel name, device ID, and client ID are automatically included +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["deviceId"] == "test-device-001" +ASSERT request.url.queryParams["clientId"] == "test-client" + +# concatFilters must be set to true +ASSERT request.url.queryParams["concatFilters"] == "true" + +# User-provided params are forwarded +ASSERT request.url.queryParams["limit"] == "10" + +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 == "test-device-001" +ASSERT result.items[1].clientId == "test-client" +``` + +--- + +## RSH7e — listSubscriptions omits clientId when LocalDevice has no clientId + +**Test ID**: `rest/unit/RSH7e/list-subscriptions-omits-clientid-1` + +**Spec requirement:** RSH7e — The client ID is included if it exists. + +Tests that `listSubscriptions()` does not include `clientId` in the query parameters when the local device has no `clientId`. + +### 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": "test-device-001" + } + ]) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) + +# Configure the local device WITHOUT a clientId +client.device = LocalDevice( + id: "test-device-001", + deviceIdentityToken: "test-device-identity-token", + clientId: null +) + +channel = client.channels.get("my-channel") +``` + +### Test Steps +```pseudo +result = AWAIT channel.push.listSubscriptions({}) +``` + +### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.queryParams["channel"] == "my-channel" +ASSERT request.url.queryParams["deviceId"] == "test-device-001" +ASSERT request.url.queryParams["concatFilters"] == "true" +ASSERT "clientId" NOT IN request.url.queryParams + +ASSERT result.items.length == 1 +``` diff --git a/uts/rest/unit/push/push_device_registrations.md b/uts/rest/unit/push/push_device_registrations.md index c9d500031..d433d2290 100644 --- a/uts/rest/unit/push/push_device_registrations.md +++ b/uts/rest/unit/push/push_device_registrations.md @@ -13,6 +13,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ## RSH1b1 — get returns DeviceDetails for known device +**Test ID**: `rest/unit/RSH1b1/get-device-details-0` + **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`. @@ -70,6 +72,8 @@ ASSERT device.push.state == "Active" ## RSH1b1 — get returns error for unknown device +**Test ID**: `rest/unit/RSH1b1/get-unknown-device-error-1` + **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. @@ -104,6 +108,8 @@ ASSERT error.statusCode == 404 ## RSH1b1 — get URL-encodes deviceId +**Test ID**: `rest/unit/RSH1b1/get-url-encodes-deviceid-2` + **Spec requirement:** RSH1b1 — `#get(deviceId)` performs a request to `/push/deviceRegistrations/:deviceId`. Tests that the deviceId is properly URL-encoded in the request path. @@ -146,6 +152,8 @@ ASSERT captured_requests[0].url.path == "/push/deviceRegistrations/" + encode_ur ## RSH1b2 — list returns paginated DeviceDetails filtered by deviceId +**Test ID**: `rest/unit/RSH1b2/list-filtered-by-deviceid-0` + **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`. @@ -198,6 +206,8 @@ ASSERT result.items[0].id == "device-001" ## RSH1b2 — list returns paginated DeviceDetails filtered by clientId +**Test ID**: `rest/unit/RSH1b2/list-filtered-by-clientid-1` + **Spec requirement:** RSH1b2 — A test should exist filtering by `clientId`. Tests that `list()` sends a GET with `clientId` filter. @@ -250,6 +260,8 @@ ASSERT result.items[1].clientId == "client-abc" ## RSH1b2 — list supports limit for pagination +**Test ID**: `rest/unit/RSH1b2/list-with-limit-param-2` + **Spec requirement:** RSH1b2 — A test should exist controlling the pagination with the `limit` attribute. Tests that `list()` forwards the `limit` parameter. @@ -291,6 +303,8 @@ ASSERT captured_requests[0].url.queryParams["limit"] == "2" ## RSH1b3 — save issues PUT with DeviceDetails +**Test ID**: `rest/unit/RSH1b3/save-put-device-details-0` + **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`. @@ -360,6 +374,8 @@ ASSERT result.push.state == "Active" ## RSH1b3 — save updates existing device +**Test ID**: `rest/unit/RSH1b3/save-updates-existing-1` + **Spec requirement:** RSH1b3 — A test should exist for a successful subsequent save with an update. Tests that `save()` can update an already-registered device. @@ -437,6 +453,8 @@ ASSERT request_count == 2 ## RSH1b3 — save propagates server error +**Test ID**: `rest/unit/RSH1b3/save-error-propagated-2` + **Spec requirement:** RSH1b3 — A test should exist for a failed save operation. Tests that a server error during save is propagated to the caller. @@ -478,6 +496,8 @@ ASSERT error.statusCode == 400 ## RSH1b4 — remove issues DELETE for device +**Test ID**: `rest/unit/RSH1b4/remove-delete-device-0` + **Spec requirement:** RSH1b4 — `#remove(deviceId)` issues a `DELETE` request to `/push/deviceRegistrations/:deviceId`. Tests that `remove()` sends a DELETE request with the correct path. @@ -516,6 +536,8 @@ ASSERT request.url.path == "/push/deviceRegistrations/" + encode_uri_component(" ## RSH1b4 — remove succeeds for nonexistent device +**Test ID**: `rest/unit/RSH1b4/remove-nonexistent-succeeds-1` + **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. @@ -543,6 +565,8 @@ AWAIT client.push.admin.deviceRegistrations.remove("nonexistent-device") ## RSH1b5 — removeWhere issues DELETE with clientId param +**Test ID**: `rest/unit/RSH1b5/remove-where-clientid-0` + **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. @@ -582,6 +606,8 @@ ASSERT request.url.queryParams["clientId"] == "client-abc" ## RSH1b5 — removeWhere issues DELETE with deviceId param +**Test ID**: `rest/unit/RSH1b5/remove-where-deviceid-1` + **Spec requirement:** RSH1b5 — A test should exist that deletes devices by `deviceId`. Tests that `removeWhere()` sends a DELETE with `deviceId` as a query parameter. @@ -618,6 +644,8 @@ ASSERT captured_requests[0].url.queryParams["deviceId"] == "device-001" ## RSH1b5 — removeWhere succeeds with no matching devices +**Test ID**: `rest/unit/RSH1b5/remove-where-no-match-succeeds-2` + **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. diff --git a/uts/rest/unit/request.md b/uts/rest/unit/request.md index b657240ba..4971775f7 100644 --- a/uts/rest/unit/request.md +++ b/uts/rest/unit/request.md @@ -23,6 +23,8 @@ The `request()` method provides a generic way to make HTTP requests to Ably endp ## RSC19f - Method signature supports required HTTP methods +**Test ID**: `rest/unit/RSC19f/supports-http-methods-0` + **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. @@ -70,6 +72,8 @@ FOR EACH test_case IN test_cases: ## RSC19f - Query parameters passed correctly +**Test ID**: `rest/unit/RSC19f/query-params-passed-1` + **Spec requirement:** The `params` argument must add query parameters to the request URL. Tests that the params argument adds URL query parameters. @@ -110,6 +114,8 @@ ASSERT request.url.query_params["direction"] == "backwards" ## RSC19f - Custom headers passed correctly +**Test ID**: `rest/unit/RSC19f/custom-headers-passed-2` + **Spec requirement:** The `headers` argument must add custom HTTP headers to the request. Tests that the headers argument adds custom HTTP headers. @@ -150,6 +156,8 @@ ASSERT request.headers["X-Another"] == "another-value" ## RSC19f - Request body sent correctly +**Test ID**: `rest/unit/RSC19f/request-body-sent-3` + **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. @@ -194,6 +202,8 @@ ASSERT body["data"] == "payload" ## RSC19f1 - X-Ably-Version header uses explicit version parameter +**Test ID**: `rest/unit/RSC19f1/version-param-sets-header-0` + Tests that the version parameter sets the X-Ably-Version header. ### Setup @@ -227,6 +237,8 @@ FOR EACH test_case IN test_cases: ## RSC19b - Uses configured authentication +**Test ID**: `rest/unit/RSC19b/uses-configured-auth-0` + **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. @@ -301,6 +313,8 @@ ASSERT request.headers["Authorization"] STARTS_WITH "Bearer " ## RSC19c - Protocol headers set correctly (JSON) +**Test ID**: `rest/unit/RSC19c/protocol-headers-json-0` + Tests that Accept and Content-Type headers reflect the configured protocol. ### Setup @@ -333,6 +347,8 @@ ASSERT request.headers["Content-Type"] == "application/json" ## RSC19c - Protocol headers set correctly (MsgPack) +**Test ID**: `rest/unit/RSC19c/protocol-headers-msgpack-1` + Tests that Accept and Content-Type headers reflect MsgPack protocol when configured. ### Setup @@ -365,6 +381,8 @@ ASSERT request.headers["Content-Type"] == "application/x-msgpack" ## RSC19c - Request body encoded according to protocol +**Test ID**: `rest/unit/RSC19c/body-encoded-per-protocol-2` + Tests that the request body is encoded using the configured protocol. ### Test Case 1: JSON encoding @@ -431,6 +449,8 @@ ASSERT body["data"] == "value" ## RSC19c - Response body decoded according to Content-Type +**Test ID**: `rest/unit/RSC19c/response-decoded-by-content-type-3` + Tests that the response body is automatically decoded based on Content-Type header. ### Test Case 1: JSON response @@ -488,6 +508,8 @@ ASSERT items[0]["id"] == "1" ## RSC19d, HP4 - HttpPaginatedResponse provides status code +**Test ID**: `rest/unit/RSC19d/response-status-code-0` + | Spec | Requirement | |------|-------------| | RSC19d | Request returns HttpPaginatedResponse | @@ -531,6 +553,8 @@ FOR EACH test_case IN test_cases: ## RSC19d, HP5 - HttpPaginatedResponse provides success indicator +**Test ID**: `rest/unit/RSC19d/response-success-indicator-1` + Tests that the success property correctly reflects 2xx status codes. ### Setup @@ -571,6 +595,8 @@ FOR EACH test_case IN test_cases: ## RSC19d, HP6 - HttpPaginatedResponse provides error code from header +**Test ID**: `rest/unit/RSC19d/response-error-code-header-2` + Tests that the errorCode property extracts the value from X-Ably-Errorcode header. ### Setup @@ -598,6 +624,8 @@ ASSERT response.errorCode == 40101 ## RSC19d, HP7 - HttpPaginatedResponse provides error message from header +**Test ID**: `rest/unit/RSC19d/response-error-message-header-3` + Tests that the errorMessage property extracts the value from X-Ably-Errormessage header. ### Setup @@ -628,6 +656,8 @@ ASSERT response.errorMessage == "Token expired" ## RSC19d, HP8 - HttpPaginatedResponse provides all response headers +**Test ID**: `rest/unit/RSC19d/response-headers-accessible-4` + Tests that all response headers are accessible. ### Setup @@ -662,6 +692,8 @@ ASSERT headers["X-Custom-Header"] == "custom-value" ## RSC19d, HP3 - HttpPaginatedResponse provides response items +**Test ID**: `rest/unit/RSC19d/response-items-decoded-5` + Tests that the items() method returns the decoded response body. ### Setup @@ -692,6 +724,8 @@ ASSERT items[1]["id"] == "msg2" ## RSC19d, HP1 - HttpPaginatedResponse pagination support +**Test ID**: `rest/unit/RSC19d/pagination-with-link-headers-6` + | Spec | Requirement | |------|-------------| | RSC19d | Request returns HttpPaginatedResponse | @@ -744,6 +778,8 @@ ASSERT response.hasNext() == false ## RSC19d - Non-array response handling +**Test ID**: `rest/unit/RSC19d/non-array-response-handling-7` + Tests that non-array responses are handled correctly (wrapped as single item). ### Setup @@ -771,6 +807,8 @@ ASSERT items.length == 1 OR items["time"] == 1234567890000 ## RSC19e - Network error handling +**Test ID**: `rest/unit/RSC19e/network-error-propagated-0` + **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. @@ -798,6 +836,8 @@ ASSERT error.code == 80000 OR error.message CONTAINS "network" OR error.message ## RSC19e - Timeout error handling +**Test ID**: `rest/unit/RSC19e/timeout-error-handling-1` + Tests that request timeouts are properly handled. ### Setup @@ -826,6 +866,8 @@ ASSERT error.code == 50003 OR error.message CONTAINS "timeout" ## RSC19e - HTTP error status does not trigger fallback +**Test ID**: `rest/unit/RSC19e/http-error-no-fallback-2` + Tests that HTTP error responses (4xx, 5xx with valid Ably error body) are returned directly without fallback retry. ### Setup @@ -862,6 +904,8 @@ ASSERT mock_http.captured_requests.length == 1 ## RSC19e, RSC15 - Fallback hosts tried on server errors +**Test ID**: `rest/unit/RSC19e/fallback-on-server-error-3` + Tests that fallback hosts are attempted when primary host returns server error without valid Ably error. ### Setup @@ -902,6 +946,8 @@ ASSERT mock_http.captured_requests[1].url.host == "fallback.ably-realtime.com" ## RSC19b - Cannot override authentication +**Test ID**: `rest/unit/RSC19b/cannot-override-auth-1` + Tests that the request() method does not allow overriding the configured authentication via custom headers. ### Setup @@ -938,6 +984,8 @@ This behavior may vary by implementation. Some libraries may allow header overri ## RSC19f - Path with leading slash +**Test ID**: `rest/unit/RSC19f/path-leading-slash-handling-4` + Tests that paths are handled correctly whether or not they include a leading slash. ### Setup @@ -969,6 +1017,8 @@ FOR EACH test_case IN test_cases: ## RSC19d - Empty response handling +**Test ID**: `rest/unit/RSC19d/empty-response-handling-8` + Tests that empty responses (204 No Content) are handled correctly. ### Setup diff --git a/uts/rest/unit/request_endpoint.md b/uts/rest/unit/request_endpoint.md index 16abb31b8..02286bb43 100644 --- a/uts/rest/unit/request_endpoint.md +++ b/uts/rest/unit/request_endpoint.md @@ -17,6 +17,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ### RSC25 - Default primary domain used for requests +**Test ID**: `rest/unit/RSC25/default-primary-domain-0` + Tests that REST requests are sent to the default primary domain when no endpoint configuration is provided. #### Setup @@ -45,6 +47,8 @@ ASSERT mock_http.captured_requests[0].url.host == DEFAULT_REST_HOST ### RSC25 - Custom endpoint used for requests +**Test ID**: `rest/unit/RSC25/custom-endpoint-domain-1` + Tests that REST requests are sent to a custom production routing policy domain. #### Setup @@ -57,7 +61,7 @@ mock_http = MockHttpClient( client = Rest(options: ClientOptions( key: "appId.keyId:keySecret", - endpoint: "sandbox" + endpoint: "test" )) ``` @@ -69,13 +73,15 @@ AWAIT client.time() #### Assertions ```pseudo ASSERT mock_http.captured_requests.length == 1 -ASSERT mock_http.captured_requests[0].url.host == "sandbox.realtime.ably.net" +ASSERT mock_http.captured_requests[0].url.host == "test.realtime.ably.net" ``` --- ### RSC25 - Multiple requests all go to primary domain +**Test ID**: `rest/unit/RSC25/multiple-requests-primary-domain-2` + Tests that successive requests continue to use the primary domain (no unexpected host switching). #### Setup @@ -107,6 +113,8 @@ FOR EACH request IN mock_http.captured_requests: ### RSC25 - Primary domain tried first before fallback +**Test ID**: `rest/unit/RSC25/primary-tried-before-fallback-3` + Tests that when the primary host fails and a fallback succeeds, the primary was attempted first. #### Setup @@ -144,6 +152,8 @@ ASSERT mock_http.captured_requests[1].url.host != DEFAULT_REST_HOST ### RSC25 - Request path preserved when sent to primary domain +**Test ID**: `rest/unit/RSC25/request-path-preserved-4` + Tests that the request path and query parameters are correctly constructed when sent to the primary domain. #### Setup diff --git a/uts/rest/unit/rest_client.md b/uts/rest/unit/rest_client.md index 4d7720571..80fe4241d 100644 --- a/uts/rest/unit/rest_client.md +++ b/uts/rest/unit/rest_client.md @@ -13,6 +13,8 @@ See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastruct ## RSC5 - Auth Attribute +**Test ID**: `rest/unit/RSC5/auth-attribute-accessible-0` + **Spec requirement:** `RestClient#auth` attribute provides access to the `Auth` object that was instantiated with the `ClientOptions` provided in the `RestClient` constructor. ### Setup @@ -32,6 +34,8 @@ ASSERT client.auth IS Auth ## RSC7e - X-Ably-Version header +**Test ID**: `rest/unit/RSC7e/ably-version-header-0` + **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. @@ -68,6 +72,8 @@ ASSERT captured_request.headers["X-Ably-Version"] matches pattern "[0-9.]+" ## RSC7d, RSC7d1, RSC7d2 - Ably-Agent header +**Test ID**: `rest/unit/RSC7d/ably-agent-header-format-0` + | Spec | Requirement | |------|-------------| | RSC7d | All requests must include Ably-Agent header | @@ -105,6 +111,8 @@ ASSERT agent matches pattern "ably-[a-z]+/[0-9]+\\.[0-9]+\\.[0-9]+" ## RSC7c - Request ID when addRequestIds enabled +**Test ID**: `rest/unit/RSC7c/request-id-included-0` + **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. @@ -140,6 +148,8 @@ ASSERT request_id matches pattern "[A-Za-z0-9_-]+" ## RSC7c - Request ID preserved on fallback retry +**Test ID**: `rest/unit/RSC7c/request-id-preserved-fallback-1` + **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. @@ -178,6 +188,8 @@ ASSERT request_id_1 == request_id_2 # Same ID for retry ## RSC8a, RSC8b - Protocol selection +**Test ID**: `rest/unit/RSC8a/protocol-selection-0` + | Spec | Requirement | |------|-------------| | RSC8a | MessagePack protocol is used by default | @@ -221,6 +233,8 @@ FOR EACH test_case IN test_cases: ## RSC8c - Accept and Content-Type headers +**Test ID**: `rest/unit/RSC8c/accept-content-type-headers-0` + **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. @@ -252,6 +266,8 @@ ASSERT request.headers["Content-Type"] == "application/json" ## RSC8d - Handle mismatched response Content-Type +**Test ID**: `rest/unit/RSC8d/mismatched-response-content-type-0` + **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. @@ -286,6 +302,8 @@ ASSERT result IS DateTime OR result == 1234567890000 ## RSC8e - Unsupported Content-Type handling +**Test ID**: `rest/unit/RSC8e/unsupported-content-type-0` + **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. @@ -340,6 +358,8 @@ ASSERT error.code == 40013 ## RSC8 - Error response decoded from MessagePack +**Test ID**: `rest/unit/RSC8/error-decoded-from-msgpack-0` + **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 @@ -391,6 +411,8 @@ losing the real error information. The SDK must check the response ## RSC13 - Request timeouts +**Test ID**: `rest/unit/RSC13/request-timeout-enforced-0` + **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. @@ -439,6 +461,8 @@ short timeout duration (e.g. 100ms), not the full mock delay. ## RSC17 - ClientId Attribute +**Test ID**: `rest/unit/RSC17/client-id-from-options-0` + **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 @@ -459,6 +483,8 @@ ASSERT client.clientId == client.auth.clientId ## RSC17 - ClientId Attribute +**Test ID**: `rest/unit/RSC17/client-id-matches-auth-1` + **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 @@ -479,6 +505,8 @@ ASSERT client.clientId == client.auth.clientId ## RSC18 - TLS configuration +**Test ID**: `rest/unit/RSC18/tls-controls-protocol-scheme-0` + **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. @@ -517,6 +545,8 @@ FOR EACH test_case IN test_cases: ## RSC18 - Basic auth over HTTP rejected +**Test ID**: `rest/unit/RSC18/basic-auth-over-http-rejected-1` + **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. diff --git a/uts/rest/unit/stats.md b/uts/rest/unit/stats.md index e5c89f8fa..717890d48 100644 --- a/uts/rest/unit/stats.md +++ b/uts/rest/unit/stats.md @@ -17,6 +17,8 @@ Tests the `stats()` method which retrieves application statistics from Ably. The ## RSC6a - stats() returns PaginatedResult with Stats objects +**Test ID**: `rest/unit/RSC6a/returns-paginated-stats-0` + **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. @@ -82,6 +84,8 @@ ASSERT request.path == "/stats" ## RSC6a - stats() sends authenticated request with standard headers +**Test ID**: `rest/unit/RSC6a/authenticated-with-headers-1` + **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. @@ -124,6 +128,8 @@ ASSERT "Ably-Agent" IN request.headers ## RSC6b1 - stats() with start parameter +**Test ID**: `rest/unit/RSC6b1/start-param-millis-0` + **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. @@ -161,6 +167,8 @@ ASSERT request.query_params["start"] == str(start_time.millisecondsSinceEpoch) ## RSC6b1 - stats() with end parameter +**Test ID**: `rest/unit/RSC6b1/end-param-millis-1` + **Spec requirement:** `end` is an optional timestamp field represented as milliseconds since epoch. Tests that the `end` parameter is sent as milliseconds since epoch. @@ -198,6 +206,8 @@ ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) ## RSC6b1 - stats() with start and end parameters +**Test ID**: `rest/unit/RSC6b1/start-and-end-params-2` + **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. @@ -237,6 +247,8 @@ ASSERT request.query_params["end"] == str(end_time.millisecondsSinceEpoch) ## RSC6b2 - stats() with direction parameter +**Test ID**: `rest/unit/RSC6b2/direction-param-forwards-0` + **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. @@ -273,6 +285,8 @@ ASSERT request.query_params["direction"] == "forwards" ## RSC6b2 - stats() direction defaults to backwards +**Test ID**: `rest/unit/RSC6b2/direction-defaults-backwards-1` + **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". @@ -312,6 +326,8 @@ ASSERT "direction" NOT IN request.query_params ## RSC6b3 - stats() with limit parameter +**Test ID**: `rest/unit/RSC6b3/limit-param-value-0` + **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. @@ -348,6 +364,8 @@ ASSERT request.query_params["limit"] == "10" ## RSC6b3 - stats() limit defaults to 100 +**Test ID**: `rest/unit/RSC6b3/limit-defaults-to-100-1` + **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". @@ -387,6 +405,8 @@ ASSERT "limit" NOT IN request.query_params ## RSC6b4 - stats() with unit parameter +**Test ID**: `rest/unit/RSC6b4/unit-param-values-0` + **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. @@ -432,6 +452,8 @@ FOR EACH test_case IN test_cases: ## RSC6b4 - stats() unit defaults to minute +**Test ID**: `rest/unit/RSC6b4/unit-defaults-to-minute-1` + **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". @@ -471,6 +493,8 @@ ASSERT "unit" NOT IN request.query_params ## RSC6b - stats() with all parameters combined +**Test ID**: `rest/unit/RSC6b/all-params-combined-0` + | Spec | Requirement | |------|-------------| | RSC6b1 | `start` and `end` timestamp parameters | @@ -524,6 +548,8 @@ ASSERT request.query_params["unit"] == "hour" ## RSC6a - stats() with no parameters sends no query params +**Test ID**: `rest/unit/RSC6a/no-params-clean-request-2` + **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`. @@ -564,6 +590,8 @@ ASSERT request.query_params IS empty ## RSC6a - stats() pagination with Link headers +**Test ID**: `rest/unit/RSC6a/pagination-link-headers-3` + **Spec requirement:** Returns a `PaginatedResult` page. PaginatedResult supports navigation via Link headers (TG4, TG6). Tests that stats results support pagination navigation using Link headers. @@ -617,6 +645,8 @@ ASSERT page2.isLast() == true ## RSC6a - stats() empty results +**Test ID**: `rest/unit/RSC6a/empty-results-handled-4` + **Spec requirement:** Returns a `PaginatedResult` page containing `Stats` objects. Must handle empty result sets correctly. Tests that stats() handles empty results correctly. @@ -651,6 +681,8 @@ ASSERT result.isLast() == true ## RSC6a - stats() error handling +**Test ID**: `rest/unit/RSC6a/error-propagated-5` + **Spec requirement:** Errors from the stats endpoint must be properly propagated to the caller. Tests that errors from the stats endpoint are properly propagated. diff --git a/uts/rest/unit/time.md b/uts/rest/unit/time.md index 525b7cc9a..dfb5418f0 100644 --- a/uts/rest/unit/time.md +++ b/uts/rest/unit/time.md @@ -24,6 +24,8 @@ Tests the `time()` method which retrieves the current server time from Ably. ## RSC16 - time() returns server time +**Test ID**: `rest/unit/RSC16/returns-server-time-0` + **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. @@ -67,6 +69,8 @@ ASSERT request.path == "/time" ## RSC16 - time() request format +**Test ID**: `rest/unit/RSC16/request-format-get-time-1` + **Spec requirement:** The time request must be a GET request to `/time` with standard Ably headers. Tests that the time request is correctly formatted. @@ -110,6 +114,8 @@ ASSERT "Ably-Agent" IN request.headers ## RSC16 - time() does not require authentication +**Test ID**: `rest/unit/RSC16/no-auth-required-2` + **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. @@ -151,6 +157,8 @@ ASSERT "Authorization" NOT IN request.headers ## RSC16 - time() works without TLS +**Test ID**: `rest/unit/RSC16/works-without-tls-3` + **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. @@ -203,6 +211,8 @@ This test verifies that the RSC18 check (which rejects basic auth over non-TLS c ## RSC16 - time() error handling +**Test ID**: `rest/unit/RSC16/error-propagated-4` + **Spec requirement:** Errors from the `/time` endpoint should be properly propagated to the caller. Tests that errors from the time endpoint are properly propagated. diff --git a/uts/rest/unit/types/error_types.md b/uts/rest/unit/types/error_types.md index 6406880ea..ed85212ee 100644 --- a/uts/rest/unit/types/error_types.md +++ b/uts/rest/unit/types/error_types.md @@ -12,6 +12,8 @@ No mocks required - these verify type structure. ## TI1-TI5 - ErrorInfo attributes +**Test ID**: `rest/unit/TI1/errorinfo-attributes-0` + **Spec requirement:** ErrorInfo type must provide all required attributes according to TI1-TI5 specifications. | Spec | Attribute | Description | @@ -74,6 +76,8 @@ ASSERT error.cause == original_error ## TI - ErrorInfo from JSON response +**Test ID**: `rest/unit/TI/errorinfo-from-json-0` + **Spec requirement:** ErrorInfo type must support deserialization from Ably JSON error responses. Tests that `ErrorInfo` can be deserialized from Ably error response. @@ -101,6 +105,8 @@ ASSERT error.href == "https://help.ably.io/error/40100" ## TI - ErrorInfo with nested error +**Test ID**: `rest/unit/TI/errorinfo-nested-cause-1` + **Spec requirement:** ErrorInfo must support nested error structures with a cause field (TI5). Tests parsing error response with nested error structure. @@ -132,6 +138,8 @@ IF error.cause IS ErrorInfo: ## TI - AblyException wraps ErrorInfo +**Test ID**: `rest/unit/TI/ably-exception-wraps-errorinfo-2` + **Spec requirement:** AblyException (throwable) must wrap ErrorInfo and expose its attributes. Tests that `AblyException` (throwable) wraps `ErrorInfo`. @@ -156,6 +164,8 @@ ASSERT exception.errorInfo == error_info ## TI - Common error codes +**Test ID**: `rest/unit/TI/common-error-codes-3` + **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. @@ -192,6 +202,8 @@ FOR EACH test_case IN test_cases: ## TI - Error string representation +**Test ID**: `rest/unit/TI/error-string-representation-4` + **Spec requirement:** ErrorInfo must provide a useful string representation including error code, status code, and message. Tests that errors have a useful string representation. @@ -216,6 +228,8 @@ ASSERT "Unauthorized" IN string_repr OR "token" IN string_repr ## TI - Error equality +**Test ID**: `rest/unit/TI/error-equality-5` + **Spec requirement:** ErrorInfo must support equality comparison based on error attributes. Tests that errors can be compared for equality. diff --git a/uts/rest/unit/types/message_types.md b/uts/rest/unit/types/message_types.md index ee35a2a4f..69dc53a6b 100644 --- a/uts/rest/unit/types/message_types.md +++ b/uts/rest/unit/types/message_types.md @@ -1,17 +1,19 @@ # Message Types Tests -Spec points: `TM1`, `TM2`, `TM3`, `TM4`, `TM5`, `TM2a`, `TM2b`, `TM2c`, `TM2d`, `TM2e`, `TM2f`, `TM2g`, `TM2h`, `TM2i` +Spec points: `TM1`, `TM2`, `TM3`, `TM4`, `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. +No mocks required - these verify type structure, constructors, and encoding. --- ## TM2a-TM2i - Message attributes +**Test ID**: `rest/unit/TM2a/message-attributes-0` + **Spec requirement:** Message type must provide all required attributes according to TM2a-TM2i specifications. | Spec | Attribute | Description | @@ -76,11 +78,13 @@ ASSERT message.extras["push"]["notification"]["title"] == "Hello" --- -## TM3 - Message from JSON (wire format) +## TM3 - fromEncoded / fromEncodedArray + +**Test ID**: `rest/unit/TM3/from-encoded-deserialization-0` -**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). +**Spec requirement (TM3):** `fromEncoded` and `fromEncodedArray` are alternative constructors that take an already-deserialized Message-like object (or array of such), and optionally a `channelOptions`, and return a `Message` (or array of `Messages`) that is decoded and decrypted as specified in RSL6. The idiomatic method name varies by SDK (e.g., `fromEncoded` in JS, `fromJson`/`fromMap` in Dart). -Tests that `Message` can be deserialized from JSON wire format. +Tests that `fromEncoded` correctly deserializes wire-format messages. ### Test Steps ```pseudo @@ -95,7 +99,7 @@ json_data = { "extras": { "headers": { "x-custom": "value" } } } -message = Message.fromJson(json_data) +message = Message.fromEncoded(json_data) ASSERT message.id == "msg-123" ASSERT message.name == "test-event" @@ -108,11 +112,13 @@ ASSERT message.extras["headers"]["x-custom"] == "value" --- -## TM3 - Message with encoded data from JSON +## TM3 - fromEncoded decodes encoding field + +**Test ID**: `rest/unit/TM3/from-encoded-decodes-encoding-1` -**Spec requirement:** Message deserialization must decode data based on the encoding field and clear the encoding after decoding. +**Spec requirement (TM3):** `fromEncoded` decodes data based on the `encoding` field, with any residual transforms left in the `encoding` property per RSL6b. -Tests that `Message` correctly handles encoded data during deserialization. +Tests that `fromEncoded` correctly handles encoded data during deserialization. ### Test Cases @@ -133,7 +139,7 @@ FOR EACH test_case IN test_cases: "encoding": test_case.encoding } - message = Message.fromJson(json_data) + message = Message.fromEncoded(json_data) ASSERT message.data == test_case.expected_data ASSERT message.encoding IS null # Encoding consumed @@ -141,94 +147,63 @@ FOR EACH test_case IN test_cases: --- -## TM4 - Message to JSON (wire format) +## TM4 - Message constructors -**Spec requirement:** Message type must support serialization to JSON wire format, automatically encoding non-string data types. +**Test ID**: `rest/unit/TM4/message-constructors-0` -Tests that `Message` serializes correctly for transmission. +**Spec requirement (TM4):** `Message` has constructors `constructor(name: String?, data: Data?)` and `constructor(name: String?, data: Data?, clientId: String?)`. -### 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. +Tests that `Message` can be constructed with the specified signatures. ### Test Steps ```pseudo -message = Message( - name: "json-event", - data: { "nested": { "array": [1, 2, 3] } } -) +# constructor(name, data) +message = Message(name: "event-name", data: "payload") +ASSERT message.name == "event-name" +ASSERT message.data == "payload" +ASSERT message.clientId IS null OR message.clientId IS undefined -json_data = message.toJson() +# constructor(name, data, clientId) +message = Message(name: "event-name", data: "payload", clientId: "client-1") +ASSERT message.name == "event-name" +ASSERT message.data == "payload" +ASSERT message.clientId == "client-1" -# 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] } } +# Both name and data are nullable +message = Message(name: null, data: null) +ASSERT message.name IS null OR message.name IS undefined +ASSERT message.data IS null OR message.data IS undefined ``` --- -## 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]) -``` - ---- +## TM - Null/missing attributes -## TM5 - Message equality +**Test ID**: `rest/unit/TM/null-missing-attributes-0` -**Spec requirement:** Message type must support equality comparison based on message content and attributes. +**Spec requirement:** Message type must handle null or missing optional attributes correctly. -Tests that messages can be compared for equality. +Tests that null or missing attributes are handled correctly. ### 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") +# Minimal message +message = Message() -ASSERT message1 == message2 # Same content -ASSERT message1 != message3 # Different id +# 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 ``` --- ## TM - Message with extras +**Test ID**: `rest/unit/TM/message-with-extras-1` + **Spec requirement:** Message extras field must support arbitrary metadata including push notification configuration (TM2h). Tests that Message extras (push notifications, etc.) are handled correctly. @@ -252,35 +227,6 @@ message = Message( } ) -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 +ASSERT message.extras["push"]["notification"]["title"] == "New Message" +ASSERT message.extras["push"]["data"]["customKey"] == "customValue" ``` diff --git a/uts/rest/unit/types/mutable_message_types.md b/uts/rest/unit/types/mutable_message_types.md index e94e7fadd..31c385ffc 100644 --- a/uts/rest/unit/types/mutable_message_types.md +++ b/uts/rest/unit/types/mutable_message_types.md @@ -9,6 +9,8 @@ Unit test (no mocking needed — pure type construction and serialization) ## TM5 — MessageAction enum values +**Test ID**: `rest/unit/TM5/message-action-enum-values-0` + **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. @@ -31,6 +33,8 @@ ASSERT MessageAction.fromInt(5) == MessageAction.MESSAGE_APPEND ## TM2j, TM2r — Message has action and serial fields +**Test ID**: `rest/unit/TM2j/action-and-serial-fields-0` + | Spec | Requirement | |------|-------------| | TM2j | `action` enum | @@ -64,6 +68,8 @@ ASSERT json_data["data"] == "hello" ## TM2s — Message.version populated from wire +**Test ID**: `rest/unit/TM2s/version-populated-from-wire-0` + | Spec | Requirement | |------|-------------| | TM2s | `version` is an object containing information about the latest version of a message | @@ -107,6 +113,8 @@ ASSERT msg.version.metadata["tool"] == "editor" ## TM2s1, TM2s2 — Message.version defaults when not on wire +**Test ID**: `rest/unit/TM2s1/version-defaults-from-message-0` + | Spec | Requirement | |------|-------------| | TM2s | If a message does not contain a `version` object the SDK must initialize one and set a subset of fields | @@ -147,6 +155,8 @@ ASSERT msg.version.metadata IS null ## TM2u, TM8a — Message.annotations defaults to empty +**Test ID**: `rest/unit/TM2u/annotations-defaults-empty-0` + | 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 | @@ -174,6 +184,8 @@ ASSERT msg.annotations.summary IS empty # No keys ## MOP2a–c — MessageOperation fields +**Test ID**: `rest/unit/MOP2a/message-operation-fields-0` + | Spec | Requirement | |------|-------------| | MOP2a | `clientId?: String` | @@ -220,6 +232,8 @@ ASSERT "metadata" NOT IN empty_json ## UDR2a — UpdateDeleteResult fields +**Test ID**: `rest/unit/UDR2a/update-delete-result-fields-0` + | Spec | Requirement | |------|-------------| | UDR1 | Contains the result of an update or delete message operation | @@ -247,6 +261,8 @@ ASSERT result3.versionSerial IS null ## TAN2 — Annotation type attributes and action encoding +**Test ID**: `rest/unit/TAN2/annotation-attributes-and-action-0` + | Spec | Requirement | |------|-------------| | TAN1 | An `Annotation` represents an individual annotation event | diff --git a/uts/rest/unit/types/options_types.md b/uts/rest/unit/types/options_types.md index 149b782f1..a32917875 100644 --- a/uts/rest/unit/types/options_types.md +++ b/uts/rest/unit/types/options_types.md @@ -12,6 +12,8 @@ No mocks required - these verify type structure and defaults. ## TO3 - ClientOptions attributes +**Test ID**: `rest/unit/TO3/client-options-attributes-0` + **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. @@ -64,7 +66,7 @@ ASSERT options.maxMessageSize == 65536 options = ClientOptions( key: "appId.keyId:keySecret", clientId: "my-client", - endpoint: "sandbox", + endpoint: "test", tls: false, httpRequestTimeout: 30000, useBinaryProtocol: false, @@ -74,7 +76,7 @@ options = ClientOptions( ASSERT options.key == "appId.keyId:keySecret" ASSERT options.clientId == "my-client" -ASSERT options.endpoint == "sandbox" +ASSERT options.endpoint == "test" ASSERT options.tls == false ASSERT options.httpRequestTimeout == 30000 ASSERT options.useBinaryProtocol == false @@ -86,6 +88,8 @@ ASSERT options.addRequestIds == true ## TO3 - ClientOptions with custom hosts +**Test ID**: `rest/unit/TO3/client-options-custom-hosts-1` + **Spec requirement:** ClientOptions must support custom host configuration including restHost and fallbackHosts. Tests custom host configuration. @@ -106,6 +110,8 @@ ASSERT options.fallbackHosts == ["fallback1.example.com", "fallback2.example.com ## TO3 - ClientOptions with auth URL +**Test ID**: `rest/unit/TO3/client-options-auth-url-2` + **Spec requirement:** ClientOptions must support authUrl configuration with customizable HTTP method, headers, and parameters. Tests auth URL configuration. @@ -129,6 +135,8 @@ ASSERT options.authParams["scope"] == "full" ## TO3 - ClientOptions with defaultTokenParams +**Test ID**: `rest/unit/TO3/client-options-default-token-params-3` + **Spec requirement:** ClientOptions must support defaultTokenParams for specifying default token request parameters. Tests default token parameters configuration. @@ -153,6 +161,8 @@ ASSERT options.defaultTokenParams.capability == "{\"*\":[\"subscribe\"]}" ## AO2 - AuthOptions attributes +**Test ID**: `rest/unit/AO2/auth-options-attributes-0` + **Spec requirement:** AuthOptions type must provide all authentication-related attributes according to AO2 specification. | Spec | Attribute | Description | @@ -204,6 +214,8 @@ ASSERT auth_options.queryTime == true ## AO - AuthOptions with authCallback +**Test ID**: `rest/unit/AO/auth-options-with-callback-0` + **Spec requirement:** AuthOptions must support authCallback function for custom token generation logic. Tests that `AuthOptions` can hold an authCallback function. @@ -229,6 +241,8 @@ ASSERT result.token == "callback-token" ## TO - Endpoint affects host selection +**Test ID**: `rest/unit/TO/endpoint-affects-host-0` + **Spec requirement:** The endpoint option must affect host selection for REST and Realtime connections. Tests that endpoint option affects default hosts. @@ -238,7 +252,7 @@ Tests that endpoint option affects default hosts. | ID | Endpoint | Expected Rest Host | |----|----------|--------------------| | 1 | (none/production) | `rest.ably.io` | -| 2 | `"sandbox"` | `sandbox-rest.ably.io` | +| 2 | `"test"` | `test-rest.ably.io` | | 3 | `"custom-env"` | `custom-env-rest.ably.io` | ### Note @@ -262,6 +276,8 @@ FOR EACH test_case IN test_cases: ## TO - Conflicting options validation +**Test ID**: `rest/unit/TO/conflicting-options-validation-1` + **Spec requirement:** ClientOptions must validate and detect conflicting configuration options. Tests that conflicting options are detected. @@ -279,7 +295,7 @@ Tests that conflicting options are detected. ClientOptions( key: "appId.keyId:keySecret", restHost: "custom.host.com", - endpoint: "sandbox" + endpoint: "test" ) FAILS WITH error ASSERT error.message CONTAINS "restHost" OR error.message CONTAINS "endpoint" ``` diff --git a/uts/rest/unit/types/paginated_result.md b/uts/rest/unit/types/paginated_result.md index 463596668..e53711d7c 100644 --- a/uts/rest/unit/types/paginated_result.md +++ b/uts/rest/unit/types/paginated_result.md @@ -20,6 +20,8 @@ The mock supports: ## TG1 - PaginatedResult items attribute +**Test ID**: `rest/unit/TG1/paginated-result-items-0` + **Spec requirement:** `PaginatedResult` must contain an `items` array with the result data. ### Setup @@ -59,6 +61,8 @@ ASSERT result.items[1].id == "item2" ## TG2 - hasNext() and isLast() methods +**Test ID**: `rest/unit/TG2/has-next-is-last-0` + **Spec requirement:** `PaginatedResult` must provide `hasNext()` and `isLast()` methods to indicate pagination state. ### Test Case 1: Has more pages @@ -135,6 +139,8 @@ ASSERT result.isLast() == true ## TG3 - next() method +**Test ID**: `rest/unit/TG3/next-fetches-next-page-0` + **Spec requirement:** The `next()` method must fetch the next page using the URL from the Link header. ### Setup @@ -198,6 +204,8 @@ ASSERT next_request.url.query_params["cursor"] == "abc123" ## TG4 - first() method +**Test ID**: `rest/unit/TG4/first-returns-first-page-0` + **Spec requirement:** The `first()` method must return to the first page using the URL from the Link header's `rel="first"` link. ### Setup @@ -259,6 +267,8 @@ ASSERT first_page.items[0].id == "item1" ## TG - Empty result +**Test ID**: `rest/unit/TG/empty-result-handling-0` + **Spec requirement:** Empty results must be handled correctly with an empty `items` array. ### Setup @@ -298,6 +308,8 @@ ASSERT result.isLast() == true ## TG - Link header parsing +**Test ID**: `rest/unit/TG/link-header-parsing-1` + **Spec requirement:** Various Link header formats must be correctly parsed to determine pagination state and next page URLs. ### Test Cases @@ -343,6 +355,8 @@ FOR EACH test_case IN test_cases: ## TG - PaginatedResult type parameter +**Test ID**: `rest/unit/TG/type-parameter-items-2` + **Spec requirement:** `PaginatedResult` must correctly type its items to the expected type `T`. ### Note @@ -381,6 +395,8 @@ ASSERT history_result.items[0] IS Message ## TG - next() on last page +**Test ID**: `rest/unit/TG/next-on-last-page-3` + **Spec requirement:** Calling `next()` on the last page must handle gracefully (return null, empty result, or throw). ### Setup @@ -425,6 +441,8 @@ ASSERT next_result IS null OR next_result.items.length == 0 ## TG - Pagination preserves authentication +**Test ID**: `rest/unit/TG/pagination-preserves-auth-4` + **Spec requirement:** Pagination requests must include the same authentication credentials as the initial request. ### Setup @@ -473,6 +491,8 @@ ASSERT captured_requests[0].headers["Authorization"] == captured_requests[1].hea ## TG - Pagination with relative URLs +**Test ID**: `rest/unit/TG/pagination-relative-urls-5` + **Spec requirement:** Link headers with relative URLs must be resolved relative to the base REST host. ### Setup @@ -524,6 +544,8 @@ ASSERT "page" IN captured_requests[1].url.query_params ## TG - Multiple Link relations +**Test ID**: `rest/unit/TG/multiple-link-relations-6` + **Spec requirement:** Link headers may contain multiple relations (next, first, last) which must all be parsed correctly. ### Setup @@ -563,6 +585,8 @@ ASSERT result.hasNext() == true ## TG - Pagination with presence results +**Test ID**: `rest/unit/TG/pagination-presence-results-7` + **Spec requirement:** Pagination must work identically for presence results as it does for message results. ### Setup @@ -610,6 +634,8 @@ ASSERT page2.items[0].clientId == "client2" ## TG - Pagination includes request headers +**Test ID**: `rest/unit/TG/pagination-includes-headers-8` + **Spec requirement:** Pagination requests must include all standard Ably headers (X-Ably-Version, Ably-Agent, etc.). ### Setup @@ -659,6 +685,8 @@ ASSERT next_request.headers["Ably-Agent"] contains "ably-" ## TG - Error handling on next() +**Test ID**: `rest/unit/TG/error-handling-on-next-9` + **Spec requirement:** Errors during pagination (e.g., 404, 500) must be raised as `AblyException`. ### Setup diff --git a/uts/rest/unit/types/presence_message_types.md b/uts/rest/unit/types/presence_message_types.md index 60ba41519..47596367b 100644 --- a/uts/rest/unit/types/presence_message_types.md +++ b/uts/rest/unit/types/presence_message_types.md @@ -9,6 +9,8 @@ Unit test — pure type/model validation, no mocks required. ## TP2 - PresenceAction enum values +**Test ID**: `rest/unit/TP2/presence-action-enum-values-0` + **Spec requirement:** PresenceMessage Action enum has the following values in order from zero: ABSENT, PRESENT, ENTER, LEAVE, UPDATE. @@ -25,6 +27,8 @@ ASSERT PresenceAction.update.index == 4 ## TP3a-TP3i - PresenceMessage attributes +**Test ID**: `rest/unit/TP3a/presence-message-attributes-0` + **Spec requirement:** PresenceMessage type must provide all required attributes. | Spec | Attribute | Description | @@ -82,6 +86,8 @@ ASSERT msg.extras["headers"]["x-custom"] == "value" ## TP3h - memberKey combines connectionId and clientId +**Test ID**: `rest/unit/TP3h/member-key-combines-ids-0` + **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. @@ -102,6 +108,8 @@ ASSERT msg.memberKey != msg2.memberKey ## TP3d - connectionId defaults from ProtocolMessage +**Test ID**: `rest/unit/TP3d/connectionid-from-protocol-message-0` + **Spec requirement:** If connectionId is not present in a received presence message, it should be set to the connectionId of the encapsulating ProtocolMessage. @@ -124,6 +132,8 @@ ASSERT presence_msg.connectionId == "proto-conn-1" ## TP3a - id defaults from ProtocolMessage +**Test ID**: `rest/unit/TP3a/id-from-protocol-message-1` + **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. @@ -147,6 +157,8 @@ ASSERT protocol_msg.presence[1].id == "proto-msg-42:1" ## TP3g - timestamp defaults from ProtocolMessage +**Test ID**: `rest/unit/TP3g/timestamp-from-protocol-message-0` + **Spec requirement:** If timestamp is not present in a received presence message, it should be set to the timestamp of the encapsulating ProtocolMessage. @@ -168,6 +180,8 @@ ASSERT presence_msg.timestamp == 9999999 ## TP3 - PresenceMessage from JSON (wire format) +**Test ID**: `rest/unit/TP3/presence-from-json-0` + **Spec requirement:** PresenceMessage must support deserialization from JSON wire format. ### Test Steps @@ -198,6 +212,8 @@ ASSERT msg.extras["headers"]["x-key"] == "x-value" ## TP3 - PresenceMessage with encoded data from JSON +**Test ID**: `rest/unit/TP3/presence-encoded-data-from-json-1` + **Spec requirement:** Deserialization must decode data based on the encoding field. ### Test Cases @@ -228,6 +244,8 @@ FOR EACH test_case IN test_cases: ## TP3 - PresenceMessage to JSON (wire format) +**Test ID**: `rest/unit/TP3/presence-to-json-2` + **Spec requirement:** PresenceMessage must support serialization to JSON wire format. ### Test Steps @@ -251,6 +269,8 @@ ASSERT json_data["extras"]["headers"]["x-key"] == "x-value" ## TP3 - Null/missing attributes omitted from serialization +**Test ID**: `rest/unit/TP3/null-attributes-omitted-3` + **Spec requirement:** Null or missing optional attributes should be omitted from serialized output. @@ -272,6 +292,8 @@ ASSERT "id" NOT IN json_data OR json_data["id"] IS null ## TP4 - fromEncoded and fromEncodedArray +**Test ID**: `rest/unit/TP4/from-encoded-presence-0` + **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. @@ -312,6 +334,8 @@ ASSERT messages[1].data == "world" ## TP5 - PresenceMessage size calculation +**Test ID**: `rest/unit/TP5/presence-message-size-0` + **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. diff --git a/uts/rest/unit/types/token_types.md b/uts/rest/unit/types/token_types.md index b4bc25cae..9f422916d 100644 --- a/uts/rest/unit/types/token_types.md +++ b/uts/rest/unit/types/token_types.md @@ -12,6 +12,8 @@ No mocks required for most tests - these verify type structure and serialization ## TD1-TD5 - TokenDetails structure +**Test ID**: `rest/unit/TD1/token-details-attributes-0` + **Spec requirement:** TokenDetails type must provide all required attributes according to TD1-TD5 specifications. | Spec | Attribute | Description | @@ -65,6 +67,8 @@ ASSERT token_with_client.clientId == "my-client" ## TD - TokenDetails from JSON +**Test ID**: `rest/unit/TD/token-details-from-json-0` + **Spec requirement:** TokenDetails must support deserialization from JSON responses containing token information. Tests that `TokenDetails` can be deserialized from JSON response. @@ -93,6 +97,8 @@ ASSERT token_details.clientId == "json-client" ## TK1-TK6 - TokenParams structure +**Test ID**: `rest/unit/TK1/token-params-attributes-0` + **Spec requirement:** TokenParams type must provide all required attributes according to TK1-TK6 specifications. | Spec | Attribute | Description | @@ -155,6 +161,8 @@ ASSERT params.nonce == "full-nonce" ## TK - TokenParams to query string +**Test ID**: `rest/unit/TK/token-params-to-query-string-0` + **Spec requirement:** TokenParams must support conversion to query parameters for token request URLs. Tests that `TokenParams` are correctly converted to query parameters. @@ -178,6 +186,8 @@ ASSERT query_map["capability"] == "{\"ch\":[\"pub\"]}" ## TE1-TE6 - TokenRequest structure +**Test ID**: `rest/unit/TE1/token-request-attributes-0` + **Spec requirement:** TokenRequest type must provide all required attributes according to TE1-TE6 specifications. | Spec | Attribute | Description | @@ -265,6 +275,8 @@ ASSERT request.nonce == "unique-nonce" ## TE - TokenRequest with mac (signature) +**Test ID**: `rest/unit/TE/token-request-mac-signature-0` + **Spec requirement:** TokenRequest must include a mac (signature) field for authentication. Tests that `TokenRequest` includes the mac signature. @@ -285,6 +297,8 @@ ASSERT request.mac == "signature-base64" ## TE - TokenRequest to JSON +**Test ID**: `rest/unit/TE/token-request-to-json-1` + **Spec requirement:** TokenRequest must support serialization to JSON for transmission to the token endpoint. Tests that `TokenRequest` serializes correctly for transmission. @@ -316,6 +330,8 @@ ASSERT json_data["mac"] == "json-mac" ## TE - TokenRequest from JSON +**Test ID**: `rest/unit/TE/token-request-from-json-2` + **Spec requirement:** TokenRequest must support deserialization from JSON. Tests that `TokenRequest` can be deserialized from JSON.