diff --git a/package.json b/package.json index e64fe72d5..46469c5ca 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "test:react": "vitest run", "test:package": "grunt test:package", "test:uts": "npm run build:node && mocha --no-config --require tsx/cjs 'test/uts/**/*.test.ts'", + "test:uts:unit": "npm run build:node && mocha --no-config --require tsx/cjs --ignore 'test/uts/**/proxy/**' --ignore 'test/uts/**/integration/**' 'test/uts/**/*.test.ts'", "concat": "grunt concat", "build": "grunt build:all && npm run build:react", "build:node": "grunt build:node", diff --git a/test/uts/deviations.md b/test/uts/deviations.md index 2fd103095..b1580b61e 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -1,230 +1,230 @@ # UTS Test Deviations -Tracks confirmed ably-js non-compliance with the Ably spec. Each entry corresponds to a test that either fails or was adapted to assert ably-js's actual behavior instead of the spec requirement. +Tracks confirmed ably-js non-compliance with the Ably spec. Each entry corresponds to a test that fails because ably-js behavior differs from the spec requirement. Tests assert spec behavior and are allowed to fail — the failures document genuine deviations. -## Failing Tests +Tests marked with `if (!process.env.RUN_DEVIATIONS) this.skip()` are skipped by default but can be run with `RUN_DEVIATIONS=1 npm run test:uts`. -### client_id: RSA7b - auth.clientId not derived from TokenDetails (REST) - -**Spec (RSA7b)**: "The clientId attribute of the Auth object is derived from the tokenDetails that are returned from an explicit auth request, or from the authCallback." - -**ably-js behavior**: For REST clients, `auth.clientId` is only set from `ClientOptions.clientId` (via `_userSetClientId` during construction). It is NOT extracted from: +## Skipped Deviations (RUN_DEVIATIONS=1 to run) -- `tokenDetails.clientId` passed in the constructor -- `TokenDetails.clientId` returned by `authCallback` -- `TokenDetails.clientId` returned by `authorize()` +These tests assert spec behavior but are skipped by default because they are known to fail. Run with `RUN_DEVIATIONS=1` to execute them. -The `_uncheckedSetClientId` method exists but is only called from the Realtime connectionManager (on CONNECTED), never from REST token acquisition paths. +### realtime_client: RTC1a - echoMessages default does not send echo=true -**Tests affected** (5 failures): +**Spec (RTC1a)**: The `echoMessages` option (default true) should be sent as `echo=true` query parameter. -- `RSA7b - clientId from TokenDetails` — `auth.clientId` is undefined instead of `'token-client-id'` -- `RSA7b - clientId from authCallback TokenDetails` — `auth.clientId` is undefined instead of `'callback-client-id'` -- `RSA7 - clientId updated after authorize()` — `auth.clientId` is undefined instead of `'client-1'`/`'client-2'` -- `RSA12 - Wildcard clientId` — `auth.clientId` is undefined instead of `'*'` -- `RSA7 - case 5: clientId inherited from token` — `auth.clientId` is undefined instead of `'token-client'` +**ably-js behavior**: ably-js only sends `echo=false` when `echoMessages` is explicitly false. When `echoMessages` is true (default), no `echo` parameter is sent — the server defaults to echoing. -**Root cause**: `_saveTokenOptions()` and `_ensureValidAuthCredentials()` store `tokenDetails` but never call `_uncheckedSetClientId(tokenDetails.clientId)`. +**Test**: `RTC1a - echoMessages default sends echo=true` — asserts `echo=true` per spec. --- -### token_renewal: RSA4b - Authorization header overwritten on retry - -**Spec (RSA4b/RSC10)**: When a REST request fails with a token error (40140-40149), the library should obtain a new token and retry the request with the new token's authorization header. - -**ably-js behavior**: The retry sends the **old** token's authorization header instead of the new one. In `Resource.do()`, after a token error: +### channel_detach: RTL5k - ATTACHED while detached does not send DETACH -```javascript -await client.auth.authorize(null, null); -return withAuthDetails(client, headers, params, doRequest); -``` +**Spec (RTL5k)**: If the channel receives an ATTACHED message while in the DETACHED state, it should send a new DETACH message. -The `headers` parameter passed to `withAuthDetails` is the `doRequest` function parameter — the **merged** headers from the first `withAuthDetails` call, which already contains `authorization: 'Bearer '`. Then `withAuthDetails` does: +**ably-js behavior**: ably-js re-enters 'attached' state instead of sending DETACH when ATTACHED is received while detached. -```javascript -const authHeaders = await client.auth.getAuthHeaders(); -return opCallback(Utils.mixin(authHeaders, headers), params); -``` +**Test**: `RTL5k - ATTACHED while detached sends DETACH` — asserts `detachMessageCount == 2` and `channel.state == 'detached'` per spec. -`Utils.mixin(newAuthHeaders, oldMergedHeaders)` copies the old `authorization` from `oldMergedHeaders` into `newAuthHeaders`, overwriting the new token's header. - -**Consequences**: +--- -1. The retry always sends the old (expired) token -2. Combined with the lack of a retry limit (see below), this causes an infinite loop +### presence_reentry: RTP17e - re-entry error message missing clientId -**Tests affected**: +**Spec (RTP17e)**: Failed re-entry should emit UPDATE with error code 91004 and message indicating the failure and clientId. -- `RSA4b - renewal on 40142 error` — `captured[1].headers.authorization` has the old token instead of the renewed one. -- `RSC10 - transparent retry after renewal` — same symptom: the retried request carries the old token's authorization header. +**ably-js behavior**: The error message is `'Presence auto re-enter failed'` without including the clientId. -**Root cause**: `src/common/lib/client/resource.ts` line ~347 — the retry should pass the original (pre-auth) headers to `withAuthDetails`, not the merged headers that include the old `authorization`. +**Test**: `RTP17e - failed re-entry emits UPDATE with error` — asserts `message.includes('my-client')` per spec. --- -### token_renewal: RSA4b - No renewal retry limit +### client_options: RSC1b - wrong error code for missing credentials -**Spec (RSA4b)**: Token renewal should retry at most once per request. If the renewed token is also rejected, the error should propagate. +**Spec (RSC1b)**: Error code should be 40106. + +**ably-js behavior**: Uses error code 40160 instead of 40106. Additionally, `{ useTokenAuth: true }` alone throws with no error code set. -**ably-js behavior**: The retry loop in `Resource.do()` is unbounded — on each token error, it calls `authorize()` and retries recursively with no counter. Combined with the header-overwrite bug above, this causes an infinite loop and eventual OOM when the server persistently returns token errors. +**Tests**: `RSC1b - no credentials raises error`, `RSC1b - clientId alone raises error` (realtime), `RSC1b - Error when no auth method available` (REST). -**Test**: `RSA4b - renewal limit` — the authCallback caps at 3 responses to prevent OOM. Per spec, only 2 callbacks should occur (initial + 1 renewal). +**Issue**: [#2204](https://github.com/ably/ably-js/issues/2204) --- -### annotations: RSAN1a3 - type validation missing +### channel_publish: RTL6i3 / RSL1e - null fields included in wire JSON + +**Spec (RTL6i3/RSL1e)**: Null values should be omitted from wire JSON. -**Spec (RSAN1a3)**: "The SDK must validate that the user supplied a `type`. All other fields are optional." Should throw error 40003. +**ably-js behavior**: Includes `"data": null` instead of omitting the key. Similarly for `name`. -**ably-js behavior**: `constructValidateAnnotation()` does not validate that `type` is present. Annotation is published without a type, and the request succeeds. +**Tests**: `RTL6i3 - null name/data fields handled correctly` (realtime), `RSL1e - null name omitted from body`, `RSL1e - null data omitted from body` (REST). -**Test**: `RSAN1a3 - type required` — asserts spec behavior (throw with code 40003). Currently fails. +**Issue**: [#2199](https://github.com/ably/ably-js/issues/2199) --- -### annotations: RSAN1c4 - idempotent IDs not generated for annotations +### connection_ping: RTN13d - ping does not defer in non-connected states -**Spec (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`." +**Spec (RTN13d)**: Ping should be deferred until the connection reaches a resolvable state. -**ably-js behavior**: `RestAnnotations.publish()` does not generate idempotent IDs. Only `RestChannel.publish()` (for messages) generates them. The annotation's `id` field is not set. +**ably-js behavior**: `ping()` immediately rejects with "not connected". -**Test**: `RSAN1c4 - idempotent ID generated` — asserts spec behavior (id in `:0` format). Currently fails. +**Test**: `RTN13d - ping deferred from CONNECTING until CONNECTED`. + +**Issue**: [#2203](https://github.com/ably/ably-js/issues/2203) --- -### rest_client: RSC7c - addRequestIds not implemented +### client_id: RSA7b - auth.clientId not derived from TokenDetails (REST) + +**Spec (RSA7b)**: The clientId attribute of the Auth object should be derived from tokenDetails returned from auth requests. -**Spec (RSC7c)**: "When the `addRequestIds` option is set to true, the library must add a `request_id` query parameter to all REST requests." +**ably-js behavior**: `auth.clientId` is only set from `ClientOptions.clientId`, not extracted from tokenDetails. -**ably-js behavior**: The `addRequestIds` option is accepted and stored in `client.options` but has no effect. No `request_id` parameter is added to any requests. There is no code referencing this option in the built bundle. +**Tests**: `RSA7b - clientId from TokenDetails`, `RSA7b - clientId from authCallback TokenDetails`, `RSA7 - clientId updated after authorize()`, `RSA12 - Wildcard clientId`, `RSA7 - case 5: clientId inherited from token`. -**Test**: `RSC7c - request_id query param when addRequestIds is true` — fails because `request_id` is null. +**Issue**: [#2192](https://github.com/ably/ably-js/issues/2192) --- -### fallback: RSC15l - request timeout does not trigger fallback +### token_renewal: RSA4b - Authorization header overwritten on retry / no retry limit -**Spec (RSC15l)**: When a request times out after the connection is established (request-level timeout), the client should retry on a fallback host, just as it does for connection-level timeouts. +**Spec (RSA4b/RSC10)**: Token renewal should use the new token's header and retry at most once. -**ably-js behavior**: Request-level timeouts propagate as errors without triggering fallback retry. Only connection-level errors (refused, DNS, timeout before connection) and HTTP 500-504 trigger fallback. +**ably-js behavior**: The retry sends the old token's authorization header. The retry loop is unbounded. -**Test**: `RSC15l - request timeout triggers fallback` — asserts spec behavior. Currently fails. +**Tests**: `RSA4b - renewal on 40142 error`, `RSC10 - transparent retry after renewal`, `RSA4b - renewal limit`. + +**Issue**: [#2193](https://github.com/ably/ably-js/issues/2193) --- -### fallback: RSC15l4 - CloudFront Server header not detected +### annotations: RSAN1a3 - type validation missing + +**Spec (RSAN1a3)**: The SDK must validate that the user supplied a `type`. -**Spec (RSC15l4)**: When a response includes `Server: CloudFront` header with status >= 400, the client should treat it as a server error and retry on a fallback host. +**ably-js behavior**: `constructValidateAnnotation()` does not validate that `type` is present. -**ably-js behavior**: `shouldFallback` in `http.ts` only checks for specific errno codes and HTTP 500-504. It does not inspect the `Server` response header. CloudFront errors with 4xx status codes are treated as non-retryable client errors. +**Tests**: `RSAN1a3 - type required` (realtime), `RTAN1a - publish validates type is required` (REST). -**Test**: `RSC15l4 - CloudFront Server header triggers fallback` — asserts spec behavior. Currently fails. +**Issue**: [#2194](https://github.com/ably/ably-js/issues/2194) --- -### fallback: REC1b2 - IPv6 endpoint address not bracketed +### annotations: RSAN1c4 / RSC22d - idempotent IDs not generated -**Spec (REC1b2)**: When `endpoint` is an IPv6 address (e.g., `::1`), the library should treat it as an explicit hostname. +**Spec (RSAN1c4)**: Annotations with empty `id` should get a generated idempotent ID. **Spec (RSC22d)**: Same for batch publish. -**ably-js behavior**: `getPrimaryDomainFromEndpoint('::1')` returns `::1` (correct via `isFqdnIpOrLocalhost`), but URL construction produces `https://::1:443/time` instead of `https://[::1]:443/time`. The missing brackets cause an "Invalid URI" error. +**ably-js behavior**: Neither `RestAnnotations.publish()` nor `batchPublish()` generates idempotent IDs. -**Test**: `REC1b2 - endpoint as IPv6 address` — asserts spec behavior. Currently fails. +**Tests**: `RSAN1c4 - idempotent ID generated`, `RSC22d - batch publish generates idempotent IDs`. ---- +**Issue**: [#2195](https://github.com/ably/ably-js/issues/2195) -## Adapted Tests +--- -Tests that pass but were adapted to assert ably-js's actual behavior instead of the spec requirement. These document genuine deviations where fixing the test to match the spec would cause a failure. +### rest_client: RSC7c - addRequestIds not implemented -### revoke_tokens: RSA17c - Response format pass-through +**Spec (RSC7c)**: The `addRequestIds` option should add a `request_id` query parameter to all REST requests. -**Spec (RSA17c)**: UTS spec expects the server to return a plain array of per-target results, and the client library to compute `successCount`, `failureCount`, and `results` from the array. +**ably-js behavior**: The option is accepted but has no effect. -**ably-js behavior**: `revokeTokens()` passes through the server response body as-is. The mock returns the pre-computed `{successCount, failureCount, results}` object, matching the actual Ably REST API response format. Additionally, `revokeTokens()` throws on HTTP 400 responses — the `batchResponse` data containing per-target success/failure results is discarded. +**Tests**: `RSC7c - request_id query param when addRequestIds is true`, `RSC22_Headers2 - request_id included when addRequestIds enabled`. -**Tests affected**: RSA17c, RSA17c_2, RSA17c_3, TRF2_1. +**Issue**: [#2196](https://github.com/ably/ably-js/issues/2196) --- -### options_types: AO2 - authMethod default not stored +### fallback: RSC15l4 - CloudFront Server header does not trigger fallback + +**Spec (RSC15l4)**: A response with a `Server: CloudFront` header and HTTP status `>= 400` should trigger fallback. -**Spec (AO2)**: `authMethod` defaults to 'GET' and should be accessible on the auth options object. +**ably-js behavior**: `shouldFallback` only receives the error object, not response headers. The `Server` header is not inspected anywhere in the fallback decision path. -**ably-js behavior**: When `authMethod` is not explicitly set, `auth.authOptions.authMethod` is `undefined`. The GET default is applied at HTTP request time, not stored in the options. +**Test**: `RSC15l4 - CloudFront Server header triggers fallback`. -**Test**: `AO2 - authMethod defaults to GET` — accepts both `'GET'` and `undefined`. +**Issue**: [#2197](https://github.com/ably/ably-js/issues/2197) --- -### client_options: RSC1b - wrong error code for missing credentials +### fallback: REC1b2 - IPv6 endpoint address not bracketed -**Spec (RSC1b)**: "If invalid arguments are provided such as no API key, no token and no means to create a token, then this will result in an error with error code 40106." +**Spec (REC1b2)**: IPv6 addresses should be supported as endpoint values. -**ably-js behavior**: Uses error code 40160 instead of 40106. Additionally, `{ useTokenAuth: true }` alone throws with no error code set. +**ably-js behavior**: URL construction produces `https://::1:443/time` instead of `https://[::1]:443/time`. -**Test**: `RSC1b - no credentials raises error` — asserts 40160 instead of spec's 40106. +**Test**: `REC1b2 - endpoint as IPv6 address`. + +**Issue**: [#2198](https://github.com/ably/ably-js/issues/2198) --- -### connection_ping: RTN13d - ping does not defer in non-connected states +### options_types: AO2 - authMethod default not stored -**Spec (RTN13d)**: "If the connection is not in the CONNECTED state when ping() is called, the ping is deferred until the connection reaches a state that can resolve it (CONNECTED, FAILED, CLOSED, SUSPENDED)." +**Spec (AO2)**: `authMethod` should default to `'GET'` and be stored in auth options. -**ably-js behavior**: `ping()` immediately rejects with "not connected" when called in CONNECTING or DISCONNECTED state. There is no deferral mechanism. `ConnectionManager.ping()` checks `this.state.state !== 'connected'` and throws immediately. +**ably-js behavior**: Default `authMethod` is not stored. -**Test**: RTN13d tests rewritten to assert immediate rejection instead of deferral. +**Test**: `AO2 - authMethod defaults to GET`. + +**Issue**: [#2205](https://github.com/ably/ably-js/issues/2205) --- -### channel_publish: RTL6i3 / publish: RSL1e - null fields included in wire JSON +### presence_message_types: TP3h - memberKey not exposed -**Spec (RTL6i3/RSL1e)**: "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" }`" +**Spec (TP3h)**: `PresenceMessage` should expose a `memberKey` property. -**ably-js behavior**: When `data` is `null`/`undefined`, ably-js includes it as `"data": null` in the JSON wire format instead of omitting the key. Similarly for `name`. +**ably-js behavior**: `memberKey` is not exposed on `PresenceMessage`. -**Root cause**: Message serialization in `src/common/lib/types/message.ts` does not strip null/undefined values before `JSON.stringify`. +**Test**: `TP3h - memberKey format`. -**Tests affected**: `RTL6i3 - null name/data fields handled correctly`, `RSL1e - null name omitted from body`. +**Issue**: [#2202](https://github.com/ably/ably-js/issues/2202) --- -### channels_collection: RTS4a - release throws on attached channels +### channels: RTL4c - errorReason not cleared on successful re-attach -**Spec (RTS4a)**: "Detaches the channel and then releases the channel resource i.e. it's deleted and can then be garbage collected" +**Spec (RTL4c, proposed)**: When a confirmation ATTACHED is received, the channel's errorReason should be set to null. -**ably-js behavior**: `channels.release()` throws error 90001 ("Channel operation failed as channel state is attached") when called on an attached channel, instead of detaching first. +**ably-js behavior**: After a channel enters FAILED state, a subsequent successful `attach()` does not clear `errorReason`. -**Test**: `RTS4a - release throws on attached channel (deviation)` — asserts the throw with code 90001. +**Note**: This is a proposed spec change (see [specification#459](https://github.com/ably/specification/issues/459)). + +**Tests**: `RTL4g - errorReason cleared on re-attach from FAILED`, `RTL4g - errorReason cleared on re-attach and detach`. --- -### batch_presence: BAR2/BGF2/RSC24_Mixed - mixed/failure results not normalised +### presence_sync: RTP18a - new sync does not discard in-flight sync -**Spec (BAR2, BGF2, RSC24)**: When the server returns HTTP 400 with `{error, batchResponse}` for mixed or all-failure batch presence results, the SDK normalises the response into `{successCount, failureCount, results}`. +**Spec (RTP18a)**: If a new SYNC sequence begins while one is in progress, the previous sync should be discarded. -**ably-js behavior**: `batchPresence()` calls `Resource.get()` with `throwError=true`. Any HTTP 400 response sets `result.err`, which is thrown. The `batchResponse` data containing per-channel success/failure results is discarded. +**ably-js behavior**: Does not discard the previous sync. -**Tests affected**: BAR2_1, BAR2_3, BGF2_1, RSC24_Mixed_1 — all assert that ably-js throws error 40020. +**Test**: `RTP18a - new sync discards previous in-flight sync`. --- -### batch_publish: RSC22d - batchPublish does not generate idempotent IDs +### integration/auth: RSC10 - token renewal infinite loop with expired JWT + +**Spec (RSC10)**: When a REST request fails with a token error (40140-40149), the client should renew the token and retry. -**Spec (RSC22d)**: "If `idempotentRestPublishing` is enabled, then RSL1k1 should be applied (to each `BatchPublishSpec` separately)." +**ably-js behavior**: Same root cause as the unit test RSA4b deviation — `withAuthDetails` overwrites the new authorization header with the stale one from the previous attempt, causing an infinite retry loop. Confirmed against the sandbox: the authCallback is called hundreds of times, each returning a valid JWT, but the request always sends the old expired token. -**ably-js behavior**: `batchPublish()` passes `BatchPublishSpec` objects directly to `Resource.post('/messages')` without any message processing. Unlike `RestChannel.publish()`, which generates idempotent IDs via the `allEmptyIds()` / `idempotentRestPublishing` code path, `batchPublish()` sends messages exactly as provided by the caller. No `id` fields are added. +**Test**: `RSC10 - token renewal with expired JWT` in `rest/integration/auth.test.ts`. -**Test**: `RSC22d - batch publish does not generate idempotent IDs (deviation)` — asserts messages lack `id` property. +**Issue**: [#2193](https://github.com/ably/ably-js/issues/2193) (same root cause as unit test deviations RSA4b/RSC10) --- -### presence_message_types: TP3h - memberKey not exposed +### integration/push_admin: RSH1b2 - push device list pagination missing Link headers + +**Spec (RSH1b2)**: `deviceRegistrations.list` with `limit` should support pagination via `hasNext()`. -**Spec (TP3h)**: `memberKey` is a "string function that combines the `connectionId` and `clientId` ensuring multiple connected clients with the same clientId are uniquely identifiable." It should be a method on `PresenceMessage`. +**Server behavior**: The push admin `GET /push/deviceRegistrations` endpoint does not return `Link` headers when `limit` is used, even when more results exist. With 3 devices registered and `limit=2`, the response returns 2 items but `hasNext()` is false because there is no `Link: rel="next"` header. -**ably-js behavior**: `memberKey` is not a method on `PresenceMessage`. It is computed internally as a lambda `(item) => item.clientId + ':' + item.connectionId` passed to `PresenceMap`, but not accessible to callers. +**Test**: `RSH1b2 - list supports pagination with limit` in `rest/integration/push_admin.test.ts`. -**Test**: `TP3h - memberKey` — falls back to asserting the component fields (`connectionId`, `clientId`) instead. +**Issue**: [ably/realtime#8380](https://github.com/ably/realtime/issues/8380) --- @@ -232,19 +232,14 @@ Tests that pass but were adapted to assert ably-js's actual behavior instead of ### MsgPack encoding/decoding not supported -The UTS mock HTTP infrastructure (`test/uts/mock_http.ts`) operates at the JSON level — `PendingRequest.respond_with()` JSON-stringifies response bodies and `PendingRequest.body` contains the JSON-parsed request body. It has no mechanism to encode/decode msgpack binary format. +The UTS mock HTTP infrastructure operates at the JSON level. It has no mechanism to encode/decode msgpack binary format. **Tests affected (10 skipped)**: -- `RSL4c` — binary data with msgpack protocol (message_encoding.test.ts) -- `RSL6` — msgpack bin type decoded to Buffer (message_encoding.test.ts) -- `RSL6` — msgpack str type decoded to string (message_encoding.test.ts) -- `RSC8a` — default msgpack protocol Content-Type (rest_client.test.ts) -- `RSC8d` — mismatched Content-Type response (rest_client.test.ts) -- `RSC8e` — unsupported Content-Type response (rest_client.test.ts) -- `RSC8` — msgpack error response decoding (rest_client.test.ts) -- `RSC19c` — msgpack request headers (request.test.ts) -- `RSC19c` — msgpack request body encoding (request.test.ts) -- `RSC19c` — msgpack response decoding (request.test.ts) - -These tests are present as `this.skip()` stubs. To implement them, the mock would need msgpack serialization/deserialization support (e.g., adding `@ably/msgpack-js` as a dev dependency and extending PendingRequest/PendingConnection). +- `RSL4c` — binary data with msgpack protocol +- `RSL6` — msgpack bin/str type decoding (2 tests) +- `RSC8a` — default msgpack protocol Content-Type +- `RSC8d` — mismatched Content-Type response +- `RSC8e` — unsupported Content-Type response +- `RSC8` — msgpack error response decoding +- `RSC19c` — msgpack request headers/body/response (3 tests) diff --git a/test/uts/helpers.ts b/test/uts/helpers.ts index a98b98504..6b6f46711 100644 --- a/test/uts/helpers.ts +++ b/test/uts/helpers.ts @@ -14,6 +14,7 @@ import { DefaultRest } from '../../src/common/lib/client/defaultrest'; import { DefaultRealtime } from '../../src/common/lib/client/defaultrealtime'; import ErrorInfo from '../../src/common/lib/types/errorinfo'; import { makeFromDeserializedWithDependencies as makeProtocolMessageFromDeserialized } from '../../src/common/lib/types/protocolmessage'; +import { populateFieldsFromParent } from '../../src/common/lib/types/basemessage'; const Ably = { Rest: DefaultRest, @@ -280,4 +281,5 @@ export { trackClient, restoreAll, flushAsync, + populateFieldsFromParent, }; diff --git a/test/uts/helpers/protocol_variants.ts b/test/uts/helpers/protocol_variants.ts new file mode 100644 index 000000000..65ee904d1 --- /dev/null +++ b/test/uts/helpers/protocol_variants.ts @@ -0,0 +1,32 @@ +/** + * Protocol variant helpers for G1 compliance. + * + * Data-path integration tests should use describeEachProtocol() to run + * once per supported protocol (JSON and MessagePack). + */ + +export type Protocol = 'json' | 'msgpack'; + +const PROTOCOLS: Protocol[] = ['json', 'msgpack']; + +/** + * Wraps a describe block to run once per protocol variant. + * Produces test output like: + * suite name [json] + * ✓ test + * suite name [msgpack] + * ✓ test + * + * The callback receives mocha's Suite `this` context via `.call()`, + * so `this.timeout()` works inside the callback when using `function()` syntax. + */ +export function describeEachProtocol( + name: string, + fn: (this: Mocha.Suite, protocol: Protocol) => void, +): void { + for (const protocol of PROTOCOLS) { + describe(`${name} [${protocol}]`, function (this: Mocha.Suite) { + fn.call(this, protocol); + }); + } +} diff --git a/test/uts/mock_http.ts b/test/uts/mock_http.ts index 199c6d373..37eec7305 100644 --- a/test/uts/mock_http.ts +++ b/test/uts/mock_http.ts @@ -127,7 +127,7 @@ class PendingRequest { /** Request times out after connection established */ respond_with_timeout(): void { this._resolve!({ - error: { code: 408, statusCode: 408, message: 'Request timed out' } as any, + error: { code: 'ETIMEDOUT', statusCode: 408, message: 'Request timed out' } as any, body: null, headers: {}, unpacked: false, @@ -320,6 +320,10 @@ class MockHttpClient { ) { return true; } + // RSC15l2: request timeout (HTTP 408) + if (statusCode === 408) { + return true; + } return statusCode >= 500 && statusCode <= 504; } } diff --git a/test/uts/realtime/integration/auth/auth.test.ts b/test/uts/realtime/integration/auth/auth.test.ts index 8d95d2435..b06ba3057 100644 --- a/test/uts/realtime/integration/auth/auth.test.ts +++ b/test/uts/realtime/integration/auth/auth.test.ts @@ -34,6 +34,7 @@ describe('uts/realtime/integration/auth/auth', function () { /** * RSA8 - Token auth on realtime connection */ + // UTS: realtime/integration/RSA8/token-auth-connect-0 it('RSA8 - JWT token auth connects successfully', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); @@ -59,6 +60,7 @@ describe('uts/realtime/integration/auth/auth', function () { /** * RTC8a - In-band reauthorization on CONNECTED client */ + // UTS: realtime/integration/RTC8a/in-band-reauth-connected-0 it('RTC8a - authorize on connected client does not disconnect', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); @@ -93,6 +95,7 @@ describe('uts/realtime/integration/auth/auth', function () { /** * RTC8c - authorize() from INITIALIZED initiates connection */ + // UTS: realtime/integration/RTC8c/authorize-initiates-connection-0 it('RTC8c - authorize from initialized state initiates connection', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); @@ -120,6 +123,7 @@ describe('uts/realtime/integration/auth/auth', function () { /** * RSA7 - Matching clientId succeeds */ + // UTS: realtime/integration/RSA7/matching-clientid-succeeds-0 it('RSA7 - matching clientId in JWT and options succeeds', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); const testClientId = `test-client-${Math.random().toString(36).substring(2, 8)}`; @@ -142,4 +146,43 @@ describe('uts/realtime/integration/auth/auth', function () { await closeAndWait(client); }); + + /** + * RSA7 - Mismatched clientId in JWT and options fails + * + * When the clientId in the JWT token differs from the clientId in + * ClientOptions, the server rejects the connection. + */ + // UTS: realtime/integration/RSA7/mismatched-clientid-fails-1 + it('RSA7 - mismatched clientId fails', async function () { + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, clientId: 'token-client-id', ttl: 3600000 })); + }, + clientId: 'wrong-client-id', + endpoint: SANDBOX_ENDPOINT, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + try { + await connectAndWait(client); + expect.fail('Expected connection to fail'); + } catch (error: any) { + expect(error.message).to.include('failed'); + } + + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(40102); + + try { + await closeAndWait(client); + } catch (e) { + /* ok — already failed */ + } + }); }); diff --git a/test/uts/realtime/integration/auth/token_renewal.test.ts b/test/uts/realtime/integration/auth/token_renewal.test.ts index 5141b6a55..ae7556f3c 100644 --- a/test/uts/realtime/integration/auth/token_renewal.test.ts +++ b/test/uts/realtime/integration/auth/token_renewal.test.ts @@ -21,7 +21,7 @@ import { } from '../sandbox'; describe('uts/realtime/integration/auth/token_renewal', function () { - this.timeout(60000); + this.timeout(120000); before(async function () { await setupSandbox(); @@ -34,6 +34,7 @@ describe('uts/realtime/integration/auth/token_renewal', function () { /** * RSA4b, RTN14b - Token renewal on expiry */ + // UTS: realtime/integration/RSA4b/token-renewal-on-expiry-0 it('RSA4b/RTN14b - token renewal on expiry', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); let callbackCount = 0; diff --git a/test/uts/realtime/integration/auth/token_request.test.ts b/test/uts/realtime/integration/auth/token_request.test.ts index ab4483b06..61f804ad6 100644 --- a/test/uts/realtime/integration/auth/token_request.test.ts +++ b/test/uts/realtime/integration/auth/token_request.test.ts @@ -31,6 +31,7 @@ describe('uts/realtime/integration/auth/token_request', function () { /** * RSA9a, RSA9g - createTokenRequest produces server-accepted token */ + // UTS: realtime/integration/RSA9a/token-request-server-accepted-0 it('RSA9a/RSA9g - createTokenRequest produces server-accepted token', async function () { const creator = new Ably.Rest({ key: getApiKey(), @@ -64,6 +65,7 @@ describe('uts/realtime/integration/auth/token_request', function () { /** * RSA9 - createTokenRequest with clientId */ + // UTS: realtime/integration/RSA9/token-request-with-clientid-0 it('RSA9 - createTokenRequest with clientId', async function () { const testClientId = `token-request-client-${Math.random().toString(36).substring(2, 10)}`; diff --git a/test/uts/realtime/integration/channels/channel_attach.test.ts b/test/uts/realtime/integration/channels/channel_attach.test.ts index 87fad43f6..fc2f9ec3f 100644 --- a/test/uts/realtime/integration/channels/channel_attach.test.ts +++ b/test/uts/realtime/integration/channels/channel_attach.test.ts @@ -32,6 +32,7 @@ describe('uts/realtime/integration/channels/channel_attach', function () { /** * RTL4c - Attach succeeds */ + // UTS: realtime/integration/RTL4c/attach-succeeds-0 it('RTL4c - attach succeeds', async function () { const channelName = uniqueChannelName('attach-RTL4c'); @@ -59,6 +60,7 @@ describe('uts/realtime/integration/channels/channel_attach', function () { /** * RTL5d - Detach succeeds */ + // UTS: realtime/integration/RTL5d/detach-succeeds-0 it('RTL5d - detach succeeds', async function () { const channelName = uniqueChannelName('detach-RTL5d'); @@ -86,6 +88,7 @@ describe('uts/realtime/integration/channels/channel_attach', function () { /** * RTL14 - Insufficient capability causes publish failure */ + // UTS: realtime/integration/RTL14/insufficient-capability-failed-0 it('RTL14 - publish with subscribe-only key fails with 40160', async function () { const channelName = uniqueChannelName('publish-not-allowed'); diff --git a/test/uts/realtime/integration/channels/channel_history.test.ts b/test/uts/realtime/integration/channels/channel_history.test.ts index a86b9a8fb..ff6f3fd64 100644 --- a/test/uts/realtime/integration/channels/channel_history.test.ts +++ b/test/uts/realtime/integration/channels/channel_history.test.ts @@ -18,8 +18,9 @@ import { uniqueChannelName, pollUntil, } from '../sandbox'; +import { describeEachProtocol } from '../../../helpers/protocol_variants'; -describe('uts/realtime/integration/channels/channel_history', function () { +describeEachProtocol('uts/realtime/integration/channels/channel_history', function (protocol) { this.timeout(30000); before(async function () { @@ -33,6 +34,7 @@ describe('uts/realtime/integration/channels/channel_history', function () { /** * RTL10d - History contains messages published by another client */ + // UTS: realtime/integration/RTL10d/history-cross-client-0 it('RTL10d - history contains messages from another client', async function () { const channelName = uniqueChannelName('history-RTL10d'); @@ -40,7 +42,7 @@ describe('uts/realtime/integration/channels/channel_history', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); @@ -48,7 +50,7 @@ describe('uts/realtime/integration/channels/channel_history', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); diff --git a/test/uts/realtime/integration/channels/channel_publish.test.ts b/test/uts/realtime/integration/channels/channel_publish.test.ts index aef62ba91..8dd2d9c67 100644 --- a/test/uts/realtime/integration/channels/channel_publish.test.ts +++ b/test/uts/realtime/integration/channels/channel_publish.test.ts @@ -18,8 +18,9 @@ import { uniqueChannelName, pollUntil, } from '../sandbox'; +import { describeEachProtocol } from '../../../helpers/protocol_variants'; -describe('uts/realtime/integration/channels/channel_publish', function () { +describeEachProtocol('uts/realtime/integration/channels/channel_publish', function (protocol) { this.timeout(30000); before(async function () { @@ -33,6 +34,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { /** * RTL6, RSL4d2 - String data round-trip */ + // UTS: realtime/integration/RTL6/string-data-roundtrip-0 it('RTL6/RSL4d2 - string data round-trip', async function () { const channelName = uniqueChannelName('publish-string'); @@ -40,7 +42,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); @@ -48,7 +50,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); @@ -81,6 +83,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { /** * RTL6, RSL4d3 - JSON object data round-trip */ + // UTS: realtime/integration/RTL6/json-data-roundtrip-1 it('RTL6/RSL4d3 - JSON object data round-trip', async function () { const channelName = uniqueChannelName('publish-json'); @@ -88,7 +91,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); @@ -96,7 +99,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); @@ -132,6 +135,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { /** * RTL6, RSL4d1 - Binary data round-trip */ + // UTS: realtime/integration/RTL6/binary-data-roundtrip-2 it('RTL6/RSL4d1 - binary data round-trip', async function () { const channelName = uniqueChannelName('publish-binary'); @@ -139,7 +143,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); @@ -147,7 +151,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); @@ -182,6 +186,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { /** * RTL6f - connectionId matches publisher */ + // UTS: realtime/integration/RTL6f/connectionid-matches-publisher-0 it('RTL6f - connectionId matches publisher', async function () { const channelName = uniqueChannelName('publish-connid'); @@ -189,7 +194,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); @@ -197,7 +202,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); @@ -230,6 +235,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { /** * RSL6a2 - Message extras round-trip */ + // UTS: realtime/integration/RSL6a2/message-extras-roundtrip-0 it('RSL6a2 - message extras round-trip', async function () { const channelName = uniqueChannelName('pushenabled:publish-extras'); @@ -237,7 +243,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); @@ -245,7 +251,7 @@ describe('uts/realtime/integration/channels/channel_publish', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); diff --git a/test/uts/realtime/integration/channels/channel_subscribe.test.ts b/test/uts/realtime/integration/channels/channel_subscribe.test.ts index 3d453a639..9be1bfdd1 100644 --- a/test/uts/realtime/integration/channels/channel_subscribe.test.ts +++ b/test/uts/realtime/integration/channels/channel_subscribe.test.ts @@ -33,6 +33,7 @@ describe('uts/realtime/integration/channels/channel_subscribe', function () { /** * RTL7a - Subscribe with no name filter receives all messages */ + // UTS: realtime/integration/RTL7a/subscribe-all-messages-0 it('RTL7a - subscribe with no name filter receives all messages', async function () { const channelName = uniqueChannelName('subscribe-all'); @@ -85,6 +86,7 @@ describe('uts/realtime/integration/channels/channel_subscribe', function () { /** * RTL7b - Subscribe with name filter receives only matching messages */ + // UTS: realtime/integration/RTL7b/subscribe-filtered-by-name-0 it('RTL7b - subscribe with name filter receives only matching messages', async function () { const channelName = uniqueChannelName('subscribe-filtered'); @@ -143,6 +145,7 @@ describe('uts/realtime/integration/channels/channel_subscribe', function () { /** * RTL7 - Bidirectional message flow */ + // UTS: realtime/integration/RTL7/bidirectional-message-flow-0 it('RTL7 - bidirectional message flow between two clients', async function () { const channelName = uniqueChannelName('subscribe-bidir'); diff --git a/test/uts/realtime/integration/connection/connection_failures.test.ts b/test/uts/realtime/integration/connection/connection_failures.test.ts index 8cce0d985..c01ca1770 100644 --- a/test/uts/realtime/integration/connection/connection_failures.test.ts +++ b/test/uts/realtime/integration/connection/connection_failures.test.ts @@ -28,6 +28,7 @@ describe('uts/realtime/integration/connection/connection_failures', function () /** * RTN14a - Invalid API key causes FAILED */ + // UTS: realtime/integration/RTN14a/invalid-key-failed-0 it('RTN14a - invalid API key causes FAILED', async function () { const client = new Ably.Realtime({ key: 'invalid.key:secret', @@ -59,6 +60,7 @@ describe('uts/realtime/integration/connection/connection_failures', function () /** * RTN14g - Non-existent key causes FAILED */ + // UTS: realtime/integration/RTN14g/revoked-key-failed-0 it('RTN14g - non-existent key causes FAILED', async function () { const client = new Ably.Realtime({ key: 'nonexistent.keyname:keysecret', diff --git a/test/uts/realtime/integration/connection/connection_lifecycle.test.ts b/test/uts/realtime/integration/connection/connection_lifecycle.test.ts index 42de24a72..82af11731 100644 --- a/test/uts/realtime/integration/connection/connection_lifecycle.test.ts +++ b/test/uts/realtime/integration/connection/connection_lifecycle.test.ts @@ -31,6 +31,7 @@ describe('uts/realtime/integration/connection/connection_lifecycle', function () /** * RTN4b, RTN21 - Successful connection establishment */ + // UTS: realtime/integration/RTN4b/successful-connection-0 it('RTN4b/RTN21 - successful connection establishment', async function () { const client = new Ably.Realtime({ key: getApiKey(), @@ -55,6 +56,7 @@ describe('uts/realtime/integration/connection/connection_lifecycle', function () /** * RTN4c, RTN12, RTN12a - Graceful connection close */ + // UTS: realtime/integration/RTN4c/graceful-close-0 it('RTN4c/RTN12/RTN12a - graceful connection close', async function () { const client = new Ably.Realtime({ key: getApiKey(), @@ -83,6 +85,7 @@ describe('uts/realtime/integration/connection/connection_lifecycle', function () * Uses two separate client instances because ably-js does not support * calling connect() on a client that has been closed. */ + // UTS: realtime/integration/RTN11/connect-reconnect-cycle-0 it('RTN11/RTN4b - connect, close, reconnect cycle', async function () { const client1 = new Ably.Realtime({ key: getApiKey(), diff --git a/test/uts/realtime/integration/delta_decoding.test.ts b/test/uts/realtime/integration/delta_decoding.test.ts index 0166b884c..ee2e21440 100644 --- a/test/uts/realtime/integration/delta_decoding.test.ts +++ b/test/uts/realtime/integration/delta_decoding.test.ts @@ -19,6 +19,7 @@ import { uniqueChannelName, pollUntil, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; const testData = [ { foo: 'bar', count: 1, status: 'active' }, @@ -39,8 +40,8 @@ function makeCountingDecoder() { return decoder; } -describe('uts/realtime/integration/delta_decoding', function () { - this.timeout(60000); +describeEachProtocol('uts/realtime/integration/delta_decoding', function (protocol) { + this.timeout(120000); before(async function () { await setupSandbox(); @@ -56,6 +57,7 @@ describe('uts/realtime/integration/delta_decoding', function () { * With a real vcdiff decoder plugin and a channel configured for delta mode, * all published messages are received with correct data. */ + // UTS: realtime/integration/PC3/delta-decode-end-to-end-0 it('PC3 - delta plugin decodes messages end-to-end', async function () { const channelName = uniqueChannelName('delta-PC3'); const countingDecoder = makeCountingDecoder(); @@ -64,7 +66,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', plugins: { vcdiff: countingDecoder }, } as any); trackClient(client); @@ -114,6 +116,7 @@ describe('uts/realtime/integration/delta_decoding', function () { * When successive messages have completely dissimilar payloads (random binary), * the server sends full messages rather than deltas. */ + // UTS: realtime/integration/RTL19b/dissimilar-payloads-no-delta-0 it('RTL19b - dissimilar payloads without delta encoding', async function () { const channelName = uniqueChannelName('delta-dissimilar'); const messageCount = 5; @@ -123,7 +126,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', plugins: { vcdiff: countingDecoder }, } as any); trackClient(client); @@ -180,6 +183,7 @@ describe('uts/realtime/integration/delta_decoding', function () { * Without params: { delta: 'vcdiff' }, the server sends full messages * and the decoder is never called. */ + // UTS: realtime/integration/PC3/no-deltas-without-param-1 it('PC3 - no deltas without delta channel param', async function () { const channelName = uniqueChannelName('delta-no-param'); const countingDecoder = makeCountingDecoder(); @@ -188,7 +192,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', plugins: { vcdiff: countingDecoder }, } as any); trackClient(client); @@ -228,6 +232,7 @@ describe('uts/realtime/integration/delta_decoding', function () { * When the stored last message ID is cleared, the next delta fails the RTL20 * check, triggering RTL18 recovery. After recovery the channel reattaches. */ + // UTS: realtime/integration/RTL18/recovery-message-id-mismatch-0 it('RTL18/RTL20 - recovery after last message ID mismatch', async function () { const channelName = uniqueChannelName('delta-recovery-mismatch'); const countingDecoder = makeCountingDecoder(); @@ -236,7 +241,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', plugins: { vcdiff: countingDecoder }, } as any); trackClient(client); @@ -308,6 +313,7 @@ describe('uts/realtime/integration/delta_decoding', function () { * When the vcdiff decoder throws, the channel transitions to ATTACHING * with error 40018 and recovers. */ + // UTS: realtime/integration/RTL18/recovery-decode-failure-1 it('RTL18 - recovery after decode failure', async function () { const channelName = uniqueChannelName('delta-recovery-decode'); @@ -321,7 +327,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', plugins: { vcdiff: failingDecoder }, } as any); trackClient(client); @@ -379,6 +385,7 @@ describe('uts/realtime/integration/delta_decoding', function () { * Without a vcdiff plugin, receiving a delta-encoded message causes * the channel to transition to FAILED with error code 40019. */ + // UTS: realtime/integration/PC3/no-plugin-causes-failed-2 it('PC3 - no plugin causes FAILED state', async function () { const channelName = uniqueChannelName('delta-no-plugin'); @@ -387,7 +394,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(subscriber); @@ -396,7 +403,7 @@ describe('uts/realtime/integration/delta_decoding', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(publisher); diff --git a/test/uts/realtime/integration/helpers/proxy.ts b/test/uts/realtime/integration/helpers/proxy.ts new file mode 100644 index 000000000..a53474ef2 --- /dev/null +++ b/test/uts/realtime/integration/helpers/proxy.ts @@ -0,0 +1,294 @@ +/** + * TypeScript helper for the Go test proxy. + * + * Wraps the proxy's REST control API to create sessions, add rules, + * trigger imperative actions, retrieve event logs, and clean up. + * + * The proxy binary is downloaded from GitHub releases on first use + * via ensureProxy(). It is killed when the Node.js process exits. + */ + +import { execSync, spawn, ChildProcess } from 'child_process'; +import * as crypto from 'crypto'; +import * as path from 'path'; +import * as fs from 'fs'; +import { pipeline } from 'stream/promises'; + +const PROXY_VERSION = 'v0.1.0'; +const PROXY_REPO = 'ably/uts-proxy'; + +const CONTROL_PORT = process.env.PROXY_CONTROL_PORT || '9100'; +const PROXY_CONTROL_HOST = process.env.PROXY_CONTROL_HOST || `http://localhost:${CONTROL_PORT}`; +const CACHE_DIR = path.resolve(__dirname, '../../../../../node_modules/.cache/uts-proxy', PROXY_VERSION); +const PROXY_BIN = path.join(CACHE_DIR, 'uts-proxy'); + +let _proxyProcess: ChildProcess | null = null; +let _proxyEnsured = false; + +const SANDBOX_REALTIME_HOST = 'sandbox.realtime.ably-nonprod.net'; +const SANDBOX_REST_HOST = 'sandbox.realtime.ably-nonprod.net'; + +let nextPort = 19000 + Math.floor(Math.random() * 1000); + +function allocatePort(): number { + return nextPort++; +} + +interface ProxyRule { + match: { + type: string; + count?: number; + action?: string; + channel?: string; + method?: string; + pathContains?: string; + queryContains?: Record; + delayMs?: number; + }; + action: { + type: string; + closeCode?: number; + delayMs?: number; + message?: Record; + status?: number; + body?: Record; + headers?: Record; + }; + times?: number; + comment?: string; +} + +interface ProxyEvent { + timestamp: string; + type: string; + direction?: string; + url?: string; + queryParams?: Record; + message?: any; + method?: string; + path?: string; + status?: number; + initiator?: string; + closeCode?: number; + ruleMatched?: string | null; + headers?: Record; +} + +interface ImperativeAction { + type: string; + message?: Record; + closeCode?: number; +} + +class ProxySession { + readonly sessionId: string; + readonly proxyHost: string; + readonly proxyPort: number; + private controlUrl: string; + + constructor(sessionId: string, proxyHost: string, proxyPort: number, controlUrl: string) { + this.sessionId = sessionId; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + this.controlUrl = controlUrl; + } + + async addRules(rules: ProxyRule[], position: 'append' | 'prepend' = 'append'): Promise { + const resp = await fetch(`${this.controlUrl}/sessions/${this.sessionId}/rules`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rules, position }), + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`addRules failed (${resp.status}): ${body}`); + } + } + + async triggerAction(action: ImperativeAction): Promise { + const resp = await fetch(`${this.controlUrl}/sessions/${this.sessionId}/actions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(action), + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`triggerAction failed (${resp.status}): ${body}`); + } + } + + async getLog(): Promise { + const resp = await fetch(`${this.controlUrl}/sessions/${this.sessionId}/log`); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`getLog failed (${resp.status}): ${body}`); + } + const data = await resp.json(); + return data.events || []; + } + + async close(): Promise { + try { + await fetch(`${this.controlUrl}/sessions/${this.sessionId}`, { method: 'DELETE' }); + } catch { + // Ignore errors during cleanup + } + } +} + +interface CreateProxySessionOpts { + endpoint?: 'nonprod:sandbox'; + port?: number; + rules?: ProxyRule[]; + timeoutMs?: number; +} + +async function createProxySession(opts: CreateProxySessionOpts = {}): Promise { + const port = opts.port || allocatePort(); + const controlUrl = PROXY_CONTROL_HOST; + + const target = { + realtimeHost: SANDBOX_REALTIME_HOST, + restHost: SANDBOX_REST_HOST, + }; + + const body: Record = { + target, + port, + rules: opts.rules || [], + }; + if (opts.timeoutMs) { + body.timeoutMs = opts.timeoutMs; + } + + const resp = await fetch(`${controlUrl}/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`createProxySession failed (${resp.status}): ${text}`); + } + + const data = await resp.json(); + return new ProxySession(data.sessionId, 'localhost', port, controlUrl); +} + +const CHECKSUMS: Record = { + 'uts-proxy_darwin_amd64.tar.gz': 'eb8abf5eec7f7137cf9e7cb6ab6f45fd162303c242b4567ab9e354c4b9a4a4ff', + 'uts-proxy_darwin_arm64.tar.gz': '845da80af7d5b1daacbdf30b34aff6ca1b2bb88c708065bdc5d9a636baf32a1f', + 'uts-proxy_linux_amd64.tar.gz': '79f444c23362cc277d163deb243dc16063c74665ff63b8bd3e56789b9d9610c7', + 'uts-proxy_linux_arm64.tar.gz': '7357e4605f19451d83bb419ee959537d6e95ca74b766721eae006d4171371030', +}; + +function assetName(): string { + const platform = process.platform === 'darwin' ? 'darwin' : 'linux'; + const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'; + return `uts-proxy_${platform}_${arch}.tar.gz`; +} + +async function downloadProxy(): Promise { + if (fs.existsSync(PROXY_BIN)) return; + + const asset = assetName(); + const expectedHash = CHECKSUMS[asset]; + if (!expectedHash) { + throw new Error(`No checksum for ${asset} — unsupported platform/arch`); + } + + fs.mkdirSync(CACHE_DIR, { recursive: true }); + + const url = `https://github.com/${PROXY_REPO}/releases/download/${PROXY_VERSION}/${asset}`; + console.log(`Downloading uts-proxy ${PROXY_VERSION} (${asset})...`); + + const resp = await fetch(url, { redirect: 'follow' }); + if (!resp.ok || !resp.body) { + throw new Error(`Failed to download ${url}: ${resp.status} ${resp.statusText}`); + } + + const tarball = path.join(CACHE_DIR, asset); + const fileStream = fs.createWriteStream(tarball); + // @ts-ignore — Node fetch body is a web ReadableStream; pipeline handles it in Node 18+ + await pipeline(resp.body, fileStream); + + const hash = crypto.createHash('sha256').update(fs.readFileSync(tarball)).digest('hex'); + if (hash !== expectedHash) { + fs.unlinkSync(tarball); + throw new Error(`Checksum mismatch for ${asset}: expected ${expectedHash}, got ${hash}`); + } + + execSync(`tar xzf ${JSON.stringify(asset)}`, { cwd: CACHE_DIR }); + fs.chmodSync(PROXY_BIN, 0o755); + fs.unlinkSync(tarball); +} + +function spawnProxy(): ChildProcess { + const child = spawn(PROXY_BIN, ['--port', CONTROL_PORT], { + stdio: ['ignore', 'inherit', 'inherit'], + detached: false, + }); + + child.on('error', (err) => { + console.error(`Proxy process error: ${err.message}`); + }); + + process.on('exit', () => { + if (child.exitCode === null) { + child.kill(); + } + }); + + return child; +} + +async function ensureProxy(timeoutMs = 15000): Promise { + if (_proxyEnsured) return; + + // Check if proxy is already running (e.g. started externally) + try { + const resp = await fetch(`${PROXY_CONTROL_HOST}/health`); + if (resp.ok) { + _proxyEnsured = true; + return; + } + } catch { + // Not running — we'll start it + } + + await downloadProxy(); + _proxyProcess = spawnProxy(); + + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const resp = await fetch(`${PROXY_CONTROL_HOST}/health`); + if (resp.ok) { + _proxyEnsured = true; + return; + } + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + + _proxyProcess.kill(); + _proxyProcess = null; + throw new Error(`Proxy failed to start within ${timeoutMs}ms`); +} + +async function waitForProxy(timeoutMs = 15000): Promise { + await ensureProxy(timeoutMs); +} + +function stopProxy(): void { + if (_proxyProcess && _proxyProcess.exitCode === null) { + _proxyProcess.kill(); + _proxyProcess = null; + } + _proxyEnsured = false; +} + +export { ProxySession, ProxyRule, ProxyEvent, ImperativeAction, createProxySession, waitForProxy, ensureProxy, stopProxy, allocatePort }; diff --git a/test/uts/realtime/integration/mutable_messages.test.ts b/test/uts/realtime/integration/mutable_messages.test.ts index 9f7d0053a..890357b01 100644 --- a/test/uts/realtime/integration/mutable_messages.test.ts +++ b/test/uts/realtime/integration/mutable_messages.test.ts @@ -18,9 +18,10 @@ import { uniqueChannelName, pollUntil, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; -describe('uts/realtime/integration/mutable_messages', function () { - this.timeout(60000); +describeEachProtocol('uts/realtime/integration/mutable_messages', function (protocol) { + this.timeout(120000); before(async function () { await setupSandbox(); @@ -36,6 +37,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * updateMessage() sends a MESSAGE ProtocolMessage with MESSAGE_UPDATE action. * Returns UpdateDeleteResult from ACK. */ + // UTS: realtime/integration/RTL32/update-message-observed-0 it('RTL32 - update message observed on subscriber', async function () { const channelName = uniqueChannelName('mutable:rt-update'); @@ -43,7 +45,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -51,7 +53,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -112,6 +114,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * * deleteMessage() sends a MESSAGE ProtocolMessage with MESSAGE_DELETE action. */ + // UTS: realtime/integration/RTL32/delete-message-observed-1 it('RTL32 - delete message observed on subscriber', async function () { const channelName = uniqueChannelName('mutable:rt-delete'); @@ -119,7 +122,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -127,7 +130,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -177,6 +180,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * * appendMessage() sends a MESSAGE ProtocolMessage with MESSAGE_APPEND action. */ + // UTS: realtime/integration/RTL32/append-message-observed-2 it('RTL32 - append message observed on subscriber', async function () { const channelName = uniqueChannelName('mutable:rt-append'); @@ -184,7 +188,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -192,7 +196,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -246,6 +250,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * * Subscriber receives create -> update -> append -> delete in order. */ + // UTS: realtime/integration/RTL32/full-mutation-lifecycle-3 it('RTL32 - full mutation lifecycle', async function () { const channelName = uniqueChannelName('mutable:rt-lifecycle'); @@ -253,7 +258,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -261,7 +266,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -347,6 +352,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * RTL28: RealtimeChannel#getMessage same as RestChannel#getMessage. * RTL31: RealtimeChannel#getMessageVersions same as RestChannel#getMessageVersions. */ + // UTS: realtime/integration/RTL28/get-message-and-versions-0 it('RTL28/RTL31 - getMessage and getMessageVersions', async function () { const channelName = uniqueChannelName('mutable:rt-get-versions'); @@ -354,7 +360,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(client); @@ -414,6 +420,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * RTAN2a: delete sends ANNOTATION_DELETE. * RTAN4b: annotations delivered to subscribers. */ + // UTS: realtime/integration/RTAN1/annotation-publish-delete-0 it('RTAN1/RTAN2/RTAN4 - annotation publish, subscribe, and delete', async function () { const channelName = uniqueChannelName('mutable:rt-annotations'); @@ -421,7 +428,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -429,7 +436,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -507,6 +514,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * * Subscribe with a type filter delivers only annotations whose type matches. */ + // UTS: realtime/integration/RTAN4c/annotation-type-filtering-0 it('RTAN4c - annotation type filtering', async function () { const channelName = uniqueChannelName('mutable:rt-ann-filter'); @@ -514,7 +522,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -522,7 +530,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -597,6 +605,7 @@ describe('uts/realtime/integration/mutable_messages', function () { * * Calling annotations.subscribe() on an unattached channel triggers implicit attach. */ + // UTS: realtime/integration/RTAN4d/annotation-implicit-attach-0 it('RTAN4d - annotation subscribe implicitly attaches channel', async function () { const channelName = uniqueChannelName('mutable:rt-ann-implicit-attach'); @@ -604,7 +613,7 @@ describe('uts/realtime/integration/mutable_messages', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(client); diff --git a/test/uts/realtime/integration/presence/presence_lifecycle.test.ts b/test/uts/realtime/integration/presence/presence_lifecycle.test.ts index e4b23f878..8b2e05140 100644 --- a/test/uts/realtime/integration/presence/presence_lifecycle.test.ts +++ b/test/uts/realtime/integration/presence/presence_lifecycle.test.ts @@ -18,9 +18,10 @@ import { uniqueChannelName, pollUntil, } from '../sandbox'; +import { describeEachProtocol } from '../../../helpers/protocol_variants'; -describe('uts/realtime/integration/presence/presence_lifecycle', function () { - this.timeout(60000); +describeEachProtocol('uts/realtime/integration/presence/presence_lifecycle', function (protocol) { + this.timeout(120000); before(async function () { await setupSandbox(); @@ -33,6 +34,7 @@ describe('uts/realtime/integration/presence/presence_lifecycle', function () { /** * RTP4, RTP6, RTP11a - Bulk enterClient observed on different connection */ + // UTS: realtime/integration/RTP4/bulk-enter-observed-0 it('RTP4/RTP6/RTP11a - bulk enterClient observed via subscribe and get', async function () { const channelName = uniqueChannelName('presence-bulk'); const memberCount = 20; @@ -41,7 +43,7 @@ describe('uts/realtime/integration/presence/presence_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -49,7 +51,7 @@ describe('uts/realtime/integration/presence/presence_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); @@ -95,6 +97,7 @@ describe('uts/realtime/integration/presence/presence_lifecycle', function () { /** * RTP8, RTP9, RTP10 - Enter, update, leave lifecycle */ + // UTS: realtime/integration/RTP8/enter-update-leave-lifecycle-0 it('RTP8/RTP9/RTP10 - enter, update, leave lifecycle observed on second connection', async function () { const channelName = uniqueChannelName('presence-lifecycle'); @@ -103,7 +106,7 @@ describe('uts/realtime/integration/presence/presence_lifecycle', function () { endpoint: SANDBOX_ENDPOINT, clientId: 'lifecycle-client', autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientA); @@ -111,7 +114,7 @@ describe('uts/realtime/integration/presence/presence_lifecycle', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(clientB); diff --git a/test/uts/realtime/integration/presence/presence_sync.test.ts b/test/uts/realtime/integration/presence/presence_sync.test.ts index 234ab436f..74647447b 100644 --- a/test/uts/realtime/integration/presence/presence_sync.test.ts +++ b/test/uts/realtime/integration/presence/presence_sync.test.ts @@ -19,7 +19,7 @@ import { } from '../sandbox'; describe('uts/realtime/integration/presence/presence_sync', function () { - this.timeout(60000); + this.timeout(120000); before(async function () { await setupSandbox(); @@ -32,6 +32,7 @@ describe('uts/realtime/integration/presence/presence_sync', function () { /** * RTP2, RTP11a - Presence SYNC delivers existing members */ + // UTS: realtime/integration/RTP2/sync-delivers-members-0 it('RTP2/RTP11a - presence SYNC delivers existing member', async function () { const channelName = uniqueChannelName('presence-sync'); @@ -77,6 +78,7 @@ describe('uts/realtime/integration/presence/presence_sync', function () { /** * RTP2 - Presence SYNC with multiple members */ + // UTS: realtime/integration/RTP2/sync-multiple-members-1 it('RTP2 - presence SYNC delivers multiple members', async function () { const channelName = uniqueChannelName('presence-sync-multi'); const memberCount = 10; diff --git a/test/uts/realtime/integration/proxy/auth_reauth.test.ts b/test/uts/realtime/integration/proxy/auth_reauth.test.ts new file mode 100644 index 000000000..e78e3c0ff --- /dev/null +++ b/test/uts/realtime/integration/proxy/auth_reauth.test.ts @@ -0,0 +1,154 @@ +/** + * UTS Proxy Integration: Auth Re-authorization Tests + * + * Spec points: RTN22, RTC8a + * Source: specification/uts/realtime/integration/proxy/auth_reauth.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + generateJWT, + pollUntil, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +function waitForState(client: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for state '${targetState}' (current: ${client.connection.state})`, + ), + ), + timeout, + ); + if (client.connection.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + client.connection.off(listener); + resolve(); + } + }; + client.connection.on(listener); + }); +} + +describe('uts/realtime/integration/proxy/auth_reauth', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RTN22/RTC8a — Server-initiated AUTH triggers re-authentication + * + * When the server sends an AUTH ProtocolMessage (action 17) to the client, + * the SDK should invoke the authCallback to obtain a new token and send + * an AUTH message back to the server, all without disrupting the connection. + */ + // UTS: realtime/proxy/RTN22/server-initiated-reauth-0 + it('RTN22/RTC8a - server-initiated AUTH triggers re-authentication', async function () { + // 1. Create proxy session with no rules (passthrough) + session = await createProxySession({ + rules: [], + }); + + // 2. Track authCallback invocations + let authCallbackCount = 0; + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + authCallbackCount++; + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // 3. Connect and wait for connected + client.connect(); + await waitForState(client, 'connected', 15000); + + // 4. Record baseline + const originalConnectionId = client.connection.id; + const originalCallbackCount = authCallbackCount; + + // 5. Record state changes from this point + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + // 6. Inject AUTH ProtocolMessage (action 17) from server to client + await session.triggerAction({ + type: 'inject_to_client', + message: { action: 17 }, + }); + + // 7. Poll until authCallbackCount increases + await pollUntil( + () => authCallbackCount > originalCallbackCount, + { timeout: 15000 }, + ); + + // Assertions + // Auth callback was invoked exactly once more + expect(authCallbackCount).to.equal(originalCallbackCount + 1); + + // Connection remains connected + expect(client.connection.state).to.equal('connected'); + + // Connection ID is unchanged (no reconnect occurred) + expect(client.connection.id).to.equal(originalConnectionId); + + // No non-connected state transitions occurred + const nonConnectedTransitions = stateChanges.filter((s) => s !== 'connected'); + expect(nonConnectedTransitions).to.be.empty; + + // Proxy log: at least 1 AUTH frame (action 17) from client to server with auth attribute + const log = await session.getLog(); + const authFrames = log.filter( + (e: any) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + (e.message?.action === 17 || e.message?.action === 'AUTH') && + e.message?.auth != null, + ); + expect(authFrames.length).to.be.at.least(1); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/proxy/channel_faults.test.ts b/test/uts/realtime/integration/proxy/channel_faults.test.ts new file mode 100644 index 000000000..109ca25ed --- /dev/null +++ b/test/uts/realtime/integration/proxy/channel_faults.test.ts @@ -0,0 +1,715 @@ +/** + * UTS Proxy Integration: Channel Fault Tests + * + * Spec points: RTL4f, RTL5f, RTL13a, RTL14 + * Source: specification/uts/realtime/integration/proxy/channel_faults.md + */ + +import { expect } from 'chai'; +import { + Ably, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + connectAndWait, + generateJWT, + uniqueChannelName, + pollUntil, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +function waitForState(client: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for state '${targetState}' (current: ${client.connection.state})`, + ), + ), + timeout, + ); + if (client.connection.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + client.connection.off(listener); + resolve(); + } + }; + client.connection.on(listener); + }); +} + +function waitForChannelState(channel: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for channel state '${targetState}' (current: ${channel.state})`, + ), + ), + timeout, + ); + if (channel.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + channel.off(listener); + resolve(); + } + }; + channel.on(listener); + }); +} + +describe('uts/realtime/integration/proxy/channel_faults', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RTL4f -- Attach timeout (server doesn't respond) + * + * When the proxy suppresses ATTACH messages so the server never sees them, + * the SDK's attach timer fires and the channel transitions to SUSPENDED. + */ + // UTS: realtime/proxy/RTL4f/attach-timeout-suppressed-0 + it('RTL4f - attach timeout when ATTACH is suppressed', async function () { + const channelName = uniqueChannelName('test-RTL4f'); + + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_server', action: 'ATTACH', channel: channelName }, + action: { type: 'suppress' }, + comment: 'RTL4f: Suppress ATTACH so server never responds', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + realtimeRequestTimeout: 3000, + } as any); + trackClient(client); + + const channel = client.channels.get(channelName); + + const channelStateChanges: string[] = []; + channel.on((change: any) => { + channelStateChanges.push(change.current); + }); + + // Connect through proxy -- connection itself is not faulted + client.connect(); + await waitForState(client, 'connected', 15000); + + // Start attach -- proxy will suppress the ATTACH, so server never responds + const attachPromise = channel.attach(); + + // Channel should enter ATTACHING immediately + await waitForChannelState(channel, 'attaching', 5000); + + // Wait for the channel to transition to SUSPENDED after realtimeRequestTimeout + await waitForChannelState(channel, 'suspended', 15000); + + // The attach() call should have failed with a timeout error + try { + await attachPromise; + expect.fail('attach should have failed'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + // Channel transitioned to SUSPENDED + expect(channel.state).to.equal('suspended'); + + // State sequence: ATTACHING -> SUSPENDED + expect(channelStateChanges).to.include('attaching'); + expect(channelStateChanges).to.include('suspended'); + const attachingIdx = channelStateChanges.indexOf('attaching'); + const suspendedIdx = channelStateChanges.indexOf('suspended'); + expect(attachingIdx).to.be.lessThan(suspendedIdx); + + // Connection remains CONNECTED (attach timeout is channel-scoped) + expect(client.connection.state).to.equal('connected'); + + // Proxy log confirms the ATTACH frames were received but suppressed by the rule. + // The log records frames before applying rules (ruleMatched indicates which rule fired). + const log = await session.getLog(); + const attachFrames = log.filter( + (e: any) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + e.message?.action === 10 && + e.message?.channel === channelName, + ); + expect(attachFrames.length).to.be.at.least(1); + // All ATTACH frames should have been caught by the suppress rule + for (const frame of attachFrames) { + expect(frame.ruleMatched).to.not.be.null; + } + + await closeAndWait(client); + }); + + /** + * RTL14 -- Server responds with ERROR to ATTACH + * + * When the proxy replaces the ATTACHED response with a channel-scoped ERROR, + * the SDK transitions the channel to FAILED. Connection remains CONNECTED. + */ + // UTS: realtime/proxy/RTL14/channel-error-goes-failed-1 + it('RTL14 - error on attach causes channel FAILED', async function () { + const channelName = uniqueChannelName('test-RTL14-error-on-attach'); + + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'ATTACHED', channel: channelName }, + action: { + type: 'replace', + message: { + action: 9, + channel: channelName, + error: { code: 40160, statusCode: 403, message: 'Not permitted' }, + }, + }, + times: 1, + comment: 'RTL14: Replace ATTACHED with channel ERROR', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const channel = client.channels.get(channelName); + + const channelStateChanges: string[] = []; + channel.on((change: any) => { + channelStateChanges.push(change.current); + }); + + // Connect through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + // Attach -- proxy replaces ATTACHED with ERROR + let attachError: any = null; + try { + await channel.attach(); + expect.fail('attach should have failed'); + } catch (err: any) { + attachError = err; + } + + // Channel should be in FAILED state + await waitForChannelState(channel, 'failed', 10000); + + // Channel transitioned to FAILED + expect(channel.state).to.equal('failed'); + + // Error reason matches the injected error + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason.code).to.equal(40160); + expect(channel.errorReason.statusCode).to.equal(403); + + // The error returned from attach() matches + expect(attachError).to.not.be.null; + expect(attachError.code).to.equal(40160); + + // State sequence: ATTACHING -> FAILED + expect(channelStateChanges).to.include('attaching'); + expect(channelStateChanges).to.include('failed'); + const attachingIdx = channelStateChanges.indexOf('attaching'); + const failedIdx = channelStateChanges.indexOf('failed'); + expect(attachingIdx).to.be.lessThan(failedIdx); + + // Connection remains CONNECTED (channel error does not affect connection) + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); + + /** + * RTL5f -- Detach timeout (server doesn't respond) + * + * Two-phase test: first connect and attach normally with no rules, + * then add a rule suppressing DETACH. The channel should revert to ATTACHED. + */ + // UTS: realtime/proxy/RTL5f/detach-timeout-suppressed-0 + it('RTL5f - detach timeout reverts channel to attached', async function () { + const channelName = uniqueChannelName('test-RTL5f'); + + // Phase 1: Create proxy session with NO fault rules (clean passthrough) + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + realtimeRequestTimeout: 3000, + } as any); + trackClient(client); + + const channel = client.channels.get(channelName); + + const channelStateChanges: string[] = []; + channel.on((change: any) => { + channelStateChanges.push(change.current); + }); + + // Phase 1: Connect and attach normally through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Clear state change history from the attach phase + channelStateChanges.length = 0; + + // Phase 2: Add rule to suppress DETACH messages + await session.addRules( + [ + { + match: { type: 'ws_frame_to_server', action: 'DETACH', channel: channelName }, + action: { type: 'suppress' }, + comment: 'RTL5f: Suppress DETACH so server never responds', + }, + ], + 'prepend', + ); + + // Phase 3: Try to detach -- proxy suppresses DETACH, so server never sends DETACHED + const detachPromise = channel.detach(); + + // Channel should enter DETACHING + await waitForChannelState(channel, 'detaching', 5000); + + // Wait for the channel to revert to ATTACHED after realtimeRequestTimeout + await waitForChannelState(channel, 'attached', 15000); + + // The detach() call should have failed with a timeout error + try { + await detachPromise; + expect.fail('detach should have failed'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + // Channel reverted to ATTACHED (previous state) + expect(channel.state).to.equal('attached'); + + // State sequence: DETACHING -> ATTACHED (revert) + expect(channelStateChanges).to.include('detaching'); + expect(channelStateChanges).to.include('attached'); + const detachingIdx = channelStateChanges.indexOf('detaching'); + const attachedIdx = channelStateChanges.indexOf('attached'); + expect(detachingIdx).to.be.lessThan(attachedIdx); + + // Connection remains CONNECTED + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); + + /** + * RTL13a -- Server sends unsolicited DETACHED, channel re-attaches + * + * Connect and attach normally, then inject a DETACHED message via triggerAction. + * The SDK should automatically re-attach against the real server. + */ + // UTS: realtime/proxy/RTL13a/unsolicited-detach-reattach-0 + it('RTL13a - unsolicited DETACHED triggers automatic reattach', async function () { + const channelName = uniqueChannelName('test-RTL13a'); + + // Create proxy session with clean passthrough (no fault rules) + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const channel = client.channels.get(channelName); + + // Connect and attach normally through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Record channel state changes from this point + const channelStateChanges: string[] = []; + channel.on((change: any) => { + channelStateChanges.push(change.current); + }); + + // Inject an unsolicited DETACHED message with error via imperative action + await session.triggerAction({ + type: 'inject_to_client', + message: { + action: 13, + channel: channelName, + error: { code: 90198, statusCode: 500, message: 'Channel detached by server' }, + }, + }); + + // Channel should transition ATTACHING (reattach) -> ATTACHED (reattach succeeds) + await waitForChannelState(channel, 'attached', 15000); + + // Channel re-attached successfully + expect(channel.state).to.equal('attached'); + + // State sequence: ATTACHING (with error from DETACHED) -> ATTACHED + expect(channelStateChanges).to.include('attaching'); + expect(channelStateChanges).to.include('attached'); + const attachingIdx = channelStateChanges.indexOf('attaching'); + const attachedIdx = channelStateChanges.indexOf('attached'); + expect(attachingIdx).to.be.lessThan(attachedIdx); + + // Connection remains CONNECTED throughout + expect(client.connection.state).to.equal('connected'); + + // Proxy log shows the re-attach ATTACH message from the client + const log = await session.getLog(); + const attachFrames = log.filter( + (e) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + e.message?.action === 10 && + e.message?.channel === channelName, + ); + // At least 2 ATTACH frames: initial attach + reattach after injected DETACHED + expect(attachFrames.length).to.be.at.least(2); + + await closeAndWait(client); + }); + + /** + * RTL14 -- Server sends channel ERROR to attached channel + * + * Connect and attach normally, then inject a channel-scoped ERROR via triggerAction. + * The channel should transition to FAILED. Connection remains CONNECTED. + */ + // UTS: realtime/proxy/RTL14/error-on-attach-0 + it('RTL14 - injected channel ERROR causes FAILED', async function () { + const channelName = uniqueChannelName('test-RTL14'); + + // Create proxy session with clean passthrough + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const channel = client.channels.get(channelName); + + // Connect and attach normally through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Record channel state changes from this point + const channelStateChanges: string[] = []; + channel.on((change: any) => { + channelStateChanges.push(change.current); + }); + + // Inject a channel-scoped ERROR message via imperative action + await session.triggerAction({ + type: 'inject_to_client', + message: { + action: 9, + channel: channelName, + error: { code: 40160, statusCode: 403, message: 'Not permitted' }, + }, + }); + + // Channel should transition to FAILED + await waitForChannelState(channel, 'failed', 10000); + + // Channel transitioned to FAILED + expect(channel.state).to.equal('failed'); + + // errorReason is set from the injected ERROR + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason.code).to.equal(40160); + expect(channel.errorReason.statusCode).to.equal(403); + expect(channel.errorReason.message).to.include('Not permitted'); + + // State change event shows only FAILED (from ATTACHED) + expect(channelStateChanges).to.deep.equal(['failed']); + + // Connection remains CONNECTED (channel-scoped ERROR does not close connection) + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); + + /** + * RTL12 -- ATTACHED with resumed=false on already-attached channel + * + * When the server sends an ATTACHED message for a channel that is already attached + * with resumed=false, the SDK emits an 'update' event (not 'attached') per RTL2g. + */ + // UTS: realtime/proxy/RTL12/attached-non-resumed-update-0 + it('RTL12 - ATTACHED with resumed=false emits UPDATE not ATTACHED', async function () { + const channelName = uniqueChannelName('test-RTL12'); + + // Create proxy session with no rules (passthrough) + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const channel = client.channels.get(channelName); + + // Connect and attach normally through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Listen for 'update' and 'attached' events separately + const updateEvents: any[] = []; + const attachedEvents: any[] = []; + channel.on('update', (change: any) => { + updateEvents.push(change); + }); + channel.on('attached', (change: any) => { + attachedEvents.push(change); + }); + + // Inject an ATTACHED message with resumed=false (flags: 0) and an error + await session.triggerAction({ + type: 'inject_to_client', + message: { + action: 11, + channel: channelName, + flags: 0, + error: { code: 91001, statusCode: 500, message: 'Continuity lost' }, + }, + }); + + // Poll until the update event arrives + await pollUntil(() => updateEvents.length >= 1, { timeout: 10000 }); + + // Exactly one 'update' event emitted + expect(updateEvents.length).to.equal(1); + expect(updateEvents[0].current).to.equal('attached'); + expect(updateEvents[0].previous).to.equal('attached'); + expect(updateEvents[0].resumed).to.equal(false); + expect(updateEvents[0].reason.code).to.equal(91001); + expect(updateEvents[0].reason.statusCode).to.equal(500); + + // No 'attached' event emitted (RTL2g: update, not attached) + expect(attachedEvents.length).to.equal(0); + + // Channel remains attached, connection remains connected + expect(channel.state).to.equal('attached'); + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); + + /** + * RTL3d -- Channels reattach after connection recovery + * + * After a transport disconnect, the SDK reconnects and automatically + * reattaches all previously-attached channels. + */ + // UTS: realtime/proxy/RTL3d/channels-reattach-on-reconnect-0 + it('RTL3d - channels reattach after connection recovery', async function () { + const channelNameA = uniqueChannelName('test-RTL3d-a'); + const channelNameB = uniqueChannelName('test-RTL3d-b'); + + // Create proxy session with no rules (passthrough) + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const channelA = client.channels.get(channelNameA); + const channelB = client.channels.get(channelNameB); + + // Connect and attach both channels normally through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + await channelA.attach(); + await channelB.attach(); + expect(channelA.state).to.equal('attached'); + expect(channelB.state).to.equal('attached'); + + // Record channel state changes from this point (clear any initial states) + const channelAStateChanges: string[] = []; + const channelBStateChanges: string[] = []; + channelA.on((change: any) => { + channelAStateChanges.push(change.current); + }); + channelB.on((change: any) => { + channelBStateChanges.push(change.current); + }); + + // Trigger a transport disconnect via WebSocket close frame + await session.triggerAction({ + type: 'close', + }); + + // Wait for connection to go disconnected first, then reconnect + await waitForState(client, 'disconnected', 15000); + await waitForState(client, 'connected', 30000); + + // Wait for both channels to reach 'attached' state after recovery + await waitForChannelState(channelA, 'attached', 15000); + await waitForChannelState(channelB, 'attached', 15000); + + // Both channels are in 'attached' state + expect(channelA.state).to.equal('attached'); + expect(channelB.state).to.equal('attached'); + + // Both channel state change arrays include 'attaching' followed by 'attached' + expect(channelAStateChanges).to.include('attaching'); + expect(channelAStateChanges).to.include('attached'); + const aAttachingIdx = channelAStateChanges.indexOf('attaching'); + const aAttachedIdx = channelAStateChanges.indexOf('attached'); + expect(aAttachingIdx).to.be.lessThan(aAttachedIdx); + + expect(channelBStateChanges).to.include('attaching'); + expect(channelBStateChanges).to.include('attached'); + const bAttachingIdx = channelBStateChanges.indexOf('attaching'); + const bAttachedIdx = channelBStateChanges.indexOf('attached'); + expect(bAttachingIdx).to.be.lessThan(bAttachedIdx); + + // Connection is connected + expect(client.connection.state).to.equal('connected'); + + // Proxy log shows at least 2 ATTACH frames for each channel (initial + reattach) + const log = await session.getLog(); + const attachFramesA = log.filter( + (e) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + e.message?.action === 10 && + e.message?.channel === channelNameA, + ); + const attachFramesB = log.filter( + (e) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + e.message?.action === 10 && + e.message?.channel === channelNameB, + ); + expect(attachFramesA.length).to.be.at.least(2); + expect(attachFramesB.length).to.be.at.least(2); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/proxy/connection_open_failures.test.ts b/test/uts/realtime/integration/proxy/connection_open_failures.test.ts new file mode 100644 index 000000000..969f500be --- /dev/null +++ b/test/uts/realtime/integration/proxy/connection_open_failures.test.ts @@ -0,0 +1,358 @@ +/** + * UTS Proxy Integration: Connection Opening Failures + * + * Spec points: RTN14a, RTN14b, RTN14c, RTN14d, RTN14g + * Source: specification/uts/realtime/integration/proxy/connection_open_failures.md + */ + +import { expect } from 'chai'; +import { + Ably, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + generateJWT, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +function waitForState(client: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for state '${targetState}' (current: ${client.connection.state})`, + ), + ), + timeout, + ); + if (client.connection.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + client.connection.off(listener); + resolve(); + } + }; + client.connection.on(listener); + }); +} + +describe('uts/realtime/integration/proxy/connection_open_failures', function () { + this.timeout(60000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RTN14a — Fatal error during connection open causes FAILED + */ + // UTS: realtime/proxy/RTN14a/fatal-connect-error-0 + it('RTN14a - fatal error during connection open causes FAILED', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'CONNECTED' }, + action: { + type: 'replace', + message: { + action: 9, + error: { code: 40005, statusCode: 400, message: 'Invalid key' }, + }, + }, + times: 1, + comment: 'RTN14a: Replace CONNECTED with fatal ERROR', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await waitForState(client, 'failed', 15000); + + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(40005); + expect(client.connection.errorReason.statusCode).to.equal(400); + + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('failed'); + const connectingIdx = stateChanges.indexOf('connecting'); + const failedIdx = stateChanges.indexOf('failed'); + expect(connectingIdx).to.be.lessThan(failedIdx); + + expect(client.connection.id).to.not.exist; + expect(client.connection.key).to.not.exist; + }); + + /** + * RTN14b — Token error during connection, SDK renews and reconnects + */ + // UTS: realtime/proxy/RTN14b/token-error-renew-reconnect-0 + it('RTN14b - token error during connection triggers renewal and reconnect', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'CONNECTED' }, + action: { + type: 'replace', + message: { + action: 9, + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }, + }, + times: 1, + comment: 'RTN14b: Token error on first connect, renewal should succeed', + }, + ], + }); + + let authCallbackCount = 0; + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + authCallbackCount++; + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await waitForState(client, 'connected', 30000); + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.exist; + expect(client.connection.key).to.exist; + expect(authCallbackCount).to.be.at.least(2); + + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('connected'); + + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + + expect(client.connection.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RTN14c — Connection timeout (no CONNECTED received) + */ + // UTS: realtime/proxy/RTN14c/connection-timeout-0 + it('RTN14c - connection timeout when CONNECTED is suppressed', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'CONNECTED' }, + action: { type: 'suppress' }, + comment: 'RTN14c: Suppress CONNECTED to force timeout', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + realtimeRequestTimeout: 3000, + } as any); + trackClient(client); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await waitForState(client, 'disconnected', 15000); + + expect(client.connection.state).to.equal('disconnected'); + expect(client.connection.errorReason).to.not.be.null; + + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('disconnected'); + const connectingIdx = stateChanges.indexOf('connecting'); + const disconnectedIdx = stateChanges.indexOf('disconnected'); + expect(connectingIdx).to.be.lessThan(disconnectedIdx); + + expect(client.connection.id).to.not.exist; + expect(client.connection.key).to.not.exist; + + await closeAndWait(client); + }); + + /** + * RTN14d — Retry after connection refused + */ + // UTS: realtime/proxy/RTN14d/retry-after-refused-0 + it('RTN14d - retry after connection refused', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_connect', count: 1 }, + action: { type: 'refuse_connection' }, + times: 1, + comment: 'RTN14d: Refuse first WebSocket connection', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + disconnectedRetryTimeout: 2000, + } as any); + trackClient(client); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await waitForState(client, 'connected', 30000); + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.exist; + expect(client.connection.key).to.exist; + + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('disconnected'); + expect(stateChanges).to.include('connected'); + + const connectingIdx = stateChanges.indexOf('connecting'); + const disconnectedIdx = stateChanges.indexOf('disconnected'); + const lastConnectedIdx = stateChanges.lastIndexOf('connected'); + expect(connectingIdx).to.be.lessThan(disconnectedIdx); + expect(disconnectedIdx).to.be.lessThan(lastConnectedIdx); + + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + + await closeAndWait(client); + }); + + /** + * RTN14g — Connection-level ERROR during open causes FAILED + */ + // UTS: realtime/proxy/RTN14g/server-error-causes-failed-0 + it('RTN14g - server error during connection open causes FAILED', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'CONNECTED' }, + action: { + type: 'replace', + message: { + action: 9, + error: { code: 50000, statusCode: 500, message: 'Internal server error' }, + }, + }, + times: 1, + comment: 'RTN14g: Connection-level ERROR (server error) during open', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await waitForState(client, 'failed', 15000); + + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(50000); + expect(client.connection.errorReason.statusCode).to.equal(500); + + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('failed'); + const connectingIdx = stateChanges.indexOf('connecting'); + const failedIdx = stateChanges.indexOf('failed'); + expect(connectingIdx).to.be.lessThan(failedIdx); + + expect(client.connection.id).to.not.exist; + expect(client.connection.key).to.not.exist; + }); +}); diff --git a/test/uts/realtime/integration/proxy/connection_resume.test.ts b/test/uts/realtime/integration/proxy/connection_resume.test.ts new file mode 100644 index 000000000..1bcc01aa4 --- /dev/null +++ b/test/uts/realtime/integration/proxy/connection_resume.test.ts @@ -0,0 +1,1051 @@ +/** + * UTS Proxy Integration: Connection Resume and Recovery Tests + * + * Spec points: RTN15a, RTN15b, RTN15c6, RTN15c7, RTN15h1, RTN15h3, RTN15j, RTN15g, RTN15g2, RTN19a, RTN19a2, RTN16d, RTN16k, RTN16l + * Source: specification/uts/realtime/integration/proxy/connection_resume.md + */ + +import { expect } from 'chai'; +import { + Ably, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + connectAndWait, + generateJWT, + pollUntil, + uniqueChannelName, + SANDBOX_ENDPOINT, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +function waitForState(client: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for state '${targetState}' (current: ${client.connection.state})`, + ), + ), + timeout, + ); + if (client.connection.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + client.connection.off(listener); + resolve(); + } + }; + client.connection.on(listener); + }); +} + +function waitForChannelState(channel: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for channel state '${targetState}' (current: ${channel.state})`, + ), + ), + timeout, + ); + if (channel.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + channel.off(listener); + resolve(); + } + }; + channel.on(listener); + }); +} + +describe('uts/realtime/integration/proxy/connection_resume', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RTN15a — Unexpected disconnect triggers resume + * + * Proxy passthrough, then imperative disconnect. Verify state sequence + * (disconnected -> connecting -> connected) and that the 2nd ws_connect + * has a `resume` query parameter. + */ + // UTS: realtime/proxy/RTN15a/disconnect-triggers-resume-0 + it('RTN15a - unexpected disconnect triggers resume', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'delay_after_ws_connect', delayMs: 1000 }, + action: { type: 'close' }, + times: 1, + comment: 'RTN15a: Close WebSocket after 1s to trigger unexpected disconnect', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Record state changes before connecting + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + // Connect through proxy — proxy will close WebSocket after 1s + client.connect(); + await waitForState(client, 'connected', 15000); + + // Wait for disconnected (triggered by temporal close), then reconnected + await waitForState(client, 'disconnected', 10000); + await waitForState(client, 'connected', 15000); + + // State changes should include disconnected -> connecting -> connected (after initial connect) + expect(stateChanges).to.include('disconnected'); + const disconnectedIdx = stateChanges.indexOf('disconnected'); + const reconnectingIdx = stateChanges.indexOf('connecting', disconnectedIdx); + const reconnectedIdx = stateChanges.indexOf('connected', reconnectingIdx); + expect(reconnectingIdx).to.be.greaterThan(disconnectedIdx); + expect(reconnectedIdx).to.be.greaterThan(reconnectingIdx); + + // Verify resume was attempted via proxy log + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + + // Second WebSocket connection should include resume query parameter + expect(wsConnects[1].queryParams).to.exist; + expect(wsConnects[1].queryParams!['resume']).to.exist; + + await closeAndWait(client); + }); + + /** + * RTN15a — Unexpected disconnect triggers resume (TCP close without close frame) + * + * Same as the test above, but the proxy closes the underlying TCP connection + * without sending a WebSocket close frame. The Node.js ws library detects + * the TCP FIN and fires its close event, so ably-js should transition to + * disconnected with minimal delay — identical to the close-frame case. + */ + // UTS: realtime/proxy/RTN15a/tcp-close-triggers-resume-1 + it('RTN15a - unexpected disconnect triggers resume (TCP close without close frame)', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'delay_after_ws_connect', delayMs: 1000 }, + action: { type: 'disconnect' }, + times: 1, + comment: 'RTN15a: Close TCP connection (no close frame) after 1s to trigger unexpected disconnect', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await waitForState(client, 'connected', 15000); + + await waitForState(client, 'disconnected', 10000); + await waitForState(client, 'connected', 15000); + + expect(stateChanges).to.include('disconnected'); + const disconnectedIdx = stateChanges.indexOf('disconnected'); + const reconnectingIdx = stateChanges.indexOf('connecting', disconnectedIdx); + const reconnectedIdx = stateChanges.indexOf('connected', reconnectingIdx); + expect(reconnectingIdx).to.be.greaterThan(disconnectedIdx); + expect(reconnectedIdx).to.be.greaterThan(reconnectingIdx); + + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + + expect(wsConnects[1].queryParams).to.exist; + expect(wsConnects[1].queryParams!['resume']).to.exist; + + await closeAndWait(client); + }); + + /** + * RTN15b, RTN15c6 — Resume preserves connectionId + * + * After unexpected disconnect and successful resume, the connection ID + * remains the same and the resume query parameter contains the connection key. + */ + // UTS: realtime/proxy/RTN15b/resume-preserves-connid-0 + it('RTN15b/RTN15c6 - resume preserves connectionId', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'delay_after_ws_connect', delayMs: 1000 }, + action: { type: 'close' }, + times: 1, + comment: 'RTN15b: Close WebSocket after 1s to trigger disconnect', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Connect through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + // Record connection identity before disconnect + const originalConnectionId = client.connection.id; + const originalConnectionKey = client.connection.key; + expect(originalConnectionId).to.exist; + expect(originalConnectionKey).to.exist; + + // Temporal trigger closes WebSocket after 1s — wait for disconnect, then reconnect + await waitForState(client, 'disconnected', 10000); + await waitForState(client, 'connected', 15000); + + // RTN15c6: Connection ID is preserved (successful resume) + expect(client.connection.id).to.equal(originalConnectionId); + + // RTN15b: Second ws_connect URL includes resume={connectionKey} + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + expect(wsConnects[1].queryParams).to.exist; + expect(wsConnects[1].queryParams!['resume']).to.equal(originalConnectionKey); + + // No error reason on successful resume + expect(client.connection.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RTN15c7 — Failed resume gets new connectionId + * + * Proxy replaces the 2nd CONNECTED (the resume response) with one containing + * a different connectionId and error code 80008. SDK should accept the new + * connection identity and expose the error. + */ + // UTS: realtime/proxy/RTN15c7/failed-resume-new-connid-0 + it('RTN15c7 - failed resume gets new connectionId', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'delay_after_ws_connect', delayMs: 1000 }, + action: { type: 'close' }, + times: 1, + comment: 'RTN15c7: Close WebSocket after 1s to trigger disconnect', + }, + { + match: { type: 'ws_frame_to_client', action: 'CONNECTED', count: 2 }, + action: { + type: 'replace', + message: { + action: 4, + connectionId: 'proxy-injected-new-id', + connectionKey: 'proxy-injected-new-key', + connectionDetails: { + connectionKey: 'proxy-injected-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: 'RTN15c7: Replace 2nd CONNECTED with failed resume (different connectionId + error 80008)', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Connect through proxy — first CONNECTED passes through normally + client.connect(); + await waitForState(client, 'connected', 15000); + + // Record original identity + const originalConnectionId = client.connection.id; + expect(originalConnectionId).to.exist; + expect(originalConnectionId).to.not.equal('proxy-injected-new-id'); + + // Temporal trigger closes WebSocket after 1s — SDK will attempt resume + // Proxy replaces the CONNECTED response with a new connectionId + await waitForState(client, 'disconnected', 10000); + await waitForState(client, 'connected', 15000); + + // RTN15c7: Connection ID changed (resume failed, got new connection) + expect(client.connection.id).to.equal('proxy-injected-new-id'); + expect(client.connection.id).to.not.equal(originalConnectionId); + + // Connection key updated to the new one + expect(client.connection.key).to.equal('proxy-injected-new-key'); + + // Error reason is set indicating why resume failed + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(80008); + + // Connection is still CONNECTED (not FAILED — the server gave a new connection) + expect(client.connection.state).to.equal('connected'); + + // Verify resume was attempted in the proxy log + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + expect(wsConnects[1].queryParams).to.exist; + expect(wsConnects[1].queryParams!['resume']).to.exist; + + await closeAndWait(client); + }); + + /** + * RTN15h1 — DISCONNECTED with token error + non-renewable token -> FAILED + * + * Proxy injects DISCONNECTED with error 40142 after 1s and closes the socket. + * Client is configured with a token string only (no key, no authCallback) + * so it cannot renew. SDK should transition to FAILED. + */ + // UTS: realtime/proxy/RTN15h1/token-error-nonrenewable-failed-0 + it('RTN15h1 - DISCONNECTED with token error and non-renewable token causes FAILED', async function () { + session = await createProxySession({ + 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, + comment: 'RTN15h1: Inject DISCONNECTED with token error (40142) after 1s', + }, + ], + }); + + // Provision a real token from the sandbox so the initial connection succeeds + const restClient = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + const tokenDetails = await restClient.auth.requestToken(); + + // Use only the token string — no key, no authCallback — making it non-renewable + const client = new Ably.Realtime({ + token: tokenDetails.token, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Connect through proxy — initial connection succeeds with the real token + client.connect(); + await waitForState(client, 'connected', 15000); + + // Record state changes + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + // After 1s the proxy injects DISCONNECTED with 40142 and closes the socket. + // The SDK has a non-renewable token, so it cannot renew -> FAILED. + await waitForState(client, 'failed', 15000); + + // RTN15h1: Ended in FAILED state + expect(client.connection.state).to.equal('failed'); + + // Error reason reflects the non-renewable token condition — ably-js reports + // 40171 ("Token not renewable") rather than the original 40142 because the SDK + // detects it has no means to renew (no key, no authCallback, no authUrl) + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(40171); + + // State changes should show the transition to FAILED + expect(stateChanges).to.include('failed'); + + // No need to close — already in FAILED state + }); + + /** + * RTN15h3 — DISCONNECTED with non-token error triggers reconnect + * + * Proxy injects DISCONNECTED with error 80003 after 1s and closes the socket. + * Rule fires once, so the reconnection attempt passes through cleanly. + * SDK should reconnect and resume rather than transitioning to FAILED. + */ + // UTS: realtime/proxy/RTN15h3/non-token-error-reconnects-0 + it('RTN15h3 - DISCONNECTED with non-token error triggers reconnect', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'delay_after_ws_connect', delayMs: 1000 }, + action: { + type: 'inject_to_client_and_close', + message: { + action: 6, + error: { + code: 80003, + statusCode: 500, + message: 'Service temporarily unavailable', + }, + }, + }, + times: 1, + comment: 'RTN15h3: Inject DISCONNECTED with non-token error (80003) after 1s, once only', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Connect through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + // Record state changes + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + // After 1s the proxy injects DISCONNECTED with non-token error and closes. + // The rule fires once, so the reconnection attempt passes through to the real server. + + // Wait for DISCONNECTED (from the injected message) + await waitForState(client, 'disconnected', 10000); + + // SDK should automatically reconnect + await waitForState(client, 'connected', 15000); + + // RTN15h3: SDK reconnected successfully (not FAILED) + expect(client.connection.state).to.equal('connected'); + + // State changes should show: disconnected -> connecting -> connected + expect(stateChanges).to.include('disconnected'); + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('connected'); + const disconnectedIdx = stateChanges.indexOf('disconnected'); + const connectingIdx = stateChanges.indexOf('connecting'); + const connectedIdx = stateChanges.indexOf('connected'); + expect(disconnectedIdx).to.be.lessThan(connectingIdx); + expect(connectingIdx).to.be.lessThan(connectedIdx); + + // Verify resume was attempted + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + expect(wsConnects[1].queryParams).to.exist; + expect(wsConnects[1].queryParams!['resume']).to.exist; + + // No error reason after successful reconnection + expect(client.connection.errorReason).to.be.null; + + await closeAndWait(client); + }); + + /** + * RTN15j — Fatal ERROR on established connection + * + * Inject a connection-level ERROR (action 9) with a fatal error code. + * SDK should transition to FAILED and all attached channels should also + * transition to FAILED with the same error. + */ + // UTS: realtime/proxy/RTN15j/fatal-error-established-conn-0 + it('RTN15j - fatal ERROR on established connection causes FAILED and channels FAILED', async function () { + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Connect through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + // Attach two channels + const channelNameA = uniqueChannelName('test-fatal-error-a'); + const channelNameB = uniqueChannelName('test-fatal-error-b'); + const channelA = client.channels.get(channelNameA); + const channelB = client.channels.get(channelNameB); + + channelA.attach(); + channelB.attach(); + await Promise.all([ + waitForChannelState(channelA, 'attached', 15000), + waitForChannelState(channelB, 'attached', 15000), + ]); + + // Record state changes for connection and both channels + const connectionStateChanges: string[] = []; + const channelAStateChanges: string[] = []; + const channelBStateChanges: string[] = []; + + client.connection.on((change: any) => { + connectionStateChanges.push(change.current); + }); + channelA.on((change: any) => { + channelAStateChanges.push(change.current); + }); + channelB.on((change: any) => { + channelBStateChanges.push(change.current); + }); + + // Inject a connection-level ERROR (action 9) with a fatal error code + // No channel field — this is a connection-level error + await session.triggerAction({ + type: 'inject_to_client', + message: { + action: 9, + error: { + code: 50000, + statusCode: 500, + message: 'Internal server error', + }, + }, + }); + + // Wait for connection to reach FAILED + await waitForState(client, 'failed', 15000); + + // Connection is in FAILED state + expect(client.connection.state).to.equal('failed'); + + // Connection error reason reflects the injected error + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(50000); + expect(client.connection.errorReason.statusCode).to.equal(500); + + // Both channels should be in FAILED state + expect(channelA.state).to.equal('failed'); + expect(channelB.state).to.equal('failed'); + + // Both channels should have the same error + expect(channelA.errorReason).to.not.be.null; + expect(channelA.errorReason.code).to.equal(50000); + expect(channelB.errorReason).to.not.be.null; + expect(channelB.errorReason.code).to.equal(50000); + + // State changes include 'failed' for connection and both channels + expect(connectionStateChanges).to.include('failed'); + expect(channelAStateChanges).to.include('failed'); + expect(channelBStateChanges).to.include('failed'); + + // Proxy log should show exactly 1 ws_connect (no reconnection attempt) + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects).to.have.length(1); + }); + + /** + * RTN15g/g2 — connectionStateTtl expiry clears resume state + * + * Proxy replaces the first CONNECTED with one that has very short + * connectionStateTtl and maxIdleInterval, then suppresses traffic after + * 2s to trigger idle timeout. After the TTL expires, the SDK should + * connect fresh (no resume) and get a new connectionId. + */ + // UTS: realtime/proxy/RTN15g/ttl-expiry-clears-resume-0 + it('RTN15g/g2 - connectionStateTtl expiry prevents resume', async function () { + // Strategy: replace the first CONNECTED with connectionStateTtl=2000ms, + // then close the WebSocket after 1s. The SDK immediately retries (since it + // was connected), but we refuse the 2nd ws_connect so the SDK stays in + // disconnected. After the connectionStateTtl (2s) expires, the SDK enters + // SUSPENDED and clears resume state. The 3rd ws_connect (after suspended + // retry) should have no resume param. + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'CONNECTED', count: 1 }, + action: { + type: 'replace', + message: { + action: 4, + connectionId: 'proxy-ttl-test-id', + connectionKey: 'proxy-ttl-test-key', + connectionDetails: { + connectionKey: 'proxy-ttl-test-key', + clientId: null, + maxMessageSize: 65536, + maxInboundRate: 250, + maxOutboundRate: 100, + maxFrameSize: 524288, + serverId: 'test-server', + connectionStateTtl: 2000, + maxIdleInterval: 15000, + }, + }, + }, + times: 1, + comment: + 'RTN15g: Replace 1st CONNECTED with short connectionStateTtl (2s)', + }, + { + match: { type: 'delay_after_ws_connect', delayMs: 1000 }, + action: { type: 'close' }, + times: 1, + comment: 'RTN15g: Close WebSocket after 1s to trigger disconnect', + }, + { + match: { type: 'ws_connect', count: 2 }, + action: { type: 'refuse_connection' }, + times: 1, + comment: 'RTN15g: Refuse 2nd connection so SDK stays in disconnected until TTL expires', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + suspendedRetryTimeout: 1000, + } as any); + trackClient(client); + + // Connect through proxy — first CONNECTED is replaced with short TTLs + client.connect(); + await waitForState(client, 'connected', 15000); + + // Record the connection ID from the replaced CONNECTED + const originalConnectionId = client.connection.id; + expect(originalConnectionId).to.equal('proxy-ttl-test-id'); + + // T=1: proxy closes WebSocket → SDK enters DISCONNECTED, retries immediately + // T=1: 2nd ws_connect is refused → SDK stays in DISCONNECTED + // T=3: connectionStateTtl (2s) expires → SDK enters SUSPENDED, clears resume state + // T=4: suspendedRetryTimeout (1s) fires → SDK connects fresh (no resume) + await waitForState(client, 'suspended', 15000); + + // Wait for fresh connection (no resume) + await waitForState(client, 'connected', 15000); + + // RTN15g: Connection ID changed — this is a fresh connection, not a resume + expect(client.connection.id).to.not.equal(originalConnectionId); + + // Verify via proxy log: the final ws_connect does NOT have resume param + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + // At least 3: initial, refused retry (with resume), fresh from suspended (no resume) + expect(wsConnects.length).to.be.at.least(3); + + // 1st ws_connect: initial connection, no resume + expect( + wsConnects[0].queryParams == null || wsConnects[0].queryParams!['resume'] == null, + ).to.be.true; + + // Last ws_connect: fresh connection from suspended (TTL expired), no resume + const lastConnect = wsConnects[wsConnects.length - 1]; + expect( + lastConnect.queryParams == null || lastConnect.queryParams!['resume'] == null, + ).to.be.true; + + await closeAndWait(client); + }); + + /** + * RTN19a/a2 — Unacked messages resent on new transport after resume + * + * Proxy suppresses the first ACK so the client's publish is left unacked. + * After disconnect and resume, the SDK should resend the MESSAGE on the + * new transport and the publish should eventually resolve successfully. + */ + // UTS: realtime/proxy/RTN19a/unacked-resent-on-resume-0 + it('RTN19a/a2 - unacked messages resent on new transport after resume', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'ws_frame_to_client', action: 'ACK', count: 1 }, + action: { type: 'suppress' }, + times: 1, + comment: 'RTN19a: Suppress the first ACK so the MESSAGE remains unacked', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Connect through proxy + client.connect(); + await waitForState(client, 'connected', 15000); + + // Attach a channel + const channelName = uniqueChannelName('test-rtn19a-resend'); + const channel = client.channels.get(channelName); + channel.attach(); + await waitForChannelState(channel, 'attached', 15000); + + // Start publish but don't await — the ACK will be suppressed + const publishPromise = channel.publish('event', 'test-data'); + + // Wait until the proxy log shows the MESSAGE was sent and its ACK suppressed + await pollUntil( + async () => { + const log = await session!.getLog(); + const messageFrames = log.filter( + (e) => e.type === 'ws_frame' && e.direction === 'client_to_server' && e.message?.action === 15, + ); + const suppressedAcks = log.filter( + (e) => e.type === 'ws_frame' && e.direction === 'server_to_client' && e.message?.action === 1 && e.ruleMatched, + ); + return messageFrames.length > 0 && suppressedAcks.length > 0; + }, + { interval: 100, timeout: 10000 }, + ); + + // Now close the WebSocket — SDK will attempt resume with the unacked message + await session.triggerAction({ type: 'close' }); + + // Wait for disconnected, then reconnected via resume + await waitForState(client, 'disconnected', 10000); + await waitForState(client, 'connected', 15000); + + // Await the publish — should resolve successfully after resend on new transport + await publishPromise; + + // Verify resume was attempted + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + expect(wsConnects[1].queryParams).to.exist; + expect(wsConnects[1].queryParams!['resume']).to.exist; + + // Verify MESSAGE frames were sent at least twice (original + resend) + const messageFrames = log.filter( + (e) => e.type === 'ws_frame' && e.direction === 'client_to_server' && e.message?.action === 15, + ); + expect(messageFrames.length).to.be.at.least(2); + + await closeAndWait(client); + }); + + /** + * RTN16d, RTN16k — Successful recovery preserves connectionId and updates connectionKey + * + * Phase 1: Connect through proxy, attach a channel, get recoveryKey, then + * forcibly close the transport (server keeps connection state alive). + * Phase 2: Create a NEW client with `recover: recoveryKey`, connect through + * a second proxy session. + * Verify: connectionId same, connectionKey updated, recover param in log. + */ + // UTS: realtime/proxy/RTN16d/recovery-preserves-connid-0 + it('RTN16d/RTN16k - successful recovery preserves connectionId and updates connectionKey', async function () { + // --- Phase 1: Establish initial connection and obtain recovery key --- + const session1 = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client1 = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session1.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client1); + + client1.connect(); + await waitForState(client1, 'connected', 15000); + + const originalConnectionId = client1.connection.id; + const originalConnectionKey = client1.connection.key; + expect(originalConnectionId).to.exist; + expect(originalConnectionKey).to.exist; + + // Attach a channel so it appears in the recovery key + const channelName = uniqueChannelName('recovery-test'); + const channel1 = client1.channels.get(channelName); + channel1.attach(); + await waitForChannelState(channel1, 'attached', 15000); + + // Get the recovery key + const recoveryKey = client1.connection.createRecoveryKey(); + expect(recoveryKey).to.not.be.null; + + // Forcibly close the WebSocket transport (server keeps connection state alive) + await session1.triggerAction({ type: 'close' }); + + // Wait for the client to detect the disconnect + await waitForState(client1, 'disconnected', 10000); + + // Close client1 without allowing it to reconnect + client1.connection.close(); + await waitForState(client1, 'closed', 10000); + await session1.close(); + + // --- Phase 2: Recover using the recovery key --- + session = await createProxySession({ + rules: [], + }); + + const client2 = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + recover: recoveryKey, + } as any); + trackClient(client2); + + client2.connect(); + await waitForState(client2, 'connected', 15000); + + // RTN16d: Connection ID is preserved (same as original connection) + expect(client2.connection.id).to.equal(originalConnectionId); + + // RTN16d: Connection key is updated (new key from server) + expect(client2.connection.key).to.exist; + expect(client2.connection.key).to.not.equal(originalConnectionKey); + + // RTN16k: Verify the recover query parameter was sent via proxy log + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(1); + expect(wsConnects[0].queryParams).to.exist; + expect(wsConnects[0].queryParams!['recover']).to.equal(originalConnectionKey); + + // No resume param (this is recovery, not resume) + expect( + wsConnects[0].queryParams!['resume'] == null, + ).to.be.true; + + // No error on successful recovery + expect(client2.connection.errorReason).to.be.null; + + await closeAndWait(client2); + }); + + /** + * RTN16l — Recovery failure treated as fresh connection (per RTN15c7) + * + * Proxy replaces the first CONNECTED response with one that has a different + * connectionId and an error (code 80008), simulating the server rejecting + * the recovery attempt. SDK should handle it as a fresh connection. + */ + // UTS: realtime/proxy/RTN16l/recovery-failure-fresh-conn-0 + it('RTN16l - recovery failure treated as fresh connection', async function () { + session = await createProxySession({ + 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)', + }, + ], + }); + + // Fabricated recovery key — connectionKey doesn't need to be valid since + // the proxy will replace the server response anyway + const fabricatedRecoveryKey = JSON.stringify({ + connectionKey: 'stale-old-key', + msgSerial: 99, + channelSerials: { + 'old-channel': 'old-serial', + }, + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + recover: fabricatedRecoveryKey, + } as any); + trackClient(client); + + // Connect with the fabricated recovery key + client.connect(); + await waitForState(client, 'connected', 15000); + + // RTN16l + RTN15c7: Connection got a new ID (recovery failed) + expect(client.connection.id).to.equal('recovery-failed-new-id'); + expect(client.connection.key).to.equal('recovery-failed-new-key'); + + // RTN15c7: Error is set on the connection indicating recovery failure + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason.code).to.equal(80008); + + // Connection is still CONNECTED (not FAILED — the server gave a new connection) + expect(client.connection.state).to.equal('connected'); + + // Verify the recover param was sent via proxy log + const log = await session.getLog(); + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(1); + expect(wsConnects[0].queryParams).to.exist; + expect(wsConnects[0].queryParams!['recover']).to.equal('stale-old-key'); + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/proxy/heartbeat.test.ts b/test/uts/realtime/integration/proxy/heartbeat.test.ts new file mode 100644 index 000000000..38a959b94 --- /dev/null +++ b/test/uts/realtime/integration/proxy/heartbeat.test.ts @@ -0,0 +1,163 @@ +/** + * UTS Proxy Integration: Heartbeat Tests + * + * Spec points: RTN23a + * Source: specification/uts/realtime/integration/proxy/heartbeat.md + */ + +import { expect } from 'chai'; +import { + Ably, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + generateJWT, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +function waitForState(client: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for state '${targetState}' (current: ${client.connection.state})`, + ), + ), + timeout, + ); + if (client.connection.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + client.connection.off(listener); + resolve(); + } + }; + client.connection.on(listener); + }); +} + +describe('uts/realtime/integration/proxy/heartbeat', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RTN23a — Heartbeat starvation causes disconnect and reconnect + * + * 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. + * + * Note: We use 'close' rather than 'suppress_onwards' because + * suppress_onwards is session-scoped and would affect the reconnection too. + */ + // UTS: realtime/proxy/RTN23a/heartbeat-starvation-reconnect-0 + it('RTN23a - heartbeat starvation causes disconnect and reconnect', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'delay_after_ws_connect', delayMs: 2000 }, + action: { type: 'close' }, + times: 1, + comment: 'RTN23a: Close WebSocket after 2s to simulate transport failure', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + // Record state changes for sequence verification + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + // Start connection + client.connect(); + + // SDK receives real CONNECTED from Ably (within the 2s before close fires) + await waitForState(client, 'connected', 15000); + + // Capture connection details from the first connection + const firstConnectionId = client.connection.id; + expect(firstConnectionId).to.exist; + + // At T+2s the proxy closes the WebSocket. The SDK transitions to DISCONNECTED + // and automatically reconnects. The close rule fires once, so the second + // WebSocket connection passes through unaffected. + + // Wait for disconnected + await waitForState(client, 'disconnected', 15000); + + // Wait for reconnection + await waitForState(client, 'connected', 30000); + + // Connection is re-established + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.exist; + expect(client.connection.key).to.exist; + + // State sequence shows: connecting -> connected -> disconnected -> connecting -> connected + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('connected'); + expect(stateChanges).to.include('disconnected'); + + const firstConnectingIdx = stateChanges.indexOf('connecting'); + const firstConnectedIdx = stateChanges.indexOf('connected'); + const disconnectedIdx = stateChanges.indexOf('disconnected'); + const secondConnectingIdx = stateChanges.indexOf('connecting', disconnectedIdx); + const lastConnectedIdx = stateChanges.lastIndexOf('connected'); + + expect(firstConnectingIdx).to.be.lessThan(firstConnectedIdx); + expect(firstConnectedIdx).to.be.lessThan(disconnectedIdx); + expect(secondConnectingIdx).to.be.greaterThan(disconnectedIdx); + expect(lastConnectedIdx).to.be.greaterThan(secondConnectingIdx); + + // Proxy event log confirms two WebSocket connections + const log = await session.getLog(); + const wsConnects = log.filter((e: any) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(2); + + // Second connection should include resume parameter (RTN15c) + expect(wsConnects[1].queryParams?.resume).to.exist; + + await closeAndWait(client); + }); +}); diff --git a/test/uts/realtime/integration/proxy/presence_reentry.test.ts b/test/uts/realtime/integration/proxy/presence_reentry.test.ts new file mode 100644 index 000000000..078e7f6ce --- /dev/null +++ b/test/uts/realtime/integration/proxy/presence_reentry.test.ts @@ -0,0 +1,311 @@ +/** + * UTS Proxy Integration: Presence Re-entry Tests + * + * Spec points: RTP17i, RTP17g + */ + +import { expect } from 'chai'; +import { + Ably, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + generateJWT, + uniqueChannelName, + pollUntil, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +function waitForState(client: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for state '${targetState}' (current: ${client.connection.state})`, + ), + ), + timeout, + ); + if (client.connection.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + client.connection.off(listener); + resolve(); + } + }; + client.connection.on(listener); + }); +} + +function waitForChannelState(channel: any, targetState: string, timeout = 15000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + new Error( + `Timed out waiting for channel state '${targetState}' (current: ${channel.state})`, + ), + ), + timeout, + ); + if (channel.state === targetState) { + clearTimeout(timer); + resolve(); + return; + } + const listener = (stateChange: any) => { + if (stateChange.current === targetState) { + clearTimeout(timer); + channel.off(listener); + resolve(); + } + }; + channel.on(listener); + }); +} + +describe('uts/realtime/integration/proxy/presence_reentry', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RTP17i/RTP17g — Automatic presence re-enter on non-resumed reattach + * + * When a channel receives an ATTACHED message without the RESUMED flag after + * already being attached, the SDK should automatically re-enter any presence + * members that were previously entered on that channel. + * + * We verify this by injecting a non-resumed ATTACHED via the proxy and checking + * the proxy log for a PRESENCE ENTER frame sent by the SDK afterward. The server + * won't broadcast the re-enter to other subscribers (since from the server's + * perspective the member never left), so we verify the SDK's behavior via the + * proxy log rather than via a second client. + */ + // UTS: realtime/proxy/RTP17i/reenter-on-non-resumed-0 + it('RTP17i/RTP17g - automatic presence re-enter on non-resumed reattach', async function () { + const channelName = uniqueChannelName('test-rtp17i'); + + session = await createProxySession({}); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + const client = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, clientId: 'client-a' })); + }, + endpoint: 'localhost', + port: session!.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(client); + + client.connect(); + await waitForState(client, 'connected', 15000); + + const channel = client.channels.get(channelName); + await channel.attach(); + await channel.presence.enter('hello'); + + // Count PRESENCE frames before the injection + const logBefore = await session!.getLog(); + const presenceFramesBefore = logBefore.filter( + (e: any) => e.type === 'ws_frame' && e.direction === 'client_to_server' && e.message?.action === 14, + ).length; + + // Inject ATTACHED without RESUMED flag — triggers RTP17i re-entry + await session!.triggerAction({ + type: 'inject_to_client', + message: { + action: 11, + channel: channelName, + flags: 0, + error: { code: 91001, statusCode: 500, message: 'Continuity lost' }, + }, + }); + + // Wait for the SDK to process the ATTACHED and send the re-enter + await pollUntil( + async () => { + const log = await session!.getLog(); + const presenceFrames = log.filter( + (e: any) => e.type === 'ws_frame' && e.direction === 'client_to_server' && e.message?.action === 14, + ); + return presenceFrames.length > presenceFramesBefore; + }, + { interval: 200, timeout: 10000 }, + ); + + // Get final log and verify + const logAfter = await session!.getLog(); + const allPresenceFrames = logAfter.filter( + (e: any) => e.type === 'ws_frame' && e.direction === 'client_to_server' && e.message?.action === 14, + ); + + // At least one new PRESENCE frame was sent after the injection + expect(allPresenceFrames.length).to.be.greaterThan(presenceFramesBefore); + + // The re-enter PRESENCE frame should contain the presence data + const reenterFrame = allPresenceFrames[allPresenceFrames.length - 1]; + expect(reenterFrame.message.presence).to.exist; + expect(reenterFrame.message.presence.length).to.be.at.least(1); + + const reenterMsg = reenterFrame.message.presence[0]; + expect(reenterMsg.clientId).to.equal('client-a'); + expect(reenterMsg.data).to.equal('hello'); + // RTP17g: action should be ENTER (action=2) + expect(reenterMsg.action).to.equal(2); + + // Channel should still be attached + expect(channel.state).to.equal('attached'); + + // Connection should still be connected + expect(client.connection.state).to.equal('connected'); + + await closeAndWait(client); + }); + + /** + * RTP17i via real disconnect — Presence re-enter after connection loss + * + * Client enters presence, then the proxy closes the WebSocket via a temporal + * trigger. On reconnection, the proxy replaces the 2nd ATTACHED with a + * non-resumed one (simulating channel state loss). The SDK should re-enter + * presence. We verify via proxy log that the PRESENCE ENTER was sent. + */ + // UTS: realtime/proxy/RTP17i/reenter-after-disconnect-1 + it('RTP17i - presence re-enter after real disconnect', async function () { + const channelName = uniqueChannelName('test-rtp17i-real'); + + // Two 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 = await createProxySession({ + 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: channelName, count: 2 }, + action: { + type: 'replace', + message: { + action: 11, + channel: channelName, + flags: 0, + error: { code: 91001, statusCode: 500, message: 'Continuity lost' }, + }, + }, + times: 1, + comment: 'RTP17i: Replace 2nd ATTACHED with non-resumed to trigger re-entry', + }, + ], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + + const clientA = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret, clientId: 'client-a' })); + }, + endpoint: 'localhost', + port: session!.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(clientA); + + clientA.connect(); + await waitForState(clientA, 'connected', 15000); + + const channelA = clientA.channels.get(channelName); + await channelA.attach(); + await channelA.presence.enter('hello'); + + // The temporal trigger will close the WebSocket at T+3s. + // Wait for disconnect and reconnect. + await waitForState(clientA, 'disconnected', 10000); + await waitForState(clientA, 'connected', 15000); + + // Wait for the channel to reattach (the 2nd ATTACHED will be replaced with non-resumed) + await waitForChannelState(channelA, 'attached', 15000); + + // After reconnection with non-resumed ATTACHED, the SDK should re-enter presence. + // Verify via proxy log: a PRESENCE frame from client after the 2nd ws_connect. + await pollUntil( + async () => { + const log = await session!.getLog(); + const wsConnects = log.filter((e: any) => e.type === 'ws_connect'); + if (wsConnects.length < 2) return false; + const secondConnectTime = wsConnects[1].timestamp; + const presenceAfterReconnect = log.filter( + (e: any) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + e.message?.action === 14 && + e.timestamp > secondConnectTime, + ); + return presenceAfterReconnect.length > 0; + }, + { interval: 200, timeout: 10000 }, + ); + + // Verify the re-enter frame details + const log = await session!.getLog(); + const wsConnects = log.filter((e: any) => e.type === 'ws_connect'); + const secondConnectTime = wsConnects[1].timestamp; + const reenterFrames = log.filter( + (e: any) => + e.type === 'ws_frame' && + e.direction === 'client_to_server' && + e.message?.action === 14 && + e.timestamp > secondConnectTime, + ); + + expect(reenterFrames.length).to.be.at.least(1); + const reenterFrame = reenterFrames[0]; + expect(reenterFrame.message.presence).to.exist; + expect(reenterFrame.message.presence.length).to.be.at.least(1); + + const reenterMsg = reenterFrame.message.presence[0]; + expect(reenterMsg.clientId).to.equal('client-a'); + expect(reenterMsg.data).to.equal('hello'); + expect(reenterMsg.action).to.equal(2); // ENTER + + // Channel is still attached, connection is connected + expect(channelA.state).to.equal('attached'); + expect(clientA.connection.state).to.equal('connected'); + + await closeAndWait(clientA); + }); +}); diff --git a/test/uts/realtime/integration/proxy/rest_faults.test.ts b/test/uts/realtime/integration/proxy/rest_faults.test.ts new file mode 100644 index 000000000..acf356da7 --- /dev/null +++ b/test/uts/realtime/integration/proxy/rest_faults.test.ts @@ -0,0 +1,250 @@ +/** + * UTS Proxy Integration: REST Fault Tests + * + * Spec points: RSC10, RSC15m, REC2c2, RTL6 + * Source: specification/uts/realtime/integration/proxy/rest_faults.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + getKeyParts, + trackClient, + closeAndWait, + connectAndWait, + generateJWT, + uniqueChannelName, + pollUntil, +} from '../sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../helpers/proxy'; + +describe('uts/realtime/integration/proxy/rest_faults', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RSC10 — Token renewal on HTTP 401 (40142) + * + * Proxy returns 401 with error code 40142 on the first HTTP request matching + * /channels/ (times: 1). The SDK should transparently renew the token via + * authCallback and retry the request. + */ + // UTS: realtime/proxy/RSC10/token-renewal-on-401-0 + it('RSC10 - token renewal on HTTP 401 (40142)', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'http_request', pathContains: '/channels/' }, + action: { + type: 'http_respond', + status: 401, + body: { error: { code: 40142, statusCode: 401, message: 'Token expired' } }, + }, + times: 1, + comment: 'RSC10: Return 401 on first channel request, then passthrough', + }, + ], + }); + + let authCallbackCount = 0; + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + authCallbackCount++; + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + const channelName = uniqueChannelName('test-RSC10-token-renewal'); + const channel = restClient.channels.get(channelName); + + // Publish a message — first request gets 401, SDK renews token, retries + await channel.publish('test-event', 'hello'); + + // authCallback was called at least twice (initial token + renewal after 401) + expect(authCallbackCount).to.be.at.least(2); + + // Proxy event log shows at least two HTTP requests to the channel endpoint + const log = await session.getLog(); + const httpRequests = log.filter((e) => e.type === 'http_request' && e.path && e.path.includes('/channels/')); + expect(httpRequests.length).to.be.at.least(2); + + // First response was the injected 401, second response was a success + const httpResponses = log.filter((e) => e.type === 'http_response'); + expect(httpResponses[0].status).to.equal(401); + expect(httpResponses[1].status).to.be.oneOf([200, 201]); + }); + + /** + * RSC15m / REC2c2 — HTTP 503 with fallback hosts disabled + * + * Proxy returns 503 with error code 50300 on the first HTTP request matching + * /channels/ (times: 1). Since endpoint='localhost' disables fallback hosts + * (REC2c2), the SDK should return the error immediately without retrying. + */ + // UTS: realtime/proxy/RSC15m/http-503-no-fallback-0 + it('RSC15m / REC2c2 - HTTP 503 error with fallback hosts disabled', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'http_request', pathContains: '/channels/' }, + action: { + type: 'http_respond', + status: 503, + body: { error: { code: 50300, statusCode: 503, message: 'Service temporarily unavailable' } }, + }, + times: 1, + comment: 'RSC15m: Return 503 on first channel request', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + const channelName = uniqueChannelName('test-RSC15m-503-error'); + const channel = restClient.channels.get(channelName); + + // Publish should fail with 503 error + let error: any; + try { + await channel.publish('test-event', 'hello'); + expect.fail('Expected publish to throw'); + } catch (err: any) { + error = err; + } + + // The error propagates to the caller with the correct error code + expect(error.code).to.equal(50300); + expect(error.statusCode).to.equal(503); + + // Proxy event log shows only one HTTP request to the channel endpoint + // (no fallback attempts since endpoint="localhost" disables fallback hosts) + const log = await session.getLog(); + const httpRequests = log.filter((e) => e.type === 'http_request' && e.path && e.path.includes('/channels/')); + expect(httpRequests.length).to.equal(1); + }); + + /** + * RTL6 — End-to-end publish and history through proxy + * + * No fault rules (pure passthrough). A Realtime client publishes through + * the proxy, then a REST client retrieves via history through the proxy. + */ + // UTS: realtime/proxy/RTL6/publish-history-through-proxy-0 + it('RTL6 - end-to-end publish and history through proxy', async function () { + session = await createProxySession({ + rules: [], + }); + + const { keyName, keySecret } = getKeyParts(getApiKey()); + + // Create Realtime client through proxy for publishing + const realtimeClient = new Ably.Realtime({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + autoConnect: false, + } as any); + trackClient(realtimeClient); + + // Create REST client through proxy for history retrieval + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + cb(null, generateJWT({ keyName, keySecret })); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + const channelName = uniqueChannelName('test-RTL6-publish-history'); + const realtimeChannel = realtimeClient.channels.get(channelName); + const restChannel = restClient.channels.get(channelName); + + // Connect Realtime client through proxy + await connectAndWait(realtimeClient, 15000); + + // Attach to the channel + await realtimeChannel.attach(); + + // Publish a message via Realtime + await realtimeChannel.publish('test-msg', 'hello world'); + + // Poll until the message appears in history (eventual consistency) + await pollUntil(async () => { + const history = await restChannel.history(); + return history.items.length > 0; + }, { interval: 500, timeout: 10000 }); + + // Retrieve channel history via REST + const history = await restChannel.history(); + + // History contains the published message + expect(history.items.length).to.be.at.least(1); + + // Find the published message in history + const publishedMsg = history.items.find((m: any) => m.name === 'test-msg'); + expect(publishedMsg).to.not.be.undefined; + expect(publishedMsg.data).to.equal('hello world'); + + // Proxy event log shows both WebSocket and HTTP traffic + const log = await session.getLog(); + + // At least one WebSocket connection was made (Realtime client) + const wsConnects = log.filter((e) => e.type === 'ws_connect'); + expect(wsConnects.length).to.be.at.least(1); + + // At least one HTTP request was made (REST history call + token requests) + const httpRequests = log.filter((e) => e.type === 'http_request'); + expect(httpRequests.length).to.be.at.least(1); + + // Clean up the Realtime client + await closeAndWait(realtimeClient); + }); +}); diff --git a/test/uts/realtime/integration/sandbox.ts b/test/uts/realtime/integration/sandbox.ts index b6de59c2f..95eea6303 100644 --- a/test/uts/realtime/integration/sandbox.ts +++ b/test/uts/realtime/integration/sandbox.ts @@ -35,6 +35,7 @@ async function provisionSandboxApp(): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(testAppSetup.post_apps), + signal: AbortSignal.timeout(30000), }); if (!response.ok) { @@ -57,10 +58,15 @@ async function provisionSandboxApp(): Promise { async function deleteSandboxApp(app: SandboxApp): Promise { const url = `https://${SANDBOX_REST_HOST}/apps/${app.appId}`; const credentials = Buffer.from(app.keys[0].keyStr).toString('base64'); - await fetch(url, { - method: 'DELETE', - headers: { Authorization: `Basic ${credentials}` }, - }); + try { + await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Basic ${credentials}` }, + signal: AbortSignal.timeout(30000), + }); + } catch { + // Best-effort cleanup — sandbox apps expire automatically + } } /** diff --git a/test/uts/realtime/unit/auth/auth_callback_errors.test.ts b/test/uts/realtime/unit/auth/auth_callback_errors.test.ts new file mode 100644 index 000000000..3341b16df --- /dev/null +++ b/test/uts/realtime/unit/auth/auth_callback_errors.test.ts @@ -0,0 +1,531 @@ +/** + * UTS: Auth Callback Error Handling Tests + * + * Spec points: RSA4c, RSA4c1, RSA4c2, RSA4c3, RSA4d, RSA4d1, RSA4e, RSA4f + * Source: specification/uts/realtime/unit/auth/auth_callback_errors_test.md + * + * Tests error handling when authentication via authCallback fails in various ways. + * 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) + * + * Protocol actions: CONNECTED=4, ERROR=9, AUTH=17 + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { + Ably, + trackClient, + installMockWebSocket, + installMockHttp, + enableFakeTimers, + restoreAll, + flushAsync, +} from '../../../helpers'; + +describe('uts/realtime/unit/auth/auth_callback_errors', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA4c1, RSA4c2 - authCallback error during CONNECTING transitions to DISCONNECTED + * + * 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. + */ + // UTS: realtime/unit/RSA4c2/callback-error-connecting-disconnected-0 + it('RSA4c1/RSA4c2 - authCallback error during CONNECTING transitions to DISCONNECTED', function (done) { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + if (authCallbackCount === 1) { + cb({ code: 50000, statusCode: 500, message: 'Auth server unavailable' }, null); + } else { + cb(null, `valid-token-${authCallbackCount}`); + } + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + client.connection.once('disconnected', () => { + // RSA4c2: Connection transitioned to DISCONNECTED (not FAILED -- it's retriable) + expect(client.connection.state).to.equal('disconnected'); + + // RSA4c1: errorReason has code 80019 wrapping the underlying cause + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + expect(client.connection.errorReason!.statusCode).to.equal(401); + + // RSA4c1: cause is set to the underlying error from authCallback + expect(client.connection.errorReason!.cause).to.not.be.null; + expect((client.connection.errorReason!.cause as any).code).to.equal(50000); + + // State change event carries the same error + const disconnectedChanges = stateChanges.filter((c: any) => c.current === 'disconnected'); + expect(disconnectedChanges.length).to.be.at.least(1); + expect(disconnectedChanges[0].reason).to.not.be.null; + expect(disconnectedChanges[0].reason.code).to.equal(80019); + + done(); + }); + + client.connect(); + }); + + /** + * RSA4c1, RSA4c2 - authCallback timeout during CONNECTING transitions to DISCONNECTED + * + * When authCallback times out (exceeds realtimeRequestTimeout), the connection + * transitions to DISCONNECTED with error code 80019. + */ + // UTS: realtime/unit/RSA4c2/callback-timeout-connecting-disconnected-1 + it('RSA4c1/RSA4c2 - authCallback timeout during CONNECTING transitions to DISCONNECTED', async function () { + const clock = enableFakeTimers(); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (_params: any, _cb: any) => { + // Never calls cb -- simulates a timeout + }, + realtimeRequestTimeout: 10000, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + client.connect(); + + // Flush event loop so that connect() microtasks run and timers get scheduled + await flushAsync(); + + // Advance time past realtimeRequestTimeout + await clock.tickAsync(11000); + + // Allow promise rejections and state transitions to propagate + for (let i = 0; i < 10; i++) { + await flushAsync(); + if (client.connection.state === 'disconnected') break; + } + + // RSA4c2: Connection transitioned to DISCONNECTED + expect(client.connection.state).to.equal('disconnected'); + + // RSA4c1: errorReason has code 80019 + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + expect(client.connection.errorReason!.statusCode).to.equal(401); + }); + + /** + * RSA4c3 - authCallback error while CONNECTED leaves connection CONNECTED + * + * When authCallback fails during an RTN22 server-initiated reauth while the + * connection is CONNECTED, the connection stays CONNECTED. 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 (see specification#466). + */ + // UTS: realtime/unit/RSA4c3/callback-error-connected-stays-0 + it('RSA4c3 - authCallback error while CONNECTED does not set errorReason', async function () { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 17) { + // AUTH -- don't respond, the auth attempt will fail before this + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + if (authCallbackCount === 1) { + cb(null, 'initial-token'); + } else { + cb({ code: 50000, statusCode: 500, message: 'Auth server unavailable' }, null); + } + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Record state changes from this point + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + // Server requests re-authentication (RTN22) + mock.active_connection!.send_to_client({ action: 17 }); // AUTH + + // Wait for the auth callback to be called a second time (the failure) + for (let i = 0; i < 10; i++) { + await flushAsync(); + if (authCallbackCount >= 2) break; + } + + // RSA4c3: Connection remains CONNECTED + expect(client.connection.state).to.equal('connected'); + + // No state changes at all — the auth failure is silently swallowed + expect(stateChanges).to.have.length(0); + + // errorReason is NOT set (see specification#466) + expect(client.connection.errorReason).to.be.null; + }); + + /** + * RSA4d - authCallback returns 403 error during CONNECTING transitions to FAILED + * + * A 403 from authCallback during initial connection is treated as fatal and causes + * the connection to transition directly to FAILED (not DISCONNECTED). + */ + // UTS: realtime/unit/RSA4d/callback-403-connecting-failed-0 + it('RSA4d - authCallback 403 during CONNECTING transitions to FAILED', function (done) { + let connectionAttempted = false; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttempted = true; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + cb({ code: 40300, statusCode: 403, message: 'Account disabled' }, null); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + client.connection.once('failed', () => { + // RSA4d: Connection went to FAILED (not DISCONNECTED) + expect(client.connection.state).to.equal('failed'); + + // No WebSocket connection was attempted (auth failed before transport) + expect(connectionAttempted).to.be.false; + + // RSA4d: ErrorInfo has code 80019 and statusCode 403 + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + expect(client.connection.errorReason!.statusCode).to.equal(403); + + // Cause is the original 403 error + expect(client.connection.errorReason!.cause).to.not.be.null; + expect((client.connection.errorReason!.cause as any).code).to.equal(40300); + expect((client.connection.errorReason!.cause as any).statusCode).to.equal(403); + + // State change event carries the error + const failedChanges = stateChanges.filter((c: any) => c.current === 'failed'); + expect(failedChanges).to.have.length(1); + expect(failedChanges[0].reason).to.not.be.null; + expect(failedChanges[0].reason.code).to.equal(80019); + expect(failedChanges[0].reason.statusCode).to.equal(403); + + // No DISCONNECTED state was reached (went directly to FAILED) + const disconnectedChanges = stateChanges.filter((c: any) => c.current === 'disconnected'); + expect(disconnectedChanges).to.have.length(0); + + done(); + }); + + client.connect(); + }); + + /** + * RSA4d - authCallback 403 during RTN22 reauth transitions CONNECTED to FAILED + * + * A 403 from authCallback during server-initiated reauth (RTN22) causes the + * connection to transition from CONNECTED to FAILED, overriding RSA4c3. + */ + // UTS: realtime/unit/RSA4d/callback-403-reauth-failed-1 + it('RSA4d - authCallback 403 during reauth transitions CONNECTED to FAILED', function (done) { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + if (authCallbackCount === 1) { + // First call succeeds (initial connection) + cb(null, 'initial-token'); + } else { + // Reauth fails with 403 + cb({ code: 40300, statusCode: 403, message: 'Account suspended' }, null); + } + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('failed', () => { + // RSA4d: FAILED with code 80019 and statusCode 403 + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + expect(client.connection.errorReason!.statusCode).to.equal(403); + expect(client.connection.errorReason!.cause).to.not.be.null; + expect((client.connection.errorReason!.cause as any).code).to.equal(40300); + + done(); + }); + + // Server requests re-authentication (RTN22) + mock.active_connection!.send_to_client({ action: 17 }); // AUTH + }); + + client.connect(); + }); + + /** + * RSA4f - authCallback returns invalid type treated as invalid format error + * + * When authCallback returns an object that is not a String, JsonObject, + * TokenRequest, or TokenDetails (e.g. an integer), it is treated as an + * invalid format error per RSA4f, and the connection transitions to + * DISCONNECTED with error code 80019 per RSA4c. + */ + // UTS: realtime/unit/RSA4f/callback-invalid-type-format-0 + it('RSA4f - authCallback returns invalid type transitions to DISCONNECTED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + // Return an invalid type -- an integer is not a valid token format + cb(null, 12345); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('disconnected', () => { + // RSA4c2: Connection transitioned to DISCONNECTED + expect(client.connection.state).to.equal('disconnected'); + + // RSA4c1: errorReason has code 80019 + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + expect(client.connection.errorReason!.statusCode).to.equal(401); + + done(); + }); + + // Also listen for FAILED in case ably-js treats this as fatal + client.connection.once('failed', () => { + // Some implementations may treat invalid format as fatal + expect(client.connection.errorReason).to.not.be.null; + done(); + }); + + client.connect(); + }); + + /** + * RSA4f - authCallback returns token string exceeding 128KiB treated as invalid format + * + * 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. + */ + // UTS: realtime/unit/RSA4f/callback-oversized-token-format-1 + it('RSA4f - authCallback returns oversized token transitions to DISCONNECTED', function (done) { + // Generate a token string larger than 128KiB (131072 bytes) + const oversizedToken = 'x'.repeat(131073); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + cb(null, oversizedToken); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('disconnected', () => { + // RSA4c2: Connection transitioned to DISCONNECTED + expect(client.connection.state).to.equal('disconnected'); + + // RSA4c1: errorReason has code 80019 + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + expect(client.connection.errorReason!.statusCode).to.equal(401); + + done(); + }); + + // Also listen for FAILED in case ably-js treats this differently + client.connection.once('failed', () => { + expect(client.connection.errorReason).to.not.be.null; + done(); + }); + + client.connect(); + }); + + /** + * RSA4e - REST authCallback error produces error with code 40170 + * + * 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. + */ + // UTS: realtime/unit/RSA4e/rest-callback-error-40170-0 + it('RSA4e - REST authCallback error produces error with code 40170', async function () { + const mockHttp = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, []); + }, + }); + installMockHttp(mockHttp); + + const client = new Ably.Rest({ + authCallback: (params: any, cb: any) => { + // Generic error -- not an explicit ErrorInfo from Ably + cb(new Error('Network failure connecting to auth server'), null); + }, + useBinaryProtocol: false, + }); + trackClient(client); + + // Attempt a REST request that requires authentication + const channel = client.channels.get('test-channel'); + + try { + await channel.status(); + expect.fail('Expected an error to be thrown'); + } catch (error: any) { + // RSA4e: Error has code 40170 and statusCode 401 + expect(error.code).to.equal(40170); + expect(error.statusCode).to.equal(401); + + // Error message should be descriptive + expect(error.message).to.not.be.null; + expect(error.message.length).to.be.greaterThan(0); + } + }); +}); diff --git a/test/uts/realtime/auth/connection_auth.test.ts b/test/uts/realtime/unit/auth/connection_auth.test.ts similarity index 90% rename from test/uts/realtime/auth/connection_auth.test.ts rename to test/uts/realtime/unit/auth/connection_auth.test.ts index bfff47d55..16d198cc7 100644 --- a/test/uts/realtime/auth/connection_auth.test.ts +++ b/test/uts/realtime/unit/auth/connection_auth.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/auth/connection_auth', function () { +describe('uts/realtime/unit/auth/connection_auth', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/auth/connection_auth', function () { * When authCallback is configured but no token is provided, the library must * obtain a token via the callback before opening the WebSocket connection. */ + // UTS: realtime/unit/RTN2e/token-before-websocket-0 it('RTN2e/RTN27b - token obtained before WebSocket connection', function (done) { let callbackInvoked = false; let callbackInvokedTime: number | null = null; @@ -77,6 +78,7 @@ describe('uts/realtime/auth/connection_auth', function () { * If authCallback fails during initial token acquisition, the library * should NOT attempt to open a WebSocket connection. */ + // UTS: realtime/unit/RTN2e/callback-error-prevents-connect-1 it('RTN2e/RTN27b - authCallback error prevents connection attempt', function (done) { let connectionAttempted = false; @@ -124,6 +126,7 @@ describe('uts/realtime/auth/connection_auth', function () { * When invoking authCallback, the library passes TokenParams that include * any configured clientId (per RSA12a). */ + // UTS: realtime/unit/RTN2e/callback-params-include-clientid-2 it('RTN2e - authCallback TokenParams include clientId', function (done) { let receivedParams: any = null; @@ -168,6 +171,7 @@ describe('uts/realtime/auth/connection_auth', function () { * If a valid (non-expired) token exists from a previous authCallback invocation, * it should be reused for subsequent connection attempts. */ + // UTS: realtime/unit/RTN2e/reuse-valid-token-3 it('RTN2e - multiple connections reuse valid token', function (done) { let callbackCount = 0; @@ -223,6 +227,7 @@ describe('uts/realtime/auth/connection_auth', function () { * RSA4c1: errorReason set with code 80019, statusCode 401, cause = underlying error * RSA4c2: connection transitions to DISCONNECTED */ + // UTS: realtime/unit/RSA4c2/callback-error-causes-disconnected-0 it('RSA4c2 - authCallback error during CONNECTING causes DISCONNECTED', function (done) { let authCallbackCount = 0; @@ -276,16 +281,14 @@ describe('uts/realtime/auth/connection_auth', function () { }); /** - * RSA4c1/RSA4c3 - authCallback error while CONNECTED + * RSA4c3 - authCallback error while CONNECTED * - * Per RSA4c3: connection should remain CONNECTED. - * Per RSA4c1: errorReason should be set with code 80019, statusCode 401, - * and cause set to the underlying error. + * Per RSA4c3: connection should remain CONNECTED. 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 (see specification#466). */ - it('RSA4c1/RSA4c3 - authCallback error while CONNECTED sets errorReason', async function () { - // DEVIATION: see deviations.md — ably-js does not set errorReason (RSA4c1) on auth failure while CONNECTED - if (!process.env.RUN_DEVIATIONS) this.skip(); - + // UTS: realtime/unit/RSA4c3/callback-error-stays-connected-0 + it('RSA4c3 - authCallback error while CONNECTED does not set errorReason', async function () { let authCallbackCount = 0; const mock = new MockWebSocket({ @@ -333,25 +336,20 @@ describe('uts/realtime/auth/connection_auth', function () { // Server requests re-authentication (RTN22) mock.active_connection!.send_to_client({ action: 17 }); // AUTH - // Wait for auth callback failure to propagate + // Wait for the auth callback to be called a second time (the failure) for (let i = 0; i < 10; i++) { await flushAsync(); - if (client.connection.errorReason != null || stateChanges.length > 0) break; + if (authCallbackCount >= 2) break; } // RSA4c3: connection should remain CONNECTED expect(client.connection.state).to.equal('connected'); - // No transitions away from connected - const nonConnected = stateChanges.filter((c: any) => c.current !== 'connected'); - expect(nonConnected).to.have.length(0); + // No state changes at all — the auth failure is silently swallowed + expect(stateChanges).to.have.length(0); - // RSA4c1: errorReason has code 80019 - expect(client.connection.errorReason).to.not.be.null; - expect(client.connection.errorReason!.code).to.equal(80019); - expect(client.connection.errorReason!.statusCode).to.equal(401); - expect(client.connection.errorReason!.cause).to.not.be.null; - expect((client.connection.errorReason!.cause as any).code).to.equal(50000); + // errorReason is NOT set (see specification#466) + expect(client.connection.errorReason).to.be.null; }); /** @@ -360,6 +358,7 @@ describe('uts/realtime/auth/connection_auth', function () { * Per RSA4d: if authCallback returns statusCode 403, the connection * transitions to FAILED with code 80019 and statusCode 403. */ + // UTS: realtime/unit/RSA4d/callback-403-causes-failed-0 it('RSA4d - authCallback 403 during CONNECTING causes FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -408,6 +407,7 @@ describe('uts/realtime/auth/connection_auth', function () { * Per RSA4d: 403 from authCallback during server-initiated reauth * causes FAILED, overriding RSA4c3's "stay CONNECTED" rule. */ + // UTS: realtime/unit/RSA4d/callback-403-reauth-causes-failed-1 it('RSA4d - authCallback 403 during reauth causes FAILED', function (done) { let authCallbackCount = 0; diff --git a/test/uts/realtime/auth/realtime_authorize.test.ts b/test/uts/realtime/unit/auth/realtime_authorize.test.ts similarity index 96% rename from test/uts/realtime/auth/realtime_authorize.test.ts rename to test/uts/realtime/unit/auth/realtime_authorize.test.ts index 8f8e5a56d..71ab00671 100644 --- a/test/uts/realtime/auth/realtime_authorize.test.ts +++ b/test/uts/realtime/unit/auth/realtime_authorize.test.ts @@ -13,10 +13,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/auth/realtime_authorize', function () { +describe('uts/realtime/unit/auth/realtime_authorize', function () { afterEach(function () { restoreAll(); }); @@ -27,6 +27,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * Calling authorize() while connected obtains a new token via the * authCallback and sends an AUTH protocol message containing the new token. */ + // UTS: realtime/unit/RTC8a/authorize-connected-sends-auth-0 it('RTC8a - authorize() on CONNECTED sends AUTH protocol message', async function () { let authCallbackCount = 0; const capturedAuthMessages: any[] = []; @@ -99,6 +100,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * CONNECTED ProtocolMessage. The Connection should emit an UPDATE event * (not a CONNECTED state change) and connection details are updated. */ + // UTS: realtime/unit/RTC8a1/successful-reauth-update-event-0 it('RTC8a1 - successful reauth emits UPDATE event', async function () { let authCallbackCount = 0; @@ -177,6 +179,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * After a successful reauth with reduced capabilities, the server sends * a channel-level ERROR that causes the affected channel to enter FAILED. */ + // UTS: realtime/unit/RTC8a1/capability-downgrade-channel-failed-1 it('RTC8a1 - capability downgrade causes channel FAILED', async function () { let authCallbackCount = 0; let authHandlerInstalled = false; @@ -268,6 +271,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * If the authentication token change fails, Ably sends an ERROR * ProtocolMessage triggering the connection to transition to FAILED. */ + // UTS: realtime/unit/RTC8a2/failed-reauth-connection-failed-0 it('RTC8a2 - failed reauth transitions connection to FAILED', async function () { let authCallbackCount = 0; @@ -337,6 +341,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * The promise returned by authorize() does not resolve until the server * responds to the AUTH message with CONNECTED or ERROR. */ + // UTS: realtime/unit/RTC8a3/authorize-completes-after-response-0 it('RTC8a3 - authorize() completes only after server response', async function () { let authCallbackCount = 0; @@ -404,6 +409,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * are halted, and after obtaining a new token the library initiates a new * connection attempt using the new token. */ + // UTS: realtime/unit/RTC8b/authorize-connecting-halts-attempt-0 it('RTC8b - authorize() while CONNECTING halts current attempt', async function () { let authCallbackCount = 0; const capturedWsUrls: string[] = []; @@ -462,6 +468,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * If the connection transitions to FAILED after authorize() is called * while CONNECTING, the authorize promise completes with an error. */ + // UTS: realtime/unit/RTC8b1/authorize-connecting-fails-on-failed-0 it('RTC8b1 - authorize() while CONNECTING fails on FAILED state', async function () { let authCallbackCount = 0; @@ -517,6 +524,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * If the connection is in a non-connected state, after obtaining a token * the library should move to CONNECTING and initiate a connection. */ + // UTS: realtime/unit/RTC8c/authorize-disconnected-initiates-connection-0 it('RTC8c - authorize() from INITIALIZED initiates connection', async function () { let authCallbackCount = 0; const capturedWsUrls: string[] = []; @@ -572,6 +580,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * authorize() can recover a FAILED connection by obtaining a new token * and reconnecting. */ + // UTS: realtime/unit/RTC8c/authorize-failed-initiates-connection-1 it('RTC8c - authorize() from FAILED initiates connection', async function () { let authCallbackCount = 0; const capturedWsUrls: string[] = []; @@ -641,6 +650,7 @@ describe('uts/realtime/auth/realtime_authorize', function () { * * authorize() from CLOSED state opens a new connection. */ + // UTS: realtime/unit/RTC8c/authorize-closed-initiates-connection-2 it('RTC8c - authorize() from CLOSED initiates connection', async function () { let authCallbackCount = 0; diff --git a/test/uts/realtime/unit/auth/token_expiry_non_renewable.test.ts b/test/uts/realtime/unit/auth/token_expiry_non_renewable.test.ts new file mode 100644 index 000000000..75819d3bd --- /dev/null +++ b/test/uts/realtime/unit/auth/token_expiry_non_renewable.test.ts @@ -0,0 +1,177 @@ +/** + * UTS: Token Expiry with Non-Renewable Token Tests + * + * Spec points: RSA4a, RSA4a1, RSA4a2 + * Source: specification/uts/realtime/unit/auth/token_expiry_non_renewable_test.md + * + * Tests 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). + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../../helpers'; + +describe('uts/realtime/unit/auth/token_expiry_non_renewable', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSA4a1 - Instantiation with non-renewable token logs info-level warning + * + * When a client is instantiated with only a token (no key, authCallback, + * or authUrl), an info-level log message with error code 40171 should be + * emitted, including a help URL per TI5. + */ + // UTS: realtime/unit/RSA4a1/non-renewable-token-logs-warning-0 + it('RSA4a1 - non-renewable token logs info-level warning with code 40171', function () { + const capturedLogMessages: Array<{ level: number; message: string }> = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + token: 'non-renewable-token', + autoConnect: false, + useBinaryProtocol: false, + logHandler: (message: string, level: number) => { + capturedLogMessages.push({ level, message }); + }, + logLevel: 4, // LOG_MICRO (ably-js uses numeric log levels: 0=NONE, 1=ERROR, 2=MAJOR, 3=MINOR, 4=MICRO) + } as any); + trackClient(client); + + // A log message with error code 40171 should have been emitted + const has40171Message = capturedLogMessages.some( + (m) => m.message.includes('40171') || (m.message.includes('no means') && m.message.includes('renew')), + ); + expect(has40171Message).to.be.true; + + // TI5: log message should include the help URL + const hasHelpUrl = capturedLogMessages.some((m) => m.message.includes('https://help.ably.io/error/40171')); + expect(hasHelpUrl).to.be.true; + }); + + /** + * RSA4a2 - Server token error with non-renewable token transitions to FAILED + * + * 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. + */ + // UTS: realtime/unit/RSA4a2/token-error-non-renewable-failed-0 + it('RSA4a2 - server token error with non-renewable token transitions to FAILED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + // Server responds with token error (40142 = token expired) + conn.respond_with_error({ + action: 9, // ERROR + error: { + code: 40142, + statusCode: 401, + message: 'Token expired', + }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + token: 'expired-token', + autoConnect: false, + useBinaryProtocol: false, + } as any); + trackClient(client); + + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + client.connection.once('failed', () => { + // Connection transitioned to FAILED (not DISCONNECTED -- no retry) + expect(client.connection.state).to.equal('failed'); + + // Error reason has code 40171 (non-renewable token error) + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(40171); + + // State change event also carries the error + const failedChanges = stateChanges.filter((c: any) => c.current === 'failed'); + expect(failedChanges).to.have.length(1); + expect(failedChanges[0].reason).to.not.be.null; + expect(failedChanges[0].reason.code).to.equal(40171); + + done(); + }); + + client.connect(); + }); + + /** + * RSA4a2 - Server token error with non-renewable token does not retry + * + * When a non-renewable token receives a token error, only one connection + * attempt is made (no retry). + */ + // UTS: realtime/unit/RSA4a2/token-error-non-renewable-no-retry-1 + it('RSA4a2 - server token error with non-renewable token does not retry', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + // Always respond with token error + conn.respond_with_error({ + action: 9, // ERROR + error: { + code: 40140, + statusCode: 401, + message: 'Token error', + }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + token: 'non-renewable-token', + autoConnect: false, + useBinaryProtocol: false, + } as any); + trackClient(client); + + client.connection.once('failed', () => { + // Only one connection attempt was made (no retry) + expect(connectionAttemptCount).to.equal(1); + + // Connection is in FAILED state + expect(client.connection.state).to.equal('failed'); + + // Error code is 40171 + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(40171); + + done(); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/channels/channel_additional_attached.test.ts b/test/uts/realtime/unit/channels/channel_additional_attached.test.ts similarity index 94% rename from test/uts/realtime/channels/channel_additional_attached.test.ts rename to test/uts/realtime/unit/channels/channel_additional_attached.test.ts index 63e2542b7..dba8cd51a 100644 --- a/test/uts/realtime/channels/channel_additional_attached.test.ts +++ b/test/uts/realtime/unit/channels/channel_additional_attached.test.ts @@ -12,10 +12,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_additional_attached', function () { +describe('uts/realtime/unit/channels/channel_additional_attached', function () { afterEach(function () { restoreAll(); }); @@ -23,6 +23,7 @@ describe('uts/realtime/channels/channel_additional_attached', function () { /** * RTL12 - Additional ATTACHED with resumed=false emits UPDATE with error */ + // UTS: realtime/unit/RTL12/update-emits-with-error-0 it('RTL12 - UPDATE emitted with error on non-resumed ATTACHED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -88,6 +89,7 @@ describe('uts/realtime/channels/channel_additional_attached', function () { /** * RTL12 - Additional ATTACHED with resumed=true does NOT emit UPDATE */ + // UTS: realtime/unit/RTL12/resumed-no-update-1 it('RTL12 - no UPDATE on resumed ATTACHED', async function () { const RESUMED = 4; // 1 << 2 @@ -144,6 +146,7 @@ describe('uts/realtime/channels/channel_additional_attached', function () { /** * RTL12 - Additional ATTACHED without error has null reason */ + // UTS: realtime/unit/RTL12/no-error-null-reason-2 it('RTL12 - UPDATE without error has null reason', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/channels/channel_annotations.test.ts b/test/uts/realtime/unit/channels/channel_annotations.test.ts similarity index 95% rename from test/uts/realtime/channels/channel_annotations.test.ts rename to test/uts/realtime/unit/channels/channel_annotations.test.ts index 650c917ca..64f3c7656 100644 --- a/test/uts/realtime/channels/channel_annotations.test.ts +++ b/test/uts/realtime/unit/channels/channel_annotations.test.ts @@ -10,13 +10,13 @@ */ import { expect } from 'chai'; -import { MockWebSocket, PendingWSConnection } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket, PendingWSConnection } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; // Flag values const ANNOTATION_SUBSCRIBE = 1 << 22; // 4194304 -describe('uts/realtime/channels/channel_annotations', function () { +describe('uts/realtime/unit/channels/channel_annotations', function () { afterEach(function () { restoreAll(); }); @@ -57,6 +57,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTL26 - channel.annotations returns RealtimeAnnotations */ + // UTS: realtime/unit/RTL26/annotations-attribute-type-0 it('RTL26 - channel.annotations is available', function () { const mock = new MockWebSocket(); installMockWebSocket(mock.constructorFn); @@ -77,6 +78,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN1a, RTAN1c - publish sends ANNOTATION protocol message */ + // UTS: realtime/unit/RTAN1a/publish-sends-annotation-0 it('RTAN1a - publish sends ANNOTATION action', async function () { const { mock, captured } = setupMock({ onMessage: (msg, conn) => { @@ -124,6 +126,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN1d - publish resolves on ACK */ + // UTS: realtime/unit/RTAN1d/publish-ack-nack-0.1 it('RTAN1d - publish resolves on ACK', async function () { const { mock } = setupMock({ onMessage: (msg, conn) => { @@ -161,6 +164,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN1d - publish rejects on NACK */ + // UTS: realtime/unit/RTAN1d/publish-ack-nack-0 it('RTAN1d - publish rejects on NACK', async function () { const { mock } = setupMock({ onMessage: (msg, conn) => { @@ -202,6 +206,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN1b - publish fails in FAILED channel state */ + // UTS: realtime/unit/RTAN1b/publish-channel-state-0 it('RTAN1b - publish fails when channel is failed', async function () { const { mock } = setupMock(); installMockWebSocket(mock.constructorFn); @@ -241,6 +246,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN2a - delete sends ANNOTATION with annotation.delete action */ + // UTS: realtime/unit/RTAN2a/delete-sends-annotation-0 it('RTAN2a - delete sends ANNOTATION with delete action', async function () { const { mock, captured } = setupMock({ onMessage: (msg, conn) => { @@ -288,6 +294,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN4a, RTAN4b - subscribe delivers annotations from server */ + // UTS: realtime/unit/RTAN4a/subscribe-delivers-annotations-0 it('RTAN4a - subscribe delivers annotations', async function () { const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); installMockWebSocket(mock.constructorFn); @@ -335,6 +342,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN4c - subscribe with type filter */ + // UTS: realtime/unit/RTAN4c/subscribe-type-filter-0 it('RTAN4c - subscribe with type filter', async function () { const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); installMockWebSocket(mock.constructorFn); @@ -379,6 +387,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN4d - subscribe implicitly attaches channel */ + // UTS: realtime/unit/RTAN4d/subscribe-implicit-attach-0 it('RTAN4d - subscribe triggers implicit attach', async function () { let attachCount = 0; const mock = new MockWebSocket({ @@ -425,6 +434,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN4e - warns when ANNOTATION_SUBSCRIBE not granted */ + // UTS: realtime/unit/RTAN4e/subscribe-warns-no-mode-0 it('RTAN4e - throws when ANNOTATION_SUBSCRIBE not in mode', async function () { // Attach without ANNOTATION_SUBSCRIBE flag const { mock } = setupMock({ attachFlags: 0 }); @@ -456,6 +466,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN4e1 - no error when channel not attached with attachOnSubscribe=false */ + // UTS: realtime/unit/RTAN4e1/no-warn-unattached-0 it('RTAN4e1 - no error when not attached with attachOnSubscribe false', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -488,6 +499,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN5a - unsubscribe removes listener */ + // UTS: realtime/unit/RTAN5a/unsubscribe-removes-listeners-0 it('RTAN5a - unsubscribe removes listener', async function () { const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); installMockWebSocket(mock.constructorFn); @@ -536,6 +548,7 @@ describe('uts/realtime/channels/channel_annotations', function () { /** * RTAN5a - unsubscribe with type removes only typed listener */ + // UTS: realtime/unit/RTAN5a/unsubscribe-type-filter-1 it('RTAN5a - unsubscribe with type filter', async function () { const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); installMockWebSocket(mock.constructorFn); @@ -599,6 +612,7 @@ describe('uts/realtime/channels/channel_annotations', function () { * * Publishing an annotation without a type field should throw an error. */ + // UTS: realtime/unit/RTAN1a/validates-type-required-1 it('RTAN1a - publish validates type is required (deviation: ably-js does not validate type client-side)', async function () { const { mock } = setupMock({ onMessage: (msg, conn) => { @@ -654,6 +668,7 @@ describe('uts/realtime/channels/channel_annotations', function () { * JSON data in an annotation should be encoded following message * encoding rules (serialized to string with encoding: "json"). */ + // UTS: realtime/unit/RTAN1a/encodes-data-json-2 it('RTAN1a - publish encodes JSON data', async function () { const { mock, captured } = setupMock({ onMessage: (msg, conn) => { diff --git a/test/uts/realtime/channels/channel_attach.test.ts b/test/uts/realtime/unit/channels/channel_attach.test.ts similarity index 96% rename from test/uts/realtime/channels/channel_attach.test.ts rename to test/uts/realtime/unit/channels/channel_attach.test.ts index e90cb6244..1ef96a832 100644 --- a/test/uts/realtime/channels/channel_attach.test.ts +++ b/test/uts/realtime/unit/channels/channel_attach.test.ts @@ -15,11 +15,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_attach', function () { +describe('uts/realtime/unit/channels/channel_attach', function () { afterEach(function () { restoreAll(); }); @@ -27,6 +27,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4a - Attach when already attached is no-op */ + // UTS: realtime/unit/RTL4a/already-attached-noop-0 it('RTL4a - attach when already attached is no-op', async function () { let attachMessageCount = 0; @@ -74,6 +75,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4h - Concurrent attach while attaching waits for completion */ + // UTS: realtime/unit/RTL4h/attach-while-attaching-0 it('RTL4h - concurrent attach while attaching', async function () { let attachMessageCount = 0; let pendingAttachChannel: string | null = null; @@ -139,6 +141,7 @@ describe('uts/realtime/channels/channel_attach', function () { * * Deviation: ably-js does NOT clear errorReason on successful re-attach. */ + // UTS: realtime/unit/RTL4g/attach-from-failed-0 it('RTL4g - attach from failed state', async function () { let attachCount = 0; @@ -208,6 +211,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4b - Attach fails when connection is closed */ + // UTS: realtime/unit/RTL4b/fails-connection-closed-0 it('RTL4b - attach fails when connection closed', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -252,6 +256,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4b - Attach fails when connection is failed */ + // UTS: realtime/unit/RTL4b/fails-connection-failed-1 it('RTL4b - attach fails when connection failed', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -295,6 +300,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4b - Attach fails when connection is suspended */ + // UTS: realtime/unit/RTL4b/fails-connection-suspended-2 it('RTL4b - attach fails when connection suspended', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -351,6 +357,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4i - Attach queued when connection is connecting */ + // UTS: realtime/unit/RTL4i/queued-while-connecting-0 it('RTL4i - attach queued when connecting', async function () { let pendingConnection: any = null; @@ -408,6 +415,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4c - Attach sends ATTACH message and transitions to attaching */ + // UTS: realtime/unit/RTL4c/sends-attach-message-1 it('RTL4c - ATTACH message sent, transitions to attaching', async function () { let capturedAttachMsg: any = null; @@ -466,6 +474,7 @@ describe('uts/realtime/channels/channel_attach', function () { * Note: Uses setOptions() to trigger reattach, since detach clears * channelSerial in ably-js. */ + // UTS: realtime/unit/RTL4c1/includes-channel-serial-0 it('RTL4c1 - ATTACH includes channelSerial on reattach', async function () { const capturedAttachMsgs: any[] = []; @@ -518,6 +527,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4f - Attach times out and transitions to suspended */ + // UTS: realtime/unit/RTL4f/timeout-to-suspended-0 it('RTL4f - attach timeout transitions to suspended', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -576,6 +586,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4k - ATTACH includes params from ChannelOptions */ + // UTS: realtime/unit/RTL4k/includes-channel-params-0 it('RTL4k - ATTACH includes params', async function () { let capturedAttachMsg: any = null; @@ -625,6 +636,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4l - ATTACH includes modes as flags */ + // UTS: realtime/unit/RTL4l/modes-encoded-as-flags-0 it('RTL4l - ATTACH includes modes as flags', async function () { const PUBLISH = 131072; // 1 << 17 const SUBSCRIBE = 262144; // 1 << 18 @@ -677,6 +689,7 @@ describe('uts/realtime/channels/channel_attach', function () { /** * RTL4m - Channel modes populated from ATTACHED response flags */ + // UTS: realtime/unit/RTL4m/modes-from-attached-0 it('RTL4m - modes populated from ATTACHED flags', async function () { const PUBLISH = 131072; // 1 << 17 const SUBSCRIBE = 262144; // 1 << 18 @@ -730,6 +743,7 @@ describe('uts/realtime/channels/channel_attach', function () { * so detach+reattach does NOT set ATTACH_RESUME. Instead, we test via * setOptions() reattach which preserves the flag. */ + // UTS: realtime/unit/RTL4j/attach-resume-flag-0 it('RTL4j - ATTACH_RESUME flag on reattach', async function () { const ATTACH_RESUME = 32; // 1 << 5 const capturedAttachMsgs: any[] = []; @@ -789,6 +803,7 @@ describe('uts/realtime/channels/channel_attach', function () { * Calling attach while a detach is pending should wait for detach to * complete and then perform the attach. */ + // UTS: realtime/unit/RTL4h/attach-while-detaching-1 it('RTL4h - attach while detaching waits then attaches', async function () { const messagesFromClient: any[] = []; let pendingDetachChannel: string | null = null; @@ -869,6 +884,7 @@ describe('uts/realtime/channels/channel_attach', function () { * Deviation: ably-js does NOT clear errorReason on successful re-attach. * This test documents the deviation. */ + // UTS: realtime/unit/RTL4c/clears-error-reason-0 it('RTL4c - errorReason after successful reattach from suspended', async function () { const clock = enableFakeTimers(); @@ -963,6 +979,7 @@ describe('uts/realtime/channels/channel_attach', function () { * When a channel attach is queued while connecting, the ATTACH message * is sent and the channel attaches once the connection becomes CONNECTED. */ + // UTS: realtime/unit/RTL4i/completes-on-connected-1 it('RTL4i - attach completes when connection becomes connected', async function () { let attachMessageReceived = false; let pendingConnection: any = null; diff --git a/test/uts/realtime/channels/channel_attributes.test.ts b/test/uts/realtime/unit/channels/channel_attributes.test.ts similarity index 95% rename from test/uts/realtime/channels/channel_attributes.test.ts rename to test/uts/realtime/unit/channels/channel_attributes.test.ts index 6e37f0696..31c68e9f9 100644 --- a/test/uts/realtime/channels/channel_attributes.test.ts +++ b/test/uts/realtime/unit/channels/channel_attributes.test.ts @@ -8,10 +8,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/channels/channel_attributes', function () { +describe('uts/realtime/unit/channels/channel_attributes', function () { afterEach(function () { restoreAll(); }); @@ -19,6 +19,7 @@ describe('uts/realtime/channels/channel_attributes', function () { /** * RTL23 - RealtimeChannel name attribute */ + // UTS: realtime/unit/RTL23/name-attribute-0 it('RTL23 - channel name attribute', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -38,6 +39,7 @@ describe('uts/realtime/channels/channel_attributes', function () { /** * RTL24 - errorReason set on channel error */ + // UTS: realtime/unit/RTL24/error-reason-channel-error-0 it('RTL24 - errorReason set on channel ERROR', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -93,6 +95,7 @@ describe('uts/realtime/channels/channel_attributes', function () { /** * RTL24 - errorReason set on attach failure */ + // UTS: realtime/unit/RTL24/error-reason-attach-failure-1 it('RTL24 - errorReason set on attach failure', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -147,6 +150,7 @@ describe('uts/realtime/channels/channel_attributes', function () { * Per RTL4g: "If the channel is in the FAILED state, the attach request * sets its errorReason to null, and proceeds with a channel attach." */ + // UTS: realtime/unit/RTL4c/error-cleared-on-attach-0 it('RTL4g - errorReason cleared on re-attach from FAILED', async function () { // DEVIATION: see deviations.md — ably-js does not clear errorReason on successful re-attach (RTL4c) if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -221,6 +225,7 @@ describe('uts/realtime/channels/channel_attributes', function () { * Per RTL4g: attach from FAILED clears errorReason. After re-attach and * detach, errorReason should remain null (detach does not set it). */ + // UTS: realtime/unit/RTL4c/error-cleared-preserved-detach-1 it('RTL4g - errorReason cleared on re-attach and detach', async function () { // DEVIATION: see deviations.md — ably-js does not clear errorReason on successful re-attach (RTL4c) if (!process.env.RUN_DEVIATIONS) this.skip(); diff --git a/test/uts/realtime/channels/channel_connection_state.test.ts b/test/uts/realtime/unit/channels/channel_connection_state.test.ts similarity index 96% rename from test/uts/realtime/channels/channel_connection_state.test.ts rename to test/uts/realtime/unit/channels/channel_connection_state.test.ts index 7032eca4a..68477c688 100644 --- a/test/uts/realtime/channels/channel_connection_state.test.ts +++ b/test/uts/realtime/unit/channels/channel_connection_state.test.ts @@ -13,11 +13,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_connection_state', function () { +describe('uts/realtime/unit/channels/channel_connection_state', function () { afterEach(function () { restoreAll(); }); @@ -25,6 +25,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3e - DISCONNECTED has no effect on ATTACHED channel */ + // UTS: realtime/unit/RTL3e/disconnected-attached-noop-0 it('RTL3e - DISCONNECTED does not affect attached channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -72,6 +73,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3a - FAILED connection transitions ATTACHED channel to FAILED */ + // UTS: realtime/unit/RTL3a/other-states-unaffected-2 it('RTL3a - FAILED connection → channel FAILED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -125,6 +127,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3a - INITIALIZED and DETACHED channels unaffected by FAILED connection */ + // UTS: realtime/unit/RTL3a/failed-attached-to-failed-0 it('RTL3a - non-attached channels unaffected by FAILED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -191,6 +194,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3b - CLOSED connection transitions ATTACHED channel to DETACHED */ + // UTS: realtime/unit/RTL3b/closed-attached-to-detached-0 it('RTL3b - CLOSED connection → channel DETACHED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -242,6 +246,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3c - SUSPENDED connection transitions ATTACHED channel to SUSPENDED */ + // UTS: realtime/unit/RTL3c/suspended-attached-to-suspended-0 it('RTL3c - SUSPENDED connection → channel SUSPENDED', async function () { let connectCount = 0; @@ -318,6 +323,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3d, RTL4c1 - CONNECTED recovery re-attaches channels with channelSerial */ + // UTS: realtime/unit/RTL3d/reattach-attached-with-serial-0 it('RTL3d - reconnect re-attaches channels with channelSerial', async function () { let connectCount = 0; const capturedAttachMsgs: any[] = []; @@ -377,6 +383,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3d - INITIALIZED and DETACHED channels NOT re-attached on reconnect */ + // UTS: realtime/unit/RTL3d/init-detached-not-reattached-2 it('RTL3d - initialized/detached channels not re-attached', async function () { let connectCount = 0; const attachedChannels: string[] = []; @@ -449,6 +456,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3d - Multiple channels re-attached on reconnect */ + // UTS: realtime/unit/RTL3d/multiple-channels-reattached-3 it('RTL3d - multiple channels re-attached on reconnect', async function () { const attachedChannels: string[] = []; @@ -514,6 +522,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3e - DISCONNECTED has no effect on ATTACHING channel */ + // UTS: realtime/unit/RTL3e/disconnected-attaching-noop-1 it('RTL3e - DISCONNECTED does not affect attaching channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -562,6 +571,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED */ + // UTS: realtime/unit/RTL3b/closed-attaching-to-detached-1 it('RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -619,6 +629,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3a - FAILED connection transitions ATTACHING channel to FAILED */ + // UTS: realtime/unit/RTL3a/failed-attaching-to-failed-1 it('RTL3a - FAILED connection transitions ATTACHING channel to FAILED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -673,6 +684,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED */ + // UTS: realtime/unit/RTL3c/suspended-attaching-to-suspended-1 it('RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED', async function () { const clock = enableFakeTimers(); @@ -741,6 +753,7 @@ describe('uts/realtime/channels/channel_connection_state', function () { /** * RTL3d - CONNECTED connection re-attaches SUSPENDED channels */ + // UTS: realtime/unit/RTL3d/reattach-suspended-channels-1 it('RTL3d - CONNECTED connection re-attaches SUSPENDED channels', async function () { const clock = enableFakeTimers(); let attachCount = 0; diff --git a/test/uts/realtime/channels/channel_delta_decoding.test.ts b/test/uts/realtime/unit/channels/channel_delta_decoding.test.ts similarity index 96% rename from test/uts/realtime/channels/channel_delta_decoding.test.ts rename to test/uts/realtime/unit/channels/channel_delta_decoding.test.ts index afe6b8cd1..6d060de3d 100644 --- a/test/uts/realtime/channels/channel_delta_decoding.test.ts +++ b/test/uts/realtime/unit/channels/channel_delta_decoding.test.ts @@ -15,8 +15,8 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; const mockVcdiffPlugin = { decode(delta: any, base: any): any { @@ -72,7 +72,7 @@ function createMockWithAutoAttach(channelName: string) { return mock; } -describe('uts/realtime/channels/channel_delta_decoding', function () { +describe('uts/realtime/unit/channels/channel_delta_decoding', function () { afterEach(function () { restoreAll(); }); @@ -83,6 +83,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { * Multiple messages in a ProtocolMessage where later messages are deltas * referencing earlier ones — works because processing is in array order. */ + // UTS: realtime/unit/RTL21/ascending-index-order-0 it('RTL21 - messages decoded in ascending index order', async function () { const channelName = 'test-RTL21'; const received: any[] = []; @@ -122,6 +123,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL19b - Non-delta message stores base payload */ + // UTS: realtime/unit/RTL19b/stores-base-payload-0 it('RTL19b - non-delta then delta succeeds', async function () { const channelName = 'test-RTL19b'; const received: any[] = []; @@ -161,6 +163,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL19c - Delta application result stored as new base payload (chained) */ + // UTS: realtime/unit/RTL19c/delta-result-becomes-base-0 it('RTL19c - chained deltas decode correctly', async function () { const channelName = 'test-RTL19c'; const received: any[] = []; @@ -213,6 +216,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL20 - Last message ID updated after successful decode */ + // UTS: realtime/unit/RTL20/last-id-updated-on-decode-1 it('RTL20 - last message ID updated correctly', async function () { const channelName = 'test-RTL20-id'; const received: any[] = []; @@ -258,6 +262,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL20 - Delta with mismatched base message ID triggers recovery */ + // UTS: realtime/unit/RTL20/mismatched-id-triggers-recovery-0 it('RTL20 - mismatched base ID triggers recovery', async function () { const channelName = 'test-RTL20-mismatch'; const attachMessages: any[] = []; @@ -321,6 +326,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * PC3 - No vcdiff plugin causes FAILED state */ + // UTS: realtime/unit/PC3/no-plugin-fails-1 it('PC3 - no vcdiff plugin causes channel FAILED', async function () { const channelName = 'test-PC3-no-plugin'; const stateChanges: any[] = []; @@ -368,6 +374,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL18 - Decode failure triggers recovery (RTL18a, RTL18b, RTL18c) */ + // UTS: realtime/unit/RTL18/decode-failure-recovery-0 it('RTL18 - decode failure triggers recovery', async function () { const channelName = 'test-RTL18-recovery'; const received: any[] = []; @@ -441,6 +448,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL18c - Recovery completes when server sends ATTACHED */ + // UTS: realtime/unit/RTL18c/recovery-completes-on-attached-0 it('RTL18c - recovery completes and new messages work', async function () { const channelName = 'test-RTL18c'; const received: any[] = []; @@ -518,6 +526,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * RTL18 - Only one recovery in progress at a time */ + // UTS: realtime/unit/RTL18/single-recovery-at-time-1 it('RTL18 - only one recovery at a time', async function () { const channelName = 'test-RTL18-single'; const attachMessages: any[] = []; @@ -592,6 +601,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { * (e.g. "base64"), the SDK decodes the base64 before storing the base * payload for future delta application. */ + // UTS: realtime/unit/RTL19a/base64-decoded-before-store-0 it('RTL19a - base64 decoded before storing base payload', async function () { const channelName = 'test-RTL19a'; const received: any[] = []; @@ -655,6 +665,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { * is the wire-form (JSON string), not the decoded object. This is critical * because the vcdiff delta is computed by the server against the wire-form. */ + // UTS: realtime/unit/RTL19b/json-wire-form-base-1 it('RTL19b - JSON-encoded non-delta stores wire-form base', async function () { const channelName = 'test-RTL19b-json'; const received: any[] = []; @@ -715,6 +726,7 @@ describe('uts/realtime/channels/channel_delta_decoding', function () { /** * PC3, PC3a - VCDiff plugin decodes delta messages */ + // UTS: realtime/unit/PC3/vcdiff-plugin-decodes-0 it('PC3 - vcdiff plugin called with correct arguments', async function () { const channelName = 'test-PC3'; const received: any[] = []; diff --git a/test/uts/realtime/channels/channel_detach.test.ts b/test/uts/realtime/unit/channels/channel_detach.test.ts similarity index 95% rename from test/uts/realtime/channels/channel_detach.test.ts rename to test/uts/realtime/unit/channels/channel_detach.test.ts index f71dcd71f..0fbaafaab 100644 --- a/test/uts/realtime/channels/channel_detach.test.ts +++ b/test/uts/realtime/unit/channels/channel_detach.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_detach', function () { +describe('uts/realtime/unit/channels/channel_detach', function () { afterEach(function () { restoreAll(); }); @@ -17,6 +17,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5a - Detach when initialized */ + // UTS: realtime/unit/RTL5a/detach-initialized-noop-0 it('RTL5a - detach from initialized state', async function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -36,6 +37,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5a - Detach when already detached is no-op */ + // UTS: realtime/unit/RTL5a/detach-already-detached-noop-1 it('RTL5a - detach when already detached is no-op', async function () { let detachMessageCount = 0; @@ -91,6 +93,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5i - Concurrent detach while detaching waits for completion */ + // UTS: realtime/unit/RTL5i/detach-while-detaching-0 it('RTL5i - concurrent detach while detaching', async function () { let detachMessageCount = 0; let pendingDetachChannel: string | null = null; @@ -161,6 +164,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5b - Detach from failed state results in error */ + // UTS: realtime/unit/RTL5b/detach-failed-errors-0 it('RTL5b - detach from failed state errors', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -219,6 +223,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5j - Detach from suspended transitions to detached immediately */ + // UTS: realtime/unit/RTL5j/detach-suspended-to-detached-0 it('RTL5j - detach from suspended is immediate', async function () { let detachMessageCount = 0; @@ -282,6 +287,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5d - Normal detach flow */ + // UTS: realtime/unit/RTL5d/normal-detach-flow-0 it('RTL5d - normal detach flow', async function () { let capturedDetachMsg: any = null; @@ -342,6 +348,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5f - Detach timeout returns to previous state */ + // UTS: realtime/unit/RTL5f/timeout-returns-previous-state-0 it('RTL5f - detach timeout returns to attached', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -408,6 +415,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5k - ATTACHED received while detaching sends new DETACH */ + // UTS: realtime/unit/RTL5k/attached-while-detaching-0 it('RTL5k - ATTACHED while detaching triggers new DETACH', async function () { let detachMessageCount = 0; @@ -469,6 +477,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5l - Detach when connection not connected transitions immediately */ + // UTS: realtime/unit/RTL5l/detach-not-connected-immediate-0 it('RTL5l - detach when disconnected is immediate', async function () { let detachMessageCount = 0; let pendingConnection: any = null; @@ -518,6 +527,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5l - Detach ATTACHED channel when connection disconnected */ + // UTS: realtime/unit/RTL5l/detach-attached-when-disconnected-1 it('RTL5l - detach attached channel when disconnected is immediate', async function () { let detachMessageCount = 0; @@ -576,6 +586,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5 - Detach emits state change events */ + // UTS: realtime/unit/RTL5/detach-state-change-events-0 it('RTL5 - detach emits state change events', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -636,6 +647,7 @@ describe('uts/realtime/channels/channel_detach', function () { * Calling detach while an attach is pending should wait for the attach * to complete and then perform the detach. */ + // UTS: realtime/unit/RTL5i/detach-while-attaching-1 it('RTL5i - detach while attaching waits then detaches', async function () { const messagesFromClient: any[] = []; @@ -706,6 +718,7 @@ describe('uts/realtime/channels/channel_detach', function () { /** * RTL5k - ATTACHED received while detached sends DETACH */ + // UTS: realtime/unit/RTL5k/attached-while-detached-1 it('RTL5k - ATTACHED while detached sends DETACH', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js doesn't send DETACH for unsolicited ATTACHED in detached state let detachMessageCount = 0; @@ -775,6 +788,7 @@ describe('uts/realtime/channels/channel_detach', function () { * This test specifically covers the case where a channel is ATTACHED * (not just ATTACHING) and connection drops to connecting. */ + // UTS: realtime/unit/RTL5l/detach-attached-when-disconnected-1.1 it('RTL5 - detach from attached when connection disconnected', async function () { let detachMessageCount = 0; diff --git a/test/uts/realtime/channels/channel_error.test.ts b/test/uts/realtime/unit/channels/channel_error.test.ts similarity index 95% rename from test/uts/realtime/channels/channel_error.test.ts rename to test/uts/realtime/unit/channels/channel_error.test.ts index 73686dfa3..24aa9d15a 100644 --- a/test/uts/realtime/channels/channel_error.test.ts +++ b/test/uts/realtime/unit/channels/channel_error.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, enableFakeTimers, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, enableFakeTimers, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_error', function () { +describe('uts/realtime/unit/channels/channel_error', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/channels/channel_error', function () { /** * RTL14 - Channel ERROR transitions ATTACHED channel to FAILED */ + // UTS: realtime/unit/RTL14/attached-to-failed-0 it('RTL14 - channel ERROR on attached channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -81,6 +82,7 @@ describe('uts/realtime/channels/channel_error', function () { /** * RTL14 - Channel ERROR transitions ATTACHING channel to FAILED */ + // UTS: realtime/unit/RTL14/attaching-to-failed-1 it('RTL14 - channel ERROR on attaching channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -135,6 +137,7 @@ describe('uts/realtime/channels/channel_error', function () { /** * RTL14 - Channel ERROR does not affect other channels */ + // UTS: realtime/unit/RTL14/other-channels-unaffected-3 it('RTL14 - channel ERROR isolated to target channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -194,6 +197,7 @@ describe('uts/realtime/channels/channel_error', function () { /** * RTL14 - Channel ERROR completes pending detach with error */ + // UTS: realtime/unit/RTL14/pending-detach-error-2 it('RTL14 - channel ERROR during detach', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -262,6 +266,7 @@ describe('uts/realtime/channels/channel_error', function () { * (channel in SUSPENDED state), the timer should be cancelled and the * channel should remain in FAILED state without retrying. */ + // UTS: realtime/unit/RTL14/cancels-pending-timers-4 it('RTL14 - channel ERROR cancels pending retry timer', async function () { let attachCount = 0; diff --git a/test/uts/realtime/channels/channel_get_message.test.ts b/test/uts/realtime/unit/channels/channel_get_message.test.ts similarity index 88% rename from test/uts/realtime/channels/channel_get_message.test.ts rename to test/uts/realtime/unit/channels/channel_get_message.test.ts index 3b9ce368b..90f3149dc 100644 --- a/test/uts/realtime/channels/channel_get_message.test.ts +++ b/test/uts/realtime/unit/channels/channel_get_message.test.ts @@ -8,11 +8,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/channels/channel_get_message', function () { +describe('uts/realtime/unit/channels/channel_get_message', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/channels/channel_get_message', function () { /** * RTL28 - getMessage delegates to REST endpoint */ + // UTS: realtime/unit/RTL28/identical-to-rest-0 it('RTL28 - getMessage calls REST /messages/{serial}', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/channels/channel_history.test.ts b/test/uts/realtime/unit/channels/channel_history.test.ts similarity index 93% rename from test/uts/realtime/channels/channel_history.test.ts rename to test/uts/realtime/unit/channels/channel_history.test.ts index aac7f83f4..5b2825cc2 100644 --- a/test/uts/realtime/channels/channel_history.test.ts +++ b/test/uts/realtime/unit/channels/channel_history.test.ts @@ -8,11 +8,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/channels/channel_history', function () { +describe('uts/realtime/unit/channels/channel_history', function () { afterEach(function () { restoreAll(); }); @@ -24,6 +24,7 @@ describe('uts/realtime/channels/channel_history', function () { * RestChannel#history. It supports start, end, direction, limit params * and returns a PaginatedResult containing Message objects. */ + // UTS: realtime/unit/RTL10a/supports-rest-params-0 it('RTL10a - history supports REST params and returns PaginatedResult', async function () { const captured: any[] = []; const mock = new MockWebSocket({ @@ -93,6 +94,7 @@ describe('uts/realtime/channels/channel_history', function () { /** * RTL10b - untilAttach adds fromSerial query parameter */ + // UTS: realtime/unit/RTL10b/adds-from-serial-0 it('RTL10b - untilAttach adds from_serial param', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -152,6 +154,7 @@ describe('uts/realtime/channels/channel_history', function () { /** * RTL10b - untilAttach errors when not attached */ + // UTS: realtime/unit/RTL10b/errors-when-not-attached-1 it('RTL10b - untilAttach throws when not attached', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/channels/channel_message_versions.test.ts b/test/uts/realtime/unit/channels/channel_message_versions.test.ts similarity index 88% rename from test/uts/realtime/channels/channel_message_versions.test.ts rename to test/uts/realtime/unit/channels/channel_message_versions.test.ts index fdf7eda90..c0d859aec 100644 --- a/test/uts/realtime/channels/channel_message_versions.test.ts +++ b/test/uts/realtime/unit/channels/channel_message_versions.test.ts @@ -8,11 +8,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/channels/channel_message_versions', function () { +describe('uts/realtime/unit/channels/channel_message_versions', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/channels/channel_message_versions', function () { /** * RTL31 - getMessageVersions delegates to REST endpoint */ + // UTS: realtime/unit/RTL31/identical-to-rest-0 it('RTL31 - getMessageVersions calls REST /messages/{serial}/versions', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/channels/channel_options.test.ts b/test/uts/realtime/unit/channels/channel_options.test.ts similarity index 93% rename from test/uts/realtime/channels/channel_options.test.ts rename to test/uts/realtime/unit/channels/channel_options.test.ts index ac5cf8c95..6a6378d98 100644 --- a/test/uts/realtime/channels/channel_options.test.ts +++ b/test/uts/realtime/unit/channels/channel_options.test.ts @@ -15,10 +15,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/channels/channel_options', function () { +describe('uts/realtime/unit/channels/channel_options', function () { afterEach(function () { restoreAll(); }); @@ -26,6 +26,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * TB2 - ChannelOptions defaults */ + // UTS: realtime/unit/TB2/channel-options-attributes-0 it('TB2 - default channel options', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -46,6 +47,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * TB2c - ChannelOptions with params */ + // UTS: realtime/unit/TB2c/options-with-params-0 it('TB2c - channel options with params', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -65,6 +67,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * TB2d - ChannelOptions with modes */ + // UTS: realtime/unit/TB2d/options-with-modes-0 it('TB2d - channel options with modes', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -86,6 +89,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * TB4 - attachOnSubscribe defaults to true */ + // UTS: realtime/unit/TB4/attach-on-subscribe-default-0 it('TB4 - attachOnSubscribe default', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -109,6 +113,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS3b - Options set on new channel via channels.get() */ + // UTS: realtime/unit/RTS3b/options-set-on-new-0 it('RTS3b - options set on new channel', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -130,6 +135,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS3c - Options updated on existing channel (when no reattach needed) */ + // UTS: realtime/unit/RTS3c/options-updated-existing-0 it('RTS3c - options updated on existing channel', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -155,6 +161,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS3c1 - Error if options would trigger reattachment on attached channel */ + // UTS: realtime/unit/RTS3c1/error-reattach-params-0 it('RTS3c1 - error when options change on attached channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -201,6 +208,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTL16 - setOptions updates channel options */ + // UTS: realtime/unit/RTL16/set-options-updates-0 it('RTL16 - setOptions updates channel options', async function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -232,6 +240,7 @@ describe('uts/realtime/channels/channel_options', function () { * stays in 'attached' during the reattach (deliberate: avoids RTL17 message * rejection). Test verifies attachCount instead of state transitions. */ + // UTS: realtime/unit/RTL16a/triggers-reattach-0 it('RTL16a - setOptions triggers reattachment when attached', async function () { let attachCount = 0; const mock = new MockWebSocket({ @@ -283,6 +292,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS5a - getDerived creates derived channel with filter */ + // UTS: realtime/unit/RTS5a/creates-derived-channel-0 it('RTS5a - getDerived creates derived channel', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -303,6 +313,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS5a1 - Derived channel filter is base64 encoded */ + // UTS: realtime/unit/RTS5a1/filter-base64-encoded-0 it('RTS5a1 - derived channel filter is base64 encoded', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -323,6 +334,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS5 - getDerived with options sets them on channel */ + // UTS: realtime/unit/RTS5/get-derived-with-options-0 it('RTS5 - getDerived with channel options', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -345,6 +357,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * DO2a - DeriveOptions filter attribute */ + // UTS: realtime/unit/DO2a/filter-attribute-0 it('DO2a - DeriveOptions filter attribute', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -369,6 +382,7 @@ describe('uts/realtime/channels/channel_options', function () { * static withCipherKey constructor. This test verifies that providing a * cipher key through the ably-js pattern sets up cipher params. */ + // UTS: realtime/unit/TB3/with-cipher-key-0 it('TB3 - cipher key via channel options', async function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -397,6 +411,7 @@ describe('uts/realtime/channels/channel_options', function () { * Changing modes on a channel that is in the attaching state should * throw error code 40000. */ + // UTS: realtime/unit/RTS3c1/error-reattach-modes-1 it('RTS3c1 - error when modes change on attaching channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -445,6 +460,7 @@ describe('uts/realtime/channels/channel_options', function () { /** * RTS5a2 - Derived channel with params included in name */ + // UTS: realtime/unit/RTS5a2/derived-with-params-0 it('RTS5a2 - derived channel with params in name', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', diff --git a/test/uts/realtime/channels/channel_properties.test.ts b/test/uts/realtime/unit/channels/channel_properties.test.ts similarity index 95% rename from test/uts/realtime/channels/channel_properties.test.ts rename to test/uts/realtime/unit/channels/channel_properties.test.ts index f565e78af..3d2769477 100644 --- a/test/uts/realtime/channels/channel_properties.test.ts +++ b/test/uts/realtime/unit/channels/channel_properties.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_properties', function () { +describe('uts/realtime/unit/channels/channel_properties', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15a - attachSerial updated from ATTACHED message */ + // UTS: realtime/unit/RTL15a/attach-serial-server-reattach-1 it('RTL15a - attachSerial from ATTACHED', async function () { let attachCount = 0; @@ -76,6 +77,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15a - attachSerial updated on server-initiated reattach */ + // UTS: realtime/unit/RTL15a/attach-serial-from-attached-0 it('RTL15a - attachSerial updated on additional ATTACHED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -126,6 +128,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15b - channelSerial updated from ATTACHED message */ + // UTS: realtime/unit/RTL15b/channel-serial-from-attached-0 it('RTL15b - channelSerial from ATTACHED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -167,6 +170,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15b - channelSerial updated from MESSAGE and PRESENCE actions */ + // UTS: realtime/unit/RTL15b/channel-serial-from-messages-1 it('RTL15b - channelSerial updated from MESSAGE', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -216,6 +220,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15b1 - channelSerial cleared on DETACHED state */ + // UTS: realtime/unit/RTL15b1/serial-cleared-detached-0 it('RTL15b1 - channelSerial cleared on detach', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -266,6 +271,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15b1 - channelSerial cleared on FAILED state */ + // UTS: realtime/unit/RTL15b1/serial-cleared-failed-2 it('RTL15b1 - channelSerial cleared on failed', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -316,6 +322,7 @@ describe('uts/realtime/channels/channel_properties', function () { /** * RTL15b1 - channelSerial cleared on SUSPENDED state */ + // UTS: realtime/unit/RTL15b1/serial-cleared-suspended-1 it('RTL15b1 - channelSerial cleared on suspended', async function () { let attachCount = 0; @@ -387,6 +394,7 @@ describe('uts/realtime/channels/channel_properties', function () { * Receiving a MESSAGE without a channelSerial should not clear or change * the existing channelSerial. */ + // UTS: realtime/unit/RTL15b/serial-not-updated-empty-2 it('RTL15b - channelSerial unchanged when not in message', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -444,6 +452,7 @@ describe('uts/realtime/channels/channel_properties', function () { * (RTL13a), so we verify the final channelSerial comes from the new * ATTACHED, not from the DETACHED message. */ + // UTS: realtime/unit/RTL15b/serial-not-updated-irrelevant-3 it('RTL15b - channelSerial not from irrelevant actions', async function () { let attachCount = 0; diff --git a/test/uts/realtime/channels/channel_publish.test.ts b/test/uts/realtime/unit/channels/channel_publish.test.ts similarity index 92% rename from test/uts/realtime/channels/channel_publish.test.ts rename to test/uts/realtime/unit/channels/channel_publish.test.ts index aaac63eda..9ebe4a665 100644 --- a/test/uts/realtime/channels/channel_publish.test.ts +++ b/test/uts/realtime/unit/channels/channel_publish.test.ts @@ -11,11 +11,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket, PendingWSConnection } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket, PendingWSConnection } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_publish', function () { +describe('uts/realtime/unit/channels/channel_publish', function () { afterEach(function () { restoreAll(); }); @@ -59,6 +59,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6i1 - Publish single message by name and data */ + // UTS: realtime/unit/RTL6i1/publish-name-and-data-0 it('RTL6i1 - publish single message by name and data', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -99,6 +100,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6i2 - Publish array of Message objects */ + // UTS: realtime/unit/RTL6i2/publish-message-array-0 it('RTL6i2 - publish array of messages in single ProtocolMessage', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -147,6 +149,7 @@ describe('uts/realtime/channels/channel_publish', function () { * Spec: "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 { "name": "click" }" */ + // UTS: realtime/unit/RTL6i3/null-fields-json-0 it('RTL6i3 - null name/data fields handled correctly', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js includes null fields in wire JSON; see #2199 const rawFrames: string[] = []; @@ -204,9 +207,20 @@ describe('uts/realtime/channels/channel_publish', function () { expect('name' in dataOnlyMsg).to.be.false; }); + /** + * RTL6i3 - Null fields omitted from msgpack wire encoding + * + * DEVIATION: ably-js does not support msgpack protocol + */ + // UTS: realtime/unit/RTL6i3/null-fields-msgpack-1 + it.skip('RTL6i3 - null fields omitted from msgpack wire encoding (msgpack not supported)', function () { + // DEVIATION: ably-js does not support msgpack protocol + }); + /** * RTL6i1 - Publish Message object */ + // UTS: realtime/unit/RTL6i1/publish-message-object-1 it('RTL6i1 - publish Message object', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -246,6 +260,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c1 - Publish immediately when CONNECTED and channel ATTACHED */ + // UTS: realtime/unit/RTL6c1/publish-when-attached-0 it('RTL6c1 - publish immediately when connected and attached', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -287,6 +302,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c1 - Publish immediately when CONNECTED and channel INITIALIZED */ + // UTS: realtime/unit/RTL6c1/publish-when-initialized-2 it('RTL6c1 - publish immediately when connected and channel initialized', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -327,6 +343,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c5 - Publish does not trigger implicit attach */ + // UTS: realtime/unit/RTL6c5/no-implicit-attach-0 it('RTL6c5 - publish does not trigger implicit attach', async function () { let attachCount = 0; const { mock, captured } = setupMock({ @@ -368,6 +385,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c2 - Publish queued when connection is CONNECTING */ + // UTS: realtime/unit/RTL6c2/queued-when-connecting-0 it('RTL6c2 - publish queued when connecting', async function () { const captured: any[] = []; const mock = new MockWebSocket({ @@ -423,6 +441,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c2 - Publish queued when connection is INITIALIZED */ + // UTS: realtime/unit/RTL6c2/queued-when-initialized-2 it('RTL6c2 - publish queued when initialized', async function () { const captured: any[] = []; const mock = new MockWebSocket({ @@ -475,6 +494,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c2 - Publish queued when connection is DISCONNECTED */ + // UTS: realtime/unit/RTL6c2/queued-when-disconnected-1 it('RTL6c2 - publish queued when disconnected', async function () { const captured: any[] = []; const mock = new MockWebSocket({ @@ -537,6 +557,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c2 - Multiple queued messages sent in order */ + // UTS: realtime/unit/RTL6c2/queued-messages-order-4 it('RTL6c2 - multiple queued messages sent in order', async function () { const captured: any[] = []; const mock = new MockWebSocket({ @@ -588,6 +609,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c4 - Publish fails when connection is CLOSED */ + // UTS: realtime/unit/RTL6c4/fails-conn-closed-1 it('RTL6c4 - publish fails when connection closed', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -630,6 +652,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c4 - Publish fails when connection is FAILED */ + // UTS: realtime/unit/RTL6c4/fails-channel-failed-4 it('RTL6c4 - publish fails when connection failed', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -671,6 +694,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c4 - Publish fails when channel is FAILED */ + // UTS: realtime/unit/RTL6c4/fails-conn-failed-2 it('RTL6c4 - publish fails when channel failed', async function () { const captured: any[] = []; const mock = new MockWebSocket({ @@ -728,6 +752,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c2 - Publish fails when queueMessages is false and not connected */ + // UTS: realtime/unit/RTL6c2/fails-no-queue-messages-3 it('RTL6c2 - publish fails when queueMessages false and not connected', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -764,6 +789,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6j - Publish returns PublishResult with serials from ACK */ + // UTS: realtime/unit/RTL6j/publish-result-serials-0 it('RTL6j - PublishResult with serial from ACK', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -803,6 +829,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6j - Batch publish returns PublishResult with multiple serials */ + // UTS: realtime/unit/RTL6j/batch-publish-serials-1 it('RTL6j - batch PublishResult with multiple serials including null', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -844,6 +871,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6j - Sequential publishes get incrementing msgSerial */ + // UTS: realtime/unit/RTL6j/incrementing-msg-serial-2 it('RTL6j - sequential publishes get incrementing msgSerial', async function () { const serials: number[] = []; const { mock } = setupMock({ @@ -888,6 +916,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6j - NACK results in error */ + // UTS: realtime/unit/RTL6j/nack-results-error-3 it('RTL6j - NACK results in publish error', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -929,6 +958,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN7e - Pending publishes fail when connection enters CLOSED */ + // UTS: realtime/unit/RTN7e/pending-fail-closed-1 it('RTN7e - pending publishes fail on connection closed', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -972,6 +1002,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN7e - Pending publishes fail when connection enters FAILED */ + // UTS: realtime/unit/RTN7e/pending-fail-failed-2 it('RTN7e - pending publishes fail on connection failed', async function () { const { mock } = setupMock({ onMessage: () => { @@ -1014,6 +1045,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN7e - Pending publishes fail when connection enters SUSPENDED */ + // UTS: realtime/unit/RTN7e/pending-fail-suspended-0 it('RTN7e - pending publishes fail on connection suspended', async function () { let firstConnect = true; const mock = new MockWebSocket({ @@ -1096,6 +1128,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN7e - Multiple pending publishes all fail on state change */ + // UTS: realtime/unit/RTN7e/multiple-pending-fail-3 it('RTN7e - multiple pending publishes all fail on close', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -1137,6 +1170,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN7d - New publish fails on DISCONNECTED when queueMessages is false */ + // UTS: realtime/unit/RTN7d/fail-disconnected-no-queue-0 it('RTN7d - new publish fails when disconnected with queueMessages false', async function () { const { mock } = setupMock(); installMockWebSocket(mock.constructorFn); @@ -1173,6 +1207,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN7d - Pending publishes survive DISCONNECTED when queueMessages is true */ + // UTS: realtime/unit/RTN7d/survive-disconnected-queue-1 it('RTN7d - pending survive disconnected with queueMessages true', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -1217,6 +1252,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN19a - Pending messages resent on new transport after disconnect */ + // UTS: realtime/unit/RTN19a/resent-on-new-transport-0 it('RTN19a - pending message resent on new transport', async function () { let connectCount = 0; const messagesPerConn: any[][] = [[], []]; @@ -1291,6 +1327,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN19a2 - Resent messages keep same msgSerial on successful resume */ + // UTS: realtime/unit/RTN19a2/same-serial-on-resume-0 it('RTN19a2 - resent messages keep msgSerial on successful resume', async function () { let connectCount = 0; const conn1Msgs: any[] = []; @@ -1376,6 +1413,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN19a2 - Resent messages get new msgSerial on failed resume */ + // UTS: realtime/unit/RTN19a2/new-serial-failed-resume-1 it('RTN19a2 - resent messages get new msgSerial on failed resume', async function () { let connectCount = 0; const conn1Msgs: any[] = []; @@ -1457,6 +1495,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN19b - Pending ATTACH resent on new transport after disconnect */ + // UTS: realtime/unit/RTN19b/attach-resent-on-reconnect-0 it('RTN19b - pending ATTACH resent after disconnect', async function () { let connectCount = 0; const attachMsgsPerConn: any[][] = [[], []]; @@ -1522,6 +1561,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTN19b - Pending DETACH resent on new transport after disconnect */ + // UTS: realtime/unit/RTN19b/detach-resent-on-reconnect-1 it('RTN19b - pending DETACH resent after disconnect', async function () { let connectCount = 0; const detachMsgsPerConn: any[][] = [[], []]; @@ -1589,6 +1629,7 @@ describe('uts/realtime/channels/channel_publish', function () { * Messages are sent immediately when the connection is CONNECTED and the * channel is in ATTACHING state (which is neither SUSPENDED nor FAILED). */ + // UTS: realtime/unit/RTL6c1/publish-when-attaching-1 it('RTL6c1 - publish immediately when connected and channel attaching', async function () { const capturedMessages: any[] = []; @@ -1640,6 +1681,7 @@ describe('uts/realtime/channels/channel_publish', function () { /** * RTL6c4 - Publish fails when channel is SUSPENDED */ + // UTS: realtime/unit/RTL6c4/fails-conn-suspended-0 it('RTL6c4 - publish fails when channel suspended', async function () { const clock = enableFakeTimers(); const capturedMessages: any[] = []; @@ -1706,9 +1748,66 @@ describe('uts/realtime/channels/channel_publish', function () { client.close(); }); + /** + * RTN7e - Error passed to publish callback represents 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). + */ + // UTS: realtime/unit/RTN7e/error-represents-reason-4 + it('RTN7e - error passed to publish callback represents the reason for the state change', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + // Don't ACK — instead send a fatal error to force FAILED state + mock.active_connection!.send_to_client_and_close({ + action: 9, // ERROR (connection-level) + error: { message: 'Connection closed due to admin action', code: 80019, statusCode: 400 }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN7e-error-reason', { attachOnSubscribe: false }); + await channel.attach(); + + // Publish — server responds with fatal ERROR instead of ACK + const publishPromise = channel.publish('pending', 'data'); + + try { + await publishPromise; + expect.fail('Should have thrown'); + } catch (err: any) { + // The error should represent the reason for the state change + expect(err).to.exist; + expect(err.code).to.equal(80019); + expect(err.statusCode).to.equal(400); + expect(err.message).to.equal('Connection closed due to admin action'); + } + + // Verify the connection entered FAILED with the matching errorReason + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80019); + client.close(); + }); + /** * RTL6c4 - Publish fails when connection is SUSPENDED */ + // UTS: realtime/unit/RTL6c4/fails-channel-suspended-3 it('RTL6c4 - publish fails when connection suspended', async function () { const clock = enableFakeTimers(); diff --git a/test/uts/realtime/channels/channel_server_initiated_detach.test.ts b/test/uts/realtime/unit/channels/channel_server_initiated_detach.test.ts similarity index 96% rename from test/uts/realtime/channels/channel_server_initiated_detach.test.ts rename to test/uts/realtime/unit/channels/channel_server_initiated_detach.test.ts index 442870a57..4355898ed 100644 --- a/test/uts/realtime/channels/channel_server_initiated_detach.test.ts +++ b/test/uts/realtime/unit/channels/channel_server_initiated_detach.test.ts @@ -13,10 +13,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_server_initiated_detach', function () { +describe('uts/realtime/unit/channels/channel_server_initiated_detach', function () { afterEach(function () { restoreAll(); }); @@ -24,6 +24,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { /** * RTL13a - Server DETACHED on ATTACHED channel triggers immediate reattach */ + // UTS: realtime/unit/RTL13a/detaching-not-server-initiated-2 it('RTL13a - server DETACHED on attached triggers reattach', async function () { let attachCount = 0; @@ -93,6 +94,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { /** * RTL13b - Server DETACHED while ATTACHING → SUSPENDED → automatic retry */ + // UTS: realtime/unit/RTL13b/attaching-detached-to-suspended-1 it('RTL13b - server DETACHED while attaching → suspended → retry', async function () { let attachCount = 0; @@ -184,6 +186,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { /** * RTL13b - Failed reattach → SUSPENDED → retry cycle */ + // UTS: realtime/unit/RTL13b/failed-reattach-suspended-retry-0 it('RTL13b - failed reattach cycles through suspended', async function () { let attachCount = 0; @@ -278,6 +281,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { /** * RTL13b - Repeated failures cycle indefinitely */ + // UTS: realtime/unit/RTL13b/repeated-failure-cycle-2 it('RTL13b - repeated failures cycle suspended → attaching', async function () { let attachCount = 0; @@ -378,6 +382,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { /** * RTL13c - Retry cancelled when connection is no longer CONNECTED */ + // UTS: realtime/unit/RTL13c/retry-cancelled-disconnected-0 it('RTL13c - retry cancelled when connection drops', async function () { let attachCount = 0; let connectCount = 0; @@ -467,6 +472,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { /** * RTL13 - DETACHED while DETACHING is normal detach flow (not reattach) */ + // UTS: realtime/unit/RTL13a/attached-reattach-triggered-0 it('RTL13 - DETACHED while detaching is normal detach', async function () { let attachCount = 0; @@ -525,6 +531,7 @@ describe('uts/realtime/channels/channel_server_initiated_detach', function () { * and receives a server-initiated DETACHED, it should immediately attempt * to reattach. */ + // UTS: realtime/unit/RTL13a/suspended-reattach-triggered-1 it('RTL13a - server DETACHED on suspended triggers reattach', async function () { let attachCount = 0; diff --git a/test/uts/realtime/channels/channel_state_events.test.ts b/test/uts/realtime/unit/channels/channel_state_events.test.ts similarity index 95% rename from test/uts/realtime/channels/channel_state_events.test.ts rename to test/uts/realtime/unit/channels/channel_state_events.test.ts index b9d581a68..5cb9c4b22 100644 --- a/test/uts/realtime/channels/channel_state_events.test.ts +++ b/test/uts/realtime/unit/channels/channel_state_events.test.ts @@ -15,10 +15,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_state_events', function () { +describe('uts/realtime/unit/channels/channel_state_events', function () { afterEach(function () { restoreAll(); }); @@ -26,6 +26,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2b - Channel state attribute */ + // UTS: realtime/unit/RTL2b/channel-state-attribute-0 it('RTL2b - channel has state attribute', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -42,6 +43,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2b - Channel initial state is initialized */ + // UTS: realtime/unit/RTL2b/initial-state-initialized-1 it('RTL2b - initial state is initialized', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -58,6 +60,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2a - State change events emitted for every state change */ + // UTS: realtime/unit/RTL2a/state-change-events-emitted-0 it('RTL2a - state change events emitted', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -108,6 +111,7 @@ describe('uts/realtime/channels/channel_state_events', function () { * Deviation: TH5 — ably-js ChannelStateChange has no `event` property. * The event name is available via `this.event` in the listener context. */ + // UTS: realtime/unit/RTL2d/state-change-object-structure-0 it('RTL2d, TH1, TH2 - ChannelStateChange structure', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -155,6 +159,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2d, TH3 - ChannelStateChange includes error reason when applicable */ + // UTS: realtime/unit/RTL2d/state-change-error-reason-1 it('RTL2d, TH3 - ChannelStateChange includes error reason', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -211,6 +216,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2 - Filtered event subscription */ + // UTS: realtime/unit/RTL2/filtered-event-subscription-0 it('RTL2 - filtered event subscription', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -259,6 +265,7 @@ describe('uts/realtime/channels/channel_state_events', function () { * When an ATTACHED message is received while already attached and * the RESUMED flag is NOT set, an 'update' event is emitted. */ + // UTS: realtime/unit/RTL2g/update-event-condition-change-0 it('RTL2g - UPDATE event emitted', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -320,6 +327,7 @@ describe('uts/realtime/channels/channel_state_events', function () { * When an UPDATE occurs, only the 'update' event is emitted, not * a duplicate 'attached' event. */ + // UTS: realtime/unit/RTL2g/no-duplicate-state-events-1 it('RTL2g - no duplicate attached events on UPDATE', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -380,6 +388,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2i, TH6 - hasBacklog flag in ChannelStateChange */ + // UTS: realtime/unit/RTL2i/has-backlog-flag-true-0 it('RTL2i, TH6 - hasBacklog true when flag present', async function () { const HAS_BACKLOG = 2; // 1 << 1 @@ -426,6 +435,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2i - hasBacklog false when flag not present */ + // UTS: realtime/unit/RTL2i/has-backlog-flag-false-1 it('RTL2i - hasBacklog false when flag not present', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -470,6 +480,7 @@ describe('uts/realtime/channels/channel_state_events', function () { /** * RTL2d - resumed flag in ChannelStateChange */ + // UTS: realtime/unit/RTL2d/resumed-flag-propagated-2 it('RTL2d - resumed flag true when RESUMED set', async function () { const RESUMED = 4; // 1 << 2 @@ -519,6 +530,7 @@ describe('uts/realtime/channels/channel_state_events', function () { * When a channel enters the FAILED state, errorReason should be * populated with the error from the server. */ + // UTS: realtime/unit/RTL24/error-reason-populated-0 it('channel errorReason populated when failed', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -574,6 +586,7 @@ describe('uts/realtime/channels/channel_state_events', function () { * Deviation: ably-js does NOT clear errorReason on successful re-attach. * This test documents the deviation. */ + // UTS: realtime/unit/RTL4c/error-reason-cleared-attach-0 it('RTL4c - errorReason after successful re-attach (deviation)', async function () { let attachCount = 0; diff --git a/test/uts/realtime/channels/channel_subscribe.test.ts b/test/uts/realtime/unit/channels/channel_subscribe.test.ts similarity index 65% rename from test/uts/realtime/channels/channel_subscribe.test.ts rename to test/uts/realtime/unit/channels/channel_subscribe.test.ts index 96d851c2e..7d1d43b4a 100644 --- a/test/uts/realtime/channels/channel_subscribe.test.ts +++ b/test/uts/realtime/unit/channels/channel_subscribe.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_subscribe', function () { +describe('uts/realtime/unit/channels/channel_subscribe', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7a - Subscribe with no name receives all messages */ + // UTS: realtime/unit/RTL7a/subscribe-all-messages-0 it('RTL7a - subscribe receives all messages', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -78,6 +79,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7b - Subscribe with name only receives matching messages */ + // UTS: realtime/unit/RTL7b/name-filtered-subscribe-0 it('RTL7b - name-filtered subscribe', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -134,6 +136,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7g - Subscribe triggers implicit attach */ + // UTS: realtime/unit/RTL7g/implicit-attach-initialized-0 it('RTL7g - subscribe triggers implicit attach', async function () { let attachCount = 0; @@ -186,6 +189,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7h - Subscribe does not attach when attachOnSubscribe is false */ + // UTS: realtime/unit/RTL7h/no-attach-on-subscribe-0 it('RTL7h - subscribe without attach when attachOnSubscribe false', async function () { let attachCount = 0; @@ -227,6 +231,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7g - Subscribe does not re-attach when already attached */ + // UTS: realtime/unit/RTL7g/no-attach-when-attached-3 it('RTL7g - subscribe does not re-attach when already attached', async function () { let attachCount = 0; @@ -280,6 +285,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { * vs server-side mechanism. ably-js uses server-side suppression via * echo=false URL parameter. Test verifies the parameter is set. */ + // UTS: realtime/unit/RTL7f/no-echo-messages-0 it('RTL7f - echoMessages false sets echo param in URL', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -309,6 +315,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL8a - Unsubscribe specific listener */ + // UTS: realtime/unit/RTL8a/unsubscribe-specific-listener-0 it('RTL8a - unsubscribe specific listener', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -376,6 +383,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL8b - Unsubscribe listener from specific name */ + // UTS: realtime/unit/RTL8b/unsubscribe-named-listener-0 it('RTL8b - unsubscribe from specific name', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -449,6 +457,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL8c - Unsubscribe with no args removes all listeners */ + // UTS: realtime/unit/RTL8c/unsubscribe-all-listeners-0 it('RTL8c - unsubscribe all', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -516,6 +525,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { * Per spec: "No messages should be passed to subscribers if the channel * is in any state other than ATTACHED." */ + // UTS: realtime/unit/RTL17/no-delivery-when-not-attached-0 it('RTL17 - messages not delivered when channel is not ATTACHED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -567,6 +577,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7a - Subscribe receives multiple messages from a single ProtocolMessage */ + // UTS: realtime/unit/RTL7a/multiple-messages-per-protocol-1 it('RTL7a - subscribe receives multiple messages from single ProtocolMessage', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -625,6 +636,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7b - Multiple name-specific subscriptions are independent */ + // UTS: realtime/unit/RTL7b/multiple-name-subscriptions-1 it('RTL7b - multiple name-specific subscriptions are independent', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -688,6 +700,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7g - Subscribe triggers implicit attach from DETACHED state */ + // UTS: realtime/unit/RTL7g/implicit-attach-detached-1 it('RTL7g - subscribe triggers implicit attach from DETACHED state', async function () { let attachCount = 0; @@ -749,6 +762,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7g - Listener registered even if implicit attach fails */ + // UTS: realtime/unit/RTL7g/listener-registered-attach-fails-2 it('RTL7g - listener registered even if implicit attach fails', async function () { let attachAttempts = 0; @@ -824,6 +838,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { /** * RTL7g - Subscribe does not attach when already attaching */ + // UTS: realtime/unit/RTL7g/no-attach-when-attaching-4 it('RTL7g - subscribe does not attach when already attaching', async function () { let attachCount = 0; @@ -875,6 +890,7 @@ describe('uts/realtime/channels/channel_subscribe', function () { * URL parameter. It does NOT filter messages client-side by connectionId. * This test verifies the URL parameter is set correctly. */ + // UTS: realtime/unit/RTL7f/no-echo-messages-0.1 it('RTL7f - echoMessages false sets echo param in URL', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -901,9 +917,447 @@ describe('uts/realtime/channels/channel_subscribe', function () { client.close(); }); + /** + * RTL22a - Subscribe with MessageFilter matching name + * + * Tests that subscribing with a MessageFilter specifying `name` delivers + * only messages whose name matches the filter. + */ + // UTS: realtime/unit/RTL22a/filter-matching-name-0 + it('RTL22a - subscribe with name filter', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL22a-name', { attachOnSubscribe: false }); + await channel.attach(); + + const filtered: any[] = []; + await channel.subscribe({ name: 'target-event' }, (msg: any) => filtered.push(msg)); + + // Message with matching name + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-name', + messages: [{ name: 'target-event', data: 'match-1' }], + }); + + // Message with different name + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-name', + messages: [{ name: 'other-event', data: 'no-match' }], + }); + + // Another matching message + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-name', + messages: [{ name: 'target-event', data: 'match-2' }], + }); + + // Message with no name + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-name', + messages: [{ data: 'no-name' }], + }); + + await flushAsync(); + + expect(filtered.length).to.equal(2); + expect(filtered[0].name).to.equal('target-event'); + expect(filtered[0].data).to.equal('match-1'); + expect(filtered[1].name).to.equal('target-event'); + expect(filtered[1].data).to.equal('match-2'); + client.close(); + }); + + /** + * RTL22a - Subscribe with MessageFilter matching extras.ref.timeserial + * + * Tests that subscribing with a MessageFilter specifying `refTimeserial` + * delivers only messages whose `extras.ref.timeserial` matches. + */ + // UTS: realtime/unit/RTL22a/filter-matching-ref-timeserial-1 + it('RTL22a - subscribe with refTimeserial filter', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL22a-ref', { attachOnSubscribe: false }); + await channel.attach(); + + const filtered: any[] = []; + await channel.subscribe({ refTimeserial: 'abc123@1700000000000-0' }, (msg: any) => filtered.push(msg)); + + // Message with matching extras.ref.timeserial + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-ref', + messages: [{ + name: 'reply', + data: 'match', + extras: { ref: { timeserial: 'abc123@1700000000000-0', type: 'com.ably.reply' } }, + }], + }); + + // Message with different extras.ref.timeserial + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-ref', + messages: [{ + name: 'reply', + data: 'no-match', + extras: { ref: { timeserial: 'xyz789@1700000000000-0', type: 'com.ably.reply' } }, + }], + }); + + // Message with no extras.ref + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-ref', + messages: [{ name: 'plain', data: 'no-ref' }], + }); + + // Another message with matching extras.ref.timeserial + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-ref', + messages: [{ + name: 'reaction', + data: 'match-2', + extras: { ref: { timeserial: 'abc123@1700000000000-0', type: 'com.ably.reaction' } }, + }], + }); + + await flushAsync(); + + expect(filtered.length).to.equal(2); + expect(filtered[0].data).to.equal('match'); + expect(filtered[1].data).to.equal('match-2'); + client.close(); + }); + + /** + * RTL22b - Subscribe with MessageFilter isRef false delivers only + * messages without extras.ref + */ + // UTS: realtime/unit/RTL22b/filter-isref-false-0 + it('RTL22b - subscribe with isRef false filter', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL22b-isref', { attachOnSubscribe: false }); + await channel.attach(); + + const filtered: any[] = []; + await channel.subscribe({ isRef: false }, (msg: any) => filtered.push(msg)); + + // Message WITHOUT extras.ref (no extras at all) — should be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22b-isref', + messages: [{ name: 'plain', data: 'no-extras' }], + }); + + // Message WITH extras.ref — should NOT be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22b-isref', + messages: [{ + 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.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22b-isref', + messages: [{ + name: 'annotated', + data: 'extras-no-ref', + extras: { headers: { 'custom-key': 'custom-value' } }, + }], + }); + + // Another message WITH extras.ref — should NOT be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22b-isref', + messages: [{ + name: 'reaction', + data: 'also-has-ref', + extras: { ref: { timeserial: 'xyz789@1700000000000-0', type: 'com.ably.reaction' } }, + }], + }); + + await flushAsync(); + + expect(filtered.length).to.equal(2); + expect(filtered[0].name).to.equal('plain'); + expect(filtered[0].data).to.equal('no-extras'); + expect(filtered[1].name).to.equal('annotated'); + expect(filtered[1].data).to.equal('extras-no-ref'); + client.close(); + }); + + /** + * RTL22c - Subscribe with MessageFilter matching multiple criteria (name + refType) + * + * Tests that when a MessageFilter specifies multiple criteria (name AND refType), + * only messages matching ALL criteria are delivered. + */ + // UTS: realtime/unit/RTL22c/filter-multiple-criteria-0 + it('RTL22c - subscribe with multiple criteria filter (name + refType)', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL22c-multi', { attachOnSubscribe: false }); + await channel.attach(); + + const filtered: any[] = []; + await channel.subscribe({ name: 'comment', refType: 'com.ably.reply' }, (msg: any) => filtered.push(msg)); + + // Message matching BOTH name AND refType — should be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22c-multi', + messages: [{ + 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.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22c-multi', + messages: [{ + 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.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22c-multi', + messages: [{ + name: 'update', + data: 'type-only', + extras: { ref: { timeserial: 'ghi@1700000000000-0', type: 'com.ably.reply' } }, + }], + }); + + // Message matching NEITHER — should NOT be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22c-multi', + messages: [{ name: 'update', data: 'neither' }], + }); + + // Another message matching BOTH — should be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22c-multi', + messages: [{ + name: 'comment', + data: 'both-match-2', + extras: { ref: { timeserial: 'jkl@1700000000000-0', type: 'com.ably.reply' } }, + }], + }); + + await flushAsync(); + + expect(filtered.length).to.equal(2); + expect(filtered[0].data).to.equal('both-match'); + expect(filtered[1].data).to.equal('both-match-2'); + client.close(); + }); + + /** + * RTL22a, MFI2e - Subscribe with MessageFilter matching clientId + * + * Tests that subscribing with a MessageFilter specifying `clientId` delivers + * only messages whose clientId matches the filter value. + */ + // UTS: realtime/unit/RTL22a/filter-matching-clientid-2 + it('RTL22a+MFI2e - subscribe with clientId filter', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL22a-clientid', { attachOnSubscribe: false }); + await channel.attach(); + + const filtered: any[] = []; + await channel.subscribe({ clientId: 'user-42' }, (msg: any) => filtered.push(msg)); + + // Message with matching clientId + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-clientid', + messages: [{ name: 'chat', data: 'hello', clientId: 'user-42' }], + }); + + // Message with different clientId — should NOT be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-clientid', + messages: [{ name: 'chat', data: 'hi', clientId: 'user-99' }], + }); + + // Message with no clientId — should NOT be delivered + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-clientid', + messages: [{ name: 'system', data: 'broadcast' }], + }); + + // Another message with matching clientId + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL22a-clientid', + messages: [{ name: 'chat', data: 'world', clientId: 'user-42' }], + }); + + await flushAsync(); + + expect(filtered.length).to.equal(2); + expect(filtered[0].data).to.equal('hello'); + expect(filtered[0].clientId).to.equal('user-42'); + expect(filtered[1].data).to.equal('world'); + expect(filtered[1].clientId).to.equal('user-42'); + client.close(); + }); + /** * RTL8a - Unsubscribe listener not currently subscribed is no-op */ + // UTS: realtime/unit/RTL8a/unsubscribe-noop-not-subscribed-1 it('RTL8a - unsubscribe non-subscribed listener is no-op', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/channels/channel_update_delete_message.test.ts b/test/uts/realtime/unit/channels/channel_update_delete_message.test.ts similarity index 94% rename from test/uts/realtime/channels/channel_update_delete_message.test.ts rename to test/uts/realtime/unit/channels/channel_update_delete_message.test.ts index 3ac57fbb8..00979ffc9 100644 --- a/test/uts/realtime/channels/channel_update_delete_message.test.ts +++ b/test/uts/realtime/unit/channels/channel_update_delete_message.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket, PendingWSConnection } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket, PendingWSConnection } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/channels/channel_update_delete_message', function () { +describe('uts/realtime/unit/channels/channel_update_delete_message', function () { afterEach(function () { restoreAll(); }); @@ -49,6 +49,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32b, RTL32b1 - updateMessage sends MESSAGE with action=message.update */ + // UTS: realtime/unit/RTL32b/update-message-action-0 it('RTL32b - updateMessage sends correct wire format', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -96,6 +97,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32b, RTL32b1 - deleteMessage sends MESSAGE with action=message.delete */ + // UTS: realtime/unit/RTL32b/delete-message-action-1 it('RTL32b - deleteMessage sends correct wire format', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -137,6 +139,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32b, RTL32b1 - appendMessage sends MESSAGE with action=message.append */ + // UTS: realtime/unit/RTL32b/append-message-action-2 it('RTL32b - appendMessage sends correct wire format', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -182,6 +185,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32b2 - version field from MessageOperation */ + // UTS: realtime/unit/RTL32b2/version-from-operation-0 it('RTL32b2 - operation included as version field', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -226,6 +230,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32c - Does not mutate user Message */ + // UTS: realtime/unit/RTL32c/no-message-mutation-0 it('RTL32c - original message not mutated', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -268,6 +273,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32d - Returns UpdateDeleteResult with versionSerial from ACK */ + // UTS: realtime/unit/RTL32d/ack-returns-result-0 it('RTL32d - returns UpdateDeleteResult with versionSerial', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -306,6 +312,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32d - NACK returns error */ + // UTS: realtime/unit/RTL32d/nack-returns-error-1 it('RTL32d - NACK returns error', async function () { const { mock } = setupMock({ onMessage: (msg) => { @@ -346,6 +353,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32e - params sent in ProtocolMessage.params */ + // UTS: realtime/unit/RTL32e/params-in-protocol-message-0 it('RTL32e - params included in ProtocolMessage', async function () { const { mock, captured } = setupMock({ onMessage: (msg) => { @@ -384,6 +392,7 @@ describe('uts/realtime/channels/channel_update_delete_message', function () { /** * RTL32a - Serial validation: empty serial throws */ + // UTS: realtime/unit/RTL32a/serial-validation-required-0 it('RTL32a - empty serial throws error', async function () { const { mock } = setupMock(); installMockWebSocket(mock.constructorFn); diff --git a/test/uts/realtime/channels/channel_when_state.test.ts b/test/uts/realtime/unit/channels/channel_when_state.test.ts similarity index 94% rename from test/uts/realtime/channels/channel_when_state.test.ts rename to test/uts/realtime/unit/channels/channel_when_state.test.ts index f65f7aec0..76a0eea07 100644 --- a/test/uts/realtime/channels/channel_when_state.test.ts +++ b/test/uts/realtime/unit/channels/channel_when_state.test.ts @@ -12,10 +12,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channel_when_state', function () { +describe('uts/realtime/unit/channels/channel_when_state', function () { afterEach(function () { restoreAll(); }); @@ -23,6 +23,7 @@ describe('uts/realtime/channels/channel_when_state', function () { /** * RTL25a - whenState resolves immediately if already in target state */ + // UTS: realtime/unit/RTL25a/resolves-immediately-current-0 it('RTL25a - whenState resolves immediately when in target state', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -64,6 +65,7 @@ describe('uts/realtime/channels/channel_when_state', function () { /** * RTL25b - whenState waits for state transition */ + // UTS: realtime/unit/RTL25b/waits-for-state-change-0 it('RTL25b - whenState waits for state then resolves', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -113,6 +115,7 @@ describe('uts/realtime/channels/channel_when_state', function () { /** * RTL25b - whenState only fires once */ + // UTS: realtime/unit/RTL25b/fires-once-only-1 it('RTL25b - whenState is one-shot', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -180,6 +183,7 @@ describe('uts/realtime/channels/channel_when_state', function () { /** * RTL25a - whenState for past state does NOT resolve */ + // UTS: realtime/unit/RTL25a/past-state-does-not-resolve-1 it('RTL25a - whenState for past state does not resolve', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/channels/channels_collection.test.ts b/test/uts/realtime/unit/channels/channels_collection.test.ts similarity index 91% rename from test/uts/realtime/channels/channels_collection.test.ts rename to test/uts/realtime/unit/channels/channels_collection.test.ts index e04f419ac..d639e48bd 100644 --- a/test/uts/realtime/channels/channels_collection.test.ts +++ b/test/uts/realtime/unit/channels/channels_collection.test.ts @@ -12,10 +12,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/channels_collection', function () { +describe('uts/realtime/unit/channels/channels_collection', function () { afterEach(function () { restoreAll(); }); @@ -23,6 +23,7 @@ describe('uts/realtime/channels/channels_collection', function () { /** * RTS1 - Channels collection accessible via RealtimeClient */ + // UTS: realtime/unit/RTS1/channels-collection-accessible-0 it('RTS1 - channels collection accessible via client.channels', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -44,6 +45,7 @@ describe('uts/realtime/channels/channels_collection', function () { * * Deviation: ably-js has no exists() method. Use `name in channels.all`. */ + // UTS: realtime/unit/RTS2/channel-exists-check-0 it('RTS2 - check channel existence', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -71,6 +73,7 @@ describe('uts/realtime/channels/channels_collection', function () { * * Deviation: ably-js has no channels.names — use Object.keys(channels.all). */ + // UTS: realtime/unit/RTS2/iterate-channels-1 it('RTS2 - iterate through existing channels', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -94,6 +97,7 @@ describe('uts/realtime/channels/channels_collection', function () { /** * RTS3a - Get creates new channel if none exists */ + // UTS: realtime/unit/RTS3a/get-creates-new-channel-0 it('RTS3a - get creates new channel', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -112,6 +116,7 @@ describe('uts/realtime/channels/channels_collection', function () { /** * RTS3a - Get returns existing channel (same reference) */ + // UTS: realtime/unit/RTS3a/get-returns-existing-channel-1 it('RTS3a - get returns same channel instance', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -131,6 +136,7 @@ describe('uts/realtime/channels/channels_collection', function () { /** * RTS4a - Release removes channel from collection */ + // UTS: realtime/unit/RTS4a/release-detaches-attached-2 it('RTS4a - release removes channel', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -151,6 +157,7 @@ describe('uts/realtime/channels/channels_collection', function () { /** * RTS4a - Release on non-existent channel is no-op */ + // UTS: realtime/unit/RTS4a/release-nonexistent-noop-1 it('RTS4a - release non-existent channel is no-op', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -171,6 +178,7 @@ describe('uts/realtime/channels/channels_collection', function () { * Per spec: "Detaches the channel and then releases the channel resource * i.e. it's deleted and can then be garbage collected" */ + // UTS: realtime/unit/RTS4a/release-removes-channel-0 it('RTS4a - release detaches and removes attached channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -225,6 +233,7 @@ describe('uts/realtime/channels/channels_collection', function () { /** * RTS3a - Get after release creates new channel instance */ + // UTS: realtime/unit/RTS3a/get-after-release-new-3 it('RTS3a - get after release creates new instance', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -251,6 +260,7 @@ describe('uts/realtime/channels/channels_collection', function () { * This test verifies that channels.all[name] returns the same channel as * channels.get(name) after creation. */ + // UTS: realtime/unit/RTS3a/subscript-operator-channel-2 it('RTS3a - channels.all bracket access returns same channel', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', diff --git a/test/uts/realtime/channels/message_field_population.test.ts b/test/uts/realtime/unit/channels/message_field_population.test.ts similarity index 95% rename from test/uts/realtime/channels/message_field_population.test.ts rename to test/uts/realtime/unit/channels/message_field_population.test.ts index 17e2dfc1a..43012fa56 100644 --- a/test/uts/realtime/channels/message_field_population.test.ts +++ b/test/uts/realtime/unit/channels/message_field_population.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/channels/message_field_population', function () { +describe('uts/realtime/unit/channels/message_field_population', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/channels/message_field_population', function () { /** * TM2a - Message id populated from ProtocolMessage id and index */ + // UTS: realtime/unit/TM2a/id-from-protocol-message-0 it('TM2a - id derived from ProtocolMessage id:index', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -79,6 +80,7 @@ describe('uts/realtime/channels/message_field_population', function () { /** * TM2a - Message with existing id is not overwritten */ + // UTS: realtime/unit/TM2a/existing-id-not-overwritten-1 it('TM2a - existing id not overwritten', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -131,6 +133,7 @@ describe('uts/realtime/channels/message_field_population', function () { /** * TM2c - Message connectionId populated from ProtocolMessage */ + // UTS: realtime/unit/TM2c/connectionid-from-protocol-0 it('TM2c - connectionId from ProtocolMessage', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -183,6 +186,7 @@ describe('uts/realtime/channels/message_field_population', function () { /** * TM2f - Message timestamp populated from ProtocolMessage */ + // UTS: realtime/unit/TM2f/timestamp-from-protocol-0 it('TM2f - timestamp from ProtocolMessage', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -235,6 +239,7 @@ describe('uts/realtime/channels/message_field_population', function () { /** * TM2a, TM2c, TM2f - All fields populated together */ + // UTS: realtime/unit/TM2a/all-fields-populated-together-3 it('TM2a+c+f - all fields populated from ProtocolMessage', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -300,6 +305,7 @@ describe('uts/realtime/channels/message_field_population', function () { * When the ProtocolMessage itself has no id field, messages without * their own id should remain without one. */ + // UTS: realtime/unit/TM2a/no-id-without-protocol-id-2 it('TM2a - no id when ProtocolMessage has no id', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -359,6 +365,7 @@ describe('uts/realtime/channels/message_field_population', function () { * A message that already has its own connectionId should retain it, * not have it overwritten by the ProtocolMessage connectionId. */ + // UTS: realtime/unit/TM2c/existing-connectionid-kept-1 it('TM2c - existing connectionId not overwritten', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -415,6 +422,7 @@ describe('uts/realtime/channels/message_field_population', function () { * A message that already has its own timestamp should retain it, * not have it overwritten by the ProtocolMessage timestamp. */ + // UTS: realtime/unit/TM2f/existing-timestamp-kept-1 it('TM2f - existing timestamp not overwritten', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/client/client_options.test.ts b/test/uts/realtime/unit/client/client_options.test.ts similarity index 83% rename from test/uts/realtime/client/client_options.test.ts rename to test/uts/realtime/unit/client/client_options.test.ts index 621d97961..12e9d6b21 100644 --- a/test/uts/realtime/client/client_options.test.ts +++ b/test/uts/realtime/unit/client/client_options.test.ts @@ -8,9 +8,9 @@ */ import { expect } from 'chai'; -import { Ably, trackClient, restoreAll } from '../../helpers'; +import { Ably, trackClient, restoreAll } from '../../../helpers'; -describe('uts/realtime/client/client_options', function () { +describe('uts/realtime/unit/client/client_options', function () { afterEach(function () { restoreAll(); }); @@ -18,18 +18,21 @@ describe('uts/realtime/client/client_options', function () { /** * RSC1a / RTC12 - API key string detected (contains :) */ + // UTS: realtime/unit/RTC12/constructor-string-detection-0.1 it('RSC1a - API key string (standard format)', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); trackClient(client); expect(client.options.key).to.equal('appId.keyId:keySecret'); }); + // UTS: realtime/unit/RTC12/constructor-string-detection-0.2 it('RSC1a - API key string (special chars)', function () { const client = new Ably.Realtime({ key: 'xVLyHw.A-pwh:5WEB4HEAT3pOqWp9', autoConnect: false }); trackClient(client); expect(client.options.key).to.equal('xVLyHw.A-pwh:5WEB4HEAT3pOqWp9'); }); + // UTS: realtime/unit/RTC12/constructor-string-detection-0.3 it('RSC1a - API key string (extended secret)', function () { const client = new Ably.Realtime({ key: 'xVLyHw.A-pwh:5WEB4HEAT3pOqWp9-the_rest', autoConnect: false }); trackClient(client); @@ -39,12 +42,14 @@ describe('uts/realtime/client/client_options', function () { /** * RSC1c / RTC12 - Token string detected (no : before first .) */ + // UTS: realtime/unit/RTC12/constructor-string-detection-0.4 it('RSC1c - token string (opaque)', function () { const client = new Ably.Realtime({ token: 'abcdef1234567890', autoConnect: false }); trackClient(client); expect(client.options.token).to.equal('abcdef1234567890'); }); + // UTS: realtime/unit/RTC12/constructor-string-detection-0.5 it('RSC1c - token string (JWT format)', function () { const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; @@ -56,6 +61,7 @@ describe('uts/realtime/client/client_options', function () { /** * RSC1b / RTC12 - No credentials raises error */ + // UTS: realtime/unit/RTC12/invalid-arguments-error-1.1 it('RSC1b - no credentials raises error', function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js uses 40160 instead of 40106; see #2204 try { @@ -66,6 +72,7 @@ describe('uts/realtime/client/client_options', function () { } }); + // UTS: realtime/unit/RTC12/invalid-arguments-error-1.2 it('RSC1b - useTokenAuth without means raises error', function () { try { new Ably.Realtime({ useTokenAuth: true, autoConnect: false } as any); @@ -75,6 +82,7 @@ describe('uts/realtime/client/client_options', function () { } }); + // UTS: realtime/unit/RTC12/invalid-arguments-error-1.3 it('RSC1b - clientId alone raises error', function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js uses 40160 instead of 40106; see #2204 try { @@ -88,6 +96,7 @@ describe('uts/realtime/client/client_options', function () { /** * RSC1 / RTC12 - ClientOptions object preserves values */ + // UTS: realtime/unit/RTC12/constructor-string-detection-0.6 it('RSC1 - ClientOptions values preserved', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', diff --git a/test/uts/realtime/client/realtime_client.test.ts b/test/uts/realtime/unit/client/realtime_client.test.ts similarity index 92% rename from test/uts/realtime/client/realtime_client.test.ts rename to test/uts/realtime/unit/client/realtime_client.test.ts index 38ed82cce..939de5c35 100644 --- a/test/uts/realtime/client/realtime_client.test.ts +++ b/test/uts/realtime/unit/client/realtime_client.test.ts @@ -6,11 +6,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/client/realtime_client', function () { +describe('uts/realtime/unit/client/realtime_client', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC2 - Connection attribute */ + // UTS: realtime/unit/RTC2/connection-attribute-0 it('RTC2 - client.connection is accessible', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); trackClient(client); @@ -34,6 +35,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC3 - Channels attribute */ + // UTS: realtime/unit/RTC3/channels-attribute-0 it('RTC3 - client.channels is accessible', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); trackClient(client); @@ -48,6 +50,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC4 - Auth attribute */ + // UTS: realtime/unit/RTC4/auth-attribute-0 it('RTC4 - client.auth is accessible', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); trackClient(client); @@ -60,6 +63,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC13 - Push attribute */ + // UTS: realtime/unit/RTC13/push-attribute-0 it('RTC13 - client.push is accessible', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); trackClient(client); @@ -72,6 +76,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC17 - clientId attribute */ + // UTS: realtime/unit/RTC17/client-id-attribute-0 it('RTC17 - clientId from options', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -88,6 +93,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1a_1 - echoMessages defaults to true */ + // UTS: realtime/unit/RTC1a/echo-messages-option-0 it('RTC1a - echoMessages default sends echo=true', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js omits echo param when true let echoParam: string | null = null; @@ -109,6 +115,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1a_2 - echoMessages set to false */ + // UTS: realtime/unit/RTC1a/echo-messages-option-0.1 it('RTC1a - echoMessages false sends echo=false', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -135,6 +142,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1b_1 - autoConnect defaults to true */ + // UTS: realtime/unit/RTC1b/auto-connect-option-0.1 it('RTC1b - autoConnect defaults to true', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -157,6 +165,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1b_2 - autoConnect set to false */ + // UTS: realtime/unit/RTC1b/auto-connect-option-0.2 it('RTC1b - autoConnect false stays initialized', async function () { const mock = new MockWebSocket({ onConnectionAttempt: () => { @@ -184,6 +193,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1b_3 - Explicit connect after autoConnect false */ + // UTS: realtime/unit/RTC1b/auto-connect-option-0 it('RTC1b - explicit connect after autoConnect false', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -215,6 +225,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1c_1 - recover string sent in connection request */ + // UTS: realtime/unit/RTC1c/recover-option-0 it('RTC1c - recover key sent in URL', function (done) { const recoveryKey = JSON.stringify({ connectionKey: 'previous-connection-key', @@ -246,6 +257,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1c_3 - Invalid recovery key handled gracefully */ + // UTS: realtime/unit/RTC12/invalid-arguments-error-1 it('RTC1c - invalid recovery key handled gracefully', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -274,6 +286,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1f_1 - transportParams included in connection URL */ + // UTS: realtime/unit/RTC1f/transport-params-option-0 it('RTC1f - transportParams in URL', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -300,6 +313,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1f_2 - transportParams with different value types */ + // UTS: realtime/unit/RTC12/constructor-string-detection-0 it('RTC1f - transportParams value types stringified', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -333,6 +347,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC1f1 - transportParams override library defaults */ + // UTS: realtime/unit/RTC1f/transport-params-option-0.1 it('RTC1f1 - transportParams override defaults', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -361,6 +376,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC15a - connect() calls Connection#connect */ + // UTS: realtime/unit/RTC15/connect-method-0 it('RTC15 - connect() proxies to connection', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -390,6 +406,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RTC16a - close() calls Connection#close */ + // UTS: realtime/unit/RTC16/close-method-0 it('RTC16 - close() proxies to connection', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -425,6 +442,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * Standard query parameters present in connection URL */ + // UTS: realtime/unit/RTC2/connection-attribute-0.1 it('Standard query params in connection URL', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -457,6 +475,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * RSC18 - TLS setting affects WebSocket URL scheme */ + // UTS: realtime/unit/RTC17/client-id-attribute-0.1 it('RSC18 - TLS true uses wss://', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -479,6 +498,7 @@ describe('uts/realtime/client/realtime_client', function () { }); }); + // UTS: realtime/unit/RTC17/client-id-attribute-0.2 it('RSC18 - TLS false uses ws://', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -504,6 +524,7 @@ describe('uts/realtime/client/realtime_client', function () { /** * useBinaryProtocol affects format query param */ + // UTS: realtime/unit/RTC17/client-id-attribute-0.3 it('useBinaryProtocol false sends format=json', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -525,6 +546,7 @@ describe('uts/realtime/client/realtime_client', function () { }); }); + // UTS: realtime/unit/RTC17/client-id-attribute-0.4 it('useBinaryProtocol true sends format=msgpack', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/client/realtime_request.test.ts b/test/uts/realtime/unit/client/realtime_request.test.ts similarity index 91% rename from test/uts/realtime/client/realtime_request.test.ts rename to test/uts/realtime/unit/client/realtime_request.test.ts index a731a4d0a..6ad90d2ee 100644 --- a/test/uts/realtime/client/realtime_request.test.ts +++ b/test/uts/realtime/unit/client/realtime_request.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/realtime/client/realtime_request', function () { +describe('uts/realtime/unit/client/realtime_request', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/client/realtime_request', function () { /** * RTC9 / RSC19 - GET request */ + // UTS: realtime/unit/RTC9/request-proxies-rest-0 it('RTC9 - request() sends GET', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -46,6 +47,7 @@ describe('uts/realtime/client/realtime_request', function () { /** * RTC9 / RSC19 - POST request with body */ + // UTS: realtime/unit/RTC9/request-proxies-rest-0.1 it('RTC9 - request() sends POST with body', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -74,6 +76,7 @@ describe('uts/realtime/client/realtime_request', function () { /** * RTC9 / RSC19 - request() with query params */ + // UTS: rest/unit/RSC19f1/version-param-sets-header-0 it('RTC9 - request() passes query params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -105,6 +108,7 @@ describe('uts/realtime/client/realtime_request', function () { /** * RTC9 / RSC19 - HttpPaginatedResponse structure */ + // UTS: realtime/unit/RTC9/request-proxies-rest-0.2 it('RTC9 - returns HttpPaginatedResponse', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -128,6 +132,7 @@ describe('uts/realtime/client/realtime_request', function () { /** * RTC9 / RSC19 - Error response */ + // UTS: rest/unit/RSC19d/empty-response-handling-8 it('RTC9 - error response has correct fields', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/realtime/client/realtime_stats.test.ts b/test/uts/realtime/unit/client/realtime_stats.test.ts similarity index 91% rename from test/uts/realtime/client/realtime_stats.test.ts rename to test/uts/realtime/unit/client/realtime_stats.test.ts index 9f75bf0d6..ad181be8c 100644 --- a/test/uts/realtime/client/realtime_stats.test.ts +++ b/test/uts/realtime/unit/client/realtime_stats.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/realtime/client/realtime_stats', function () { +describe('uts/realtime/unit/client/realtime_stats', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/client/realtime_stats', function () { /** * RTC5a - stats() sends GET /stats */ + // UTS: realtime/unit/RTC5/stats-proxies-rest-0.1 it('RTC5a - stats() sends GET /stats', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -48,6 +49,7 @@ describe('uts/realtime/client/realtime_stats', function () { /** * RTC5b - stats() accepts params */ + // UTS: realtime/unit/RTC5/stats-proxies-rest-0.2 it('RTC5b - stats() passes query params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -77,6 +79,7 @@ describe('uts/realtime/client/realtime_stats', function () { /** * RTC5 - stats() returns PaginatedResult */ + // UTS: realtime/unit/RTC5/stats-proxies-rest-0 it('RTC5 - stats() returns PaginatedResult', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/realtime/client/realtime_time.test.ts b/test/uts/realtime/unit/client/realtime_time.test.ts similarity index 86% rename from test/uts/realtime/client/realtime_time.test.ts rename to test/uts/realtime/unit/client/realtime_time.test.ts index 0d2b7e2b7..9f0054f88 100644 --- a/test/uts/realtime/client/realtime_time.test.ts +++ b/test/uts/realtime/unit/client/realtime_time.test.ts @@ -8,10 +8,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll, trackClient } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/client/realtime_time', function () { +describe('uts/realtime/unit/client/realtime_time', function () { afterEach(function () { restoreAll(); }); @@ -21,6 +21,7 @@ describe('uts/realtime/client/realtime_time', function () { * * time() makes a GET request to /time and returns the server timestamp. */ + // UTS: rest/unit/RSC16/returns-server-time-0.1 it('RTC6a - time() returns server time', async function () { const serverTime = 1625000000000; diff --git a/test/uts/realtime/client/realtime_timeouts.test.ts b/test/uts/realtime/unit/client/realtime_timeouts.test.ts similarity index 94% rename from test/uts/realtime/client/realtime_timeouts.test.ts rename to test/uts/realtime/unit/client/realtime_timeouts.test.ts index fff8f0f2d..bac3f3304 100644 --- a/test/uts/realtime/client/realtime_timeouts.test.ts +++ b/test/uts/realtime/unit/client/realtime_timeouts.test.ts @@ -6,9 +6,9 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; /** * Helper: wait for connection state using real event loop. @@ -39,7 +39,7 @@ async function connectWithFakeTimers(client: any, clock: any) { } } -describe('uts/realtime/client/realtime_timeouts', function () { +describe('uts/realtime/unit/client/realtime_timeouts', function () { afterEach(function () { restoreAll(); }); @@ -47,6 +47,7 @@ describe('uts/realtime/client/realtime_timeouts', function () { /** * RTC7 - default timeouts applied when not configured */ + // UTS: realtime/unit/RTC7/default-timeouts-applied-3 it('RTC7 - default timeouts', function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -71,6 +72,7 @@ describe('uts/realtime/client/realtime_timeouts', function () { * When the server does not respond to ATTACH within the custom timeout, * the channel should transition to SUSPENDED (RTL4f). */ + // UTS: realtime/unit/RTC7/attach-request-timeout-0 it('RTC7 - realtimeRequestTimeout on attach', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -129,6 +131,7 @@ describe('uts/realtime/client/realtime_timeouts', function () { * When the server does not respond to DETACH within the custom timeout, * the channel should return to ATTACHED (RTL5f). */ + // UTS: realtime/unit/RTC7/detach-request-timeout-1 it('RTC7 - realtimeRequestTimeout on detach', async function () { let ignoreDetach = false; @@ -199,6 +202,7 @@ describe('uts/realtime/client/realtime_timeouts', function () { * After disconnect, RTN15a triggers an immediate retry. If that fails too, * the library waits disconnectedRetryTimeout before the next attempt. */ + // UTS: realtime/unit/RTC7/disconnected-retry-timeout-2 it('RTC7 - disconnectedRetryTimeout controls retry delay', async function () { let connectionAttemptCount = 0; diff --git a/test/uts/realtime/connection/auto_connect.test.ts b/test/uts/realtime/unit/connection/auto_connect.test.ts similarity index 91% rename from test/uts/realtime/connection/auto_connect.test.ts rename to test/uts/realtime/unit/connection/auto_connect.test.ts index 063487b18..2a1c70dd7 100644 --- a/test/uts/realtime/connection/auto_connect.test.ts +++ b/test/uts/realtime/unit/connection/auto_connect.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/connection/auto_connect', function () { +describe('uts/realtime/unit/connection/auto_connect', function () { afterEach(function () { restoreAll(); }); @@ -17,6 +17,7 @@ describe('uts/realtime/connection/auto_connect', function () { /** * RTN3 - autoConnect true initiates connection immediately */ + // UTS: realtime/unit/RTN3/auto-connect-true-0 it('RTN3 - autoConnect true initiates connection immediately', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -50,6 +51,7 @@ describe('uts/realtime/connection/auto_connect', function () { /** * RTN3 - autoConnect false does not initiate connection */ + // UTS: realtime/unit/RTN3/auto-connect-false-1 it('RTN3 - autoConnect false does not initiate connection', async function () { let connectionAttempted = false; @@ -80,6 +82,7 @@ describe('uts/realtime/connection/auto_connect', function () { /** * RTN3 - explicit connect after autoConnect false */ + // UTS: realtime/unit/RTN3/explicit-connect-after-false-2 it('RTN3 - explicit connect after autoConnect false', function (done) { let connectionAttempted = false; diff --git a/test/uts/realtime/unit/connection/backoff_jitter.test.ts b/test/uts/realtime/unit/connection/backoff_jitter.test.ts new file mode 100644 index 000000000..425ccfb42 --- /dev/null +++ b/test/uts/realtime/unit/connection/backoff_jitter.test.ts @@ -0,0 +1,347 @@ +/** + * UTS: Backoff and Jitter Tests + * + * Spec points: RTB1, RTB1a, RTB1b + * Source: specification/uts/realtime/unit/connection/backoff_jitter_test.md + * + * RTB1 defines how retry delays are calculated for connections in the + * DISCONNECTED state and channels in the SUSPENDED state. The delay is: + * initialRetryTimeout * backoffCoefficient * jitterCoefficient + * + * RTB1a: backoff = min((n+2)/3, 2) for the nth retry + * RTB1b: jitter is uniformly distributed in [0.8, 1.0] + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, Platform, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; + +// Import the backoff/jitter functions directly from utils for unit testing +import { getBackoffCoefficient, getJitterCoefficient, getRetryTime } from '../../../../../src/common/lib/util/utils'; + +async function pumpTimers(clock: any, iterations = 30) { + for (let i = 0; i < iterations; i++) { + clock.tick(0); + await flushAsync(); + } +} + +describe('uts/realtime/unit/connection/backoff_jitter', function () { + afterEach(function () { + restoreAll(); + }); + + // --- RTB1a: Backoff coefficient --- + + /** + * RTB1a - Backoff coefficient follows min((n+2)/3, 2) for successive retries + * + * The backoff coefficient for the nth retry is calculated as + * min((n+2)/3, 2), producing the sequence [1, 4/3, 5/3, 2, 2, ...]. + */ + // UTS: realtime/unit/RTB1a/backoff-coefficient-sequence-0 + it('RTB1a - backoff coefficient follows min((n+2)/3, 2)', function () { + // Calculate backoff coefficients for retries 1 through 10 + const coefficients: number[] = []; + for (let n = 1; n <= 10; n++) { + coefficients.push(getBackoffCoefficient(n)); + } + + // Verify exact values for the first few retries + expect(coefficients[0]).to.equal(1.0); // n=1: (1+2)/3 = 1 + expect(coefficients[1]).to.equal(4.0 / 3.0); // n=2: (2+2)/3 = 4/3 + expect(coefficients[2]).to.equal(5.0 / 3.0); // n=3: (3+2)/3 = 5/3 + expect(coefficients[3]).to.equal(2.0); // n=4: (4+2)/3 = 2, capped at 2 + + // Verify all subsequent retries are capped at 2.0 + for (let i = 3; i < 10; i++) { + expect(coefficients[i]).to.equal(2.0); + } + }); + + // --- RTB1b: Jitter coefficient --- + + /** + * RTB1b - Jitter coefficient is between 0.8 and 1.0 + * + * The jitter coefficient is a random number between 0.8 and 1.0, + * approximately uniformly distributed. + */ + // UTS: realtime/unit/RTB1b/jitter-coefficient-range-0 + it('RTB1b - jitter coefficient is between 0.8 and 1.0 with uniform distribution', function () { + const sampleCount = 1000; + const jitterValues: number[] = []; + + for (let i = 0; i < sampleCount; i++) { + jitterValues.push(getJitterCoefficient()); + } + + // All values must be within [0.8, 1.0] + for (const jitter of jitterValues) { + expect(jitter).to.be.at.least(0.8); + expect(jitter).to.be.at.most(1.0); + } + + // Verify approximate uniformity: the mean should be close to 0.9 + const mean = jitterValues.reduce((a, b) => a + b, 0) / sampleCount; + expect(mean).to.be.at.least(0.85); + expect(mean).to.be.at.most(0.95); + + // Verify spread: not all values are the same + const minValue = Math.min(...jitterValues); + const maxValue = Math.max(...jitterValues); + expect(maxValue - minValue).to.be.greaterThan(0.05); + }); + + // --- RTB1: Combined retry delay for DISCONNECTED connections --- + + /** + * RTB1 - Combined retry delay for DISCONNECTED connections + * + * Verifies that the retryIn value on ConnectionStateChange events during + * DISCONNECTED retries follows the formula: + * disconnectedRetryTimeout * min((n+2)/3, 2) * jitter(0.8-1.0) + */ + // UTS: realtime/unit/RTB1/disconnected-retry-delay-0 + it('RTB1 - DISCONNECTED retry delays follow backoff * jitter formula', async function () { + let connectionAttemptCount = 0; + const retryDelays: number[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + // Initial connection succeeds + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 60000, + } as any, + }); + } else { + // All reconnection attempts fail + conn.respond_with_refused(); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, 'yes'), + }); + installMockHttp(httpMock); + + const disconnectedRetryTimeout = 2000; // 2 seconds + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + disconnectedRetryTimeout: disconnectedRetryTimeout, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + // Capture retryIn from DISCONNECTED state changes + client.connection.on((change: any) => { + if (change.current === 'disconnected' && change.retryIn != null) { + retryDelays.push(change.retryIn); + } + }); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + + // Simulate unexpected disconnect to trigger reconnection cycle + mock.active_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. + for (let i = 0; i < 30; i++) { + await clock.tickAsync(5000); + await pumpTimers(clock); + if (retryDelays.length >= 5) break; + } + + expect(retryDelays.length).to.be.at.least(5); + + // Retry 1: backoff = 1.0, range = [2000*0.8, 2000*1.0] = [1600, 2000] + expect(retryDelays[0]).to.be.at.least(disconnectedRetryTimeout * 1.0 * 0.8); + expect(retryDelays[0]).to.be.at.most(disconnectedRetryTimeout * 1.0 * 1.0); + + // Retry 2: backoff = 4/3, range = [2000*4/3*0.8, 2000*4/3*1.0] + expect(retryDelays[1]).to.be.at.least(disconnectedRetryTimeout * (4.0 / 3.0) * 0.8); + expect(retryDelays[1]).to.be.at.most(disconnectedRetryTimeout * (4.0 / 3.0) * 1.0); + + // Retry 3: backoff = 5/3, range = [2000*5/3*0.8, 2000*5/3*1.0] + expect(retryDelays[2]).to.be.at.least(disconnectedRetryTimeout * (5.0 / 3.0) * 0.8); + expect(retryDelays[2]).to.be.at.most(disconnectedRetryTimeout * (5.0 / 3.0) * 1.0); + + // Retry 4: backoff = 2.0 (capped), range = [2000*2*0.8, 2000*2*1.0] = [3200, 4000] + expect(retryDelays[3]).to.be.at.least(disconnectedRetryTimeout * 2.0 * 0.8); + expect(retryDelays[3]).to.be.at.most(disconnectedRetryTimeout * 2.0 * 1.0); + + // Retry 5: backoff = 2.0 (capped), same range + expect(retryDelays[4]).to.be.at.least(disconnectedRetryTimeout * 2.0 * 0.8); + expect(retryDelays[4]).to.be.at.most(disconnectedRetryTimeout * 2.0 * 1.0); + + client.close(); + }); + + // --- RTB1: Combined retry delay for SUSPENDED channels --- + + /** + * RTB1 - Combined retry delay for SUSPENDED channels + * + * Verifies that the retry timing for SUSPENDED channel re-attach attempts + * follows the formula: channelRetryTimeout * backoff * jitter. + * + * Note: ably-js ChannelStateChange does not expose a retryIn property. + * Instead, we verify the timing by observing when the channel transitions + * from SUSPENDED to ATTACHING (i.e., when the retry timer fires). The + * elapsed time between SUSPENDED and ATTACHING should match the expected + * retry delay. + */ + // UTS: realtime/unit/RTB1/suspended-channel-retry-delay-1 + it('RTB1 - SUSPENDED channel retry timing follows backoff * jitter formula', async function () { + const channelName = 'test-RTB1-channel'; + let connectionAttemptCount = 0; + let attachCount = 0; + const retryTimings: number[] = []; + let lastSuspendedTime = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach succeeds + conn!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } else { + // All subsequent re-attach attempts fail with DETACHED + // (per RTL13b, when attaching state receives DETACHED, channel goes to SUSPENDED) + conn!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + error: { + code: 90001, + statusCode: 500, + message: 'Channel re-attach failed', + }, + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, 'yes'), + }); + installMockHttp(httpMock); + + const channelRetryTimeout = 3000; // 3 seconds + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + channelRetryTimeout: channelRetryTimeout, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get(channelName); + + // Track transitions to measure retry timing + channel.on((change: any) => { + if (change.current === 'suspended') { + lastSuspendedTime = clock.now; + } + if (change.current === 'attaching' && lastSuspendedTime > 0) { + const elapsed = clock.now - lastSuspendedTime; + retryTimings.push(elapsed); + } + }); + + // Initial attach succeeds + channel.attach(); + await pumpTimers(clock); + + expect(channel.state).to.equal('attached'); + + // Server sends DETACHED error on the channel while attached. + // Per RTL13a, when attached and receiving DETACHED, it triggers attaching. + // Then the re-attach fails with DETACHED response, which puts it into SUSPENDED. + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: channelName, + error: { + code: 90001, + statusCode: 500, + message: 'Channel error', + }, + }); + + // Advance time in increments to allow multiple SUSPENDED -> ATTACHING cycles. + for (let i = 0; i < 30; i++) { + await clock.tickAsync(7000); + await pumpTimers(clock); + if (retryTimings.length >= 4) break; + } + + expect(retryTimings.length).to.be.at.least(4); + + // Retry 1: backoff = 1.0, range = [3000*0.8, 3000*1.0] = [2400, 3000] + expect(retryTimings[0]).to.be.at.least(channelRetryTimeout * 1.0 * 0.8); + expect(retryTimings[0]).to.be.at.most(channelRetryTimeout * 1.0 * 1.0); + + // Retry 2: backoff = 4/3, range = [3000*4/3*0.8, 3000*4/3*1.0] = [3200, 4000] + expect(retryTimings[1]).to.be.at.least(channelRetryTimeout * (4.0 / 3.0) * 0.8); + expect(retryTimings[1]).to.be.at.most(channelRetryTimeout * (4.0 / 3.0) * 1.0); + + // Retry 3: backoff = 5/3, range = [3000*5/3*0.8, 3000*5/3*1.0] = [4000, 5000] + expect(retryTimings[2]).to.be.at.least(channelRetryTimeout * (5.0 / 3.0) * 0.8); + expect(retryTimings[2]).to.be.at.most(channelRetryTimeout * (5.0 / 3.0) * 1.0); + + // Retry 4: backoff = 2.0 (capped), range = [3000*2*0.8, 3000*2*1.0] = [4800, 6000] + expect(retryTimings[3]).to.be.at.least(channelRetryTimeout * 2.0 * 0.8); + expect(retryTimings[3]).to.be.at.most(channelRetryTimeout * 2.0 * 1.0); + + client.close(); + }); +}); diff --git a/test/uts/realtime/connection/connection_failures.test.ts b/test/uts/realtime/unit/connection/connection_failures.test.ts similarity index 96% rename from test/uts/realtime/connection/connection_failures.test.ts rename to test/uts/realtime/unit/connection/connection_failures.test.ts index f5baf0564..8bc0c906e 100644 --- a/test/uts/realtime/connection/connection_failures.test.ts +++ b/test/uts/realtime/unit/connection/connection_failures.test.ts @@ -6,9 +6,9 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; async function pumpTimers(clock: any, iterations = 30) { for (let i = 0; i < iterations; i++) { @@ -17,7 +17,7 @@ async function pumpTimers(clock: any, iterations = 30) { } } -describe('uts/realtime/connection/connection_failures', function () { +describe('uts/realtime/unit/connection/connection_failures', function () { afterEach(function () { restoreAll(); }); @@ -25,6 +25,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15a - Unexpected transport disconnect triggers resume */ + // UTS: realtime/unit/RTN15a/unexpected-transport-disconnect-0 it('RTN15a - unexpected disconnect triggers resume', function (done) { let connectionAttemptCount = 0; @@ -85,6 +86,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15b, RTN15c6 - Successful resume preserves connectionId, uses resume param */ + // UTS: realtime/unit/RTN15b/successful-resume-0 it('RTN15b, RTN15c6 - successful resume with connectionKey in URL', function (done) { let connectionAttemptCount = 0; @@ -159,6 +161,7 @@ describe('uts/realtime/connection/connection_failures', function () { * Per spec: When connection is resumed, Connection.key may change and is * provided in CONNECTED message connectionDetails. */ + // UTS: realtime/unit/RTN15e/connection-key-updated-0 it('RTN15e - connection key updated on resume', function (done) { let connectionAttemptCount = 0; @@ -228,6 +231,7 @@ describe('uts/realtime/connection/connection_failures', function () { * The error should be set as Connection#errorReason and as the reason * in the CONNECTED event. */ + // UTS: realtime/unit/RTN15c7/failed-resume-new-id-0 it('RTN15c7 - failed resume gets new connectionId', function (done) { let connectionAttemptCount = 0; @@ -308,6 +312,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15g - Connection state cleared after connectionStateTtl (no resume) */ + // UTS: realtime/unit/RTN15g/state-cleared-after-ttl-0 it('RTN15g - no resume after connectionStateTtl expires', async function () { let connectionAttemptCount = 0; @@ -397,6 +402,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15h1 - DISCONNECTED with token error, no means to renew → FAILED */ + // UTS: realtime/unit/RTN15h1/token-error-no-renew-0 it('RTN15h1 - token error without renewal causes FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -444,6 +450,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15h2 - DISCONNECTED with token error, renewable token → reconnect */ + // UTS: realtime/unit/RTN15h2/token-error-renew-success-0 it('RTN15h2 - token error with renewal reconnects', function (done) { let connectionAttemptCount = 0; let authCallbackCount = 0; @@ -515,6 +522,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15h3 - DISCONNECTED with non-token error → immediate resume */ + // UTS: realtime/unit/RTN15h3/non-token-error-resume-0 it('RTN15h3 - non-token disconnect triggers resume', async function () { let connectionAttemptCount = 0; @@ -593,6 +601,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15c4 - Fatal ERROR during resume → FAILED */ + // UTS: realtime/unit/RTN15c4/fatal-error-during-resume-0 it('RTN15c4 - fatal error during resume causes FAILED', function (done) { let connectionAttemptCount = 0; @@ -652,6 +661,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15c5 - Token error during resume triggers renewal */ + // UTS: realtime/unit/RTN15c5/token-error-during-resume-0 it('RTN15c5 - token error during resume triggers renewal', function (done) { let connectionAttemptCount = 0; let authCallbackCount = 0; @@ -727,6 +737,7 @@ describe('uts/realtime/connection/connection_failures', function () { /** * RTN15j - ERROR with empty channel when CONNECTED → FAILED */ + // UTS: realtime/unit/RTN15j/error-empty-channel-failed-0 it('RTN15j - connection-level ERROR causes FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -776,6 +787,7 @@ describe('uts/realtime/connection/connection_failures', function () { * has the means to renew the token, but the token creation fails, the connection * must transition to the DISCONNECTED state and set Connection#errorReason. */ + // UTS: realtime/unit/RTN15h2/token-error-renew-fails-1 it('RTN15h2 - token error with renewal failure causes DISCONNECTED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/connection/connection_id_key.test.ts b/test/uts/realtime/unit/connection/connection_id_key.test.ts similarity index 93% rename from test/uts/realtime/connection/connection_id_key.test.ts rename to test/uts/realtime/unit/connection/connection_id_key.test.ts index 4fda83bb6..280eca1fe 100644 --- a/test/uts/realtime/connection/connection_id_key.test.ts +++ b/test/uts/realtime/unit/connection/connection_id_key.test.ts @@ -6,11 +6,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/connection/connection_id_key', function () { +describe('uts/realtime/unit/connection/connection_id_key', function () { afterEach(function () { restoreAll(); }); @@ -18,6 +18,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN8a - Connection ID is unset until connected */ + // UTS: realtime/unit/RTN8a/id-unset-until-connected-0 it('RTN8a - connection.id is null before connected', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -55,6 +56,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN9a - Connection key is unset until connected */ + // UTS: realtime/unit/RTN9a/key-unset-until-connected-0 it('RTN9a - connection.key is null before connected', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -91,6 +93,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN8b - Connection ID is unique per connection */ + // UTS: realtime/unit/RTN8b/id-unique-per-connection-0 it('RTN8b - connection.id is unique per client', function (done) { let connectionCount = 0; @@ -141,6 +144,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN9b - Connection key is unique per connection */ + // UTS: realtime/unit/RTN9b/key-unique-per-connection-0 it('RTN9b - connection.key is unique per client', function (done) { let connectionCount = 0; @@ -191,6 +195,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN8c - Connection ID is null in CLOSED state */ + // UTS: realtime/unit/RTN8c/id-null-after-closed-0 it('RTN8c - connection.id is null after close', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -237,6 +242,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN9c - Connection key is null in CLOSED state */ + // UTS: realtime/unit/RTN9c/key-null-after-closed-0 it('RTN9c - connection.key is null after close', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -283,6 +289,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN8c, RTN9c - ID and key null after FAILED */ + // UTS: realtime/unit/RTN8c/id-key-null-after-failed-1 it('RTN8c, RTN9c - id and key null in FAILED state', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -313,6 +320,7 @@ describe('uts/realtime/connection/connection_id_key', function () { /** * RTN8c, RTN9c - ID and key null in SUSPENDED state */ + // UTS: realtime/unit/RTN8c/id-key-null-after-suspended-2 it('RTN8c, RTN9c - id and key null in SUSPENDED state', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/connection/connection_open_failures.test.ts b/test/uts/realtime/unit/connection/connection_open_failures.test.ts similarity index 94% rename from test/uts/realtime/connection/connection_open_failures.test.ts rename to test/uts/realtime/unit/connection/connection_open_failures.test.ts index 0d1b92252..6b451bfb0 100644 --- a/test/uts/realtime/connection/connection_open_failures.test.ts +++ b/test/uts/realtime/unit/connection/connection_open_failures.test.ts @@ -6,9 +6,9 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; async function pumpTimers(clock: any, iterations = 30) { for (let i = 0; i < iterations; i++) { @@ -17,7 +17,7 @@ async function pumpTimers(clock: any, iterations = 30) { } } -describe('uts/realtime/connection/connection_open_failures', function () { +describe('uts/realtime/unit/connection/connection_open_failures', function () { afterEach(function () { restoreAll(); }); @@ -25,6 +25,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { /** * RTN14a - Invalid API key causes FAILED state */ + // UTS: realtime/unit/RTN14a/invalid-key-failed-0 it('RTN14a - invalid API key causes FAILED state', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -59,6 +60,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { /** * RTN14b - Token error with renewable token triggers renewal and retry */ + // UTS: realtime/unit/RTN14b/token-renewal-fails-1 it('RTN14b - token error with renewable token retries', function (done) { let connectionAttemptCount = 0; @@ -114,6 +116,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { * means to renew the token, the connection transitions to FAILED with * error code 40171. */ + // UTS: realtime/unit/RSA4a/token-error-no-renewal-0 it('RSA4a - token error without renewal causes FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -147,6 +150,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { * Note: ably-js connectingTimeout = webSocketConnectTimeout + realtimeRequestTimeout. * Both must be configured short for this test. */ + // UTS: realtime/unit/RTN14c/connection-timeout-0 it('RTN14c - connection timeout causes DISCONNECTED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -192,6 +196,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { /** * RTN14d - Retry after recoverable failure */ + // UTS: realtime/unit/RTN14d/retry-recoverable-failure-0 it('RTN14d - automatic retry after recoverable failure', async function () { let connectionAttemptCount = 0; @@ -248,6 +253,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { /** * RTN14e - DISCONNECTED → SUSPENDED after connectionStateTtl */ + // UTS: realtime/unit/RTN14e/disconnected-to-suspended-0 it('RTN14e - transitions to SUSPENDED after connectionStateTtl', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -290,6 +296,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { /** * RTN14f - SUSPENDED state retries and eventually connects */ + // UTS: realtime/unit/RTN14f/suspended-retries-indefinitely-0 it('RTN14f - SUSPENDED retries and connects', async function () { let connectionAttemptCount = 0; @@ -352,6 +359,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { * Per spec: ERROR ProtocolMessage with empty channel received during connection * opening (before CONNECTED) transitions connection to FAILED. */ + // UTS: realtime/unit/RTN14g/error-empty-channel-failed-0 it('RTN14g - ERROR with empty channel causes FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -392,6 +400,7 @@ describe('uts/realtime/connection/connection_open_failures', function () { * to another token error, then the connection transitions to DISCONNECTED and * Connection#errorReason is set. */ + // UTS: realtime/unit/RTN14b/token-error-with-renewal-0 it('RTN14b - token error with renewal failure causes DISCONNECTED', function (done) { let connectionAttemptCount = 0; diff --git a/test/uts/realtime/connection/connection_ping.test.ts b/test/uts/realtime/unit/connection/connection_ping.test.ts similarity index 94% rename from test/uts/realtime/connection/connection_ping.test.ts rename to test/uts/realtime/unit/connection/connection_ping.test.ts index b50958b1f..8deab4016 100644 --- a/test/uts/realtime/connection/connection_ping.test.ts +++ b/test/uts/realtime/unit/connection/connection_ping.test.ts @@ -6,9 +6,9 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; /** Helper: pump fake + real event loops */ async function pumpTimers(clock: any, iterations = 30) { @@ -18,7 +18,7 @@ async function pumpTimers(clock: any, iterations = 30) { } } -describe('uts/realtime/connection/connection_ping', function () { +describe('uts/realtime/unit/connection/connection_ping', function () { afterEach(function () { restoreAll(); }); @@ -26,6 +26,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13a - Ping sends HEARTBEAT and returns round-trip duration */ + // UTS: realtime/unit/RTN13a/ping-heartbeat-roundtrip-0 it('RTN13a - ping sends HEARTBEAT and returns duration', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -65,6 +66,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13e - HEARTBEAT includes random id for disambiguation */ + // UTS: realtime/unit/RTN13e/heartbeat-random-id-0 it('RTN13e - sent HEARTBEAT includes id', function (done) { let capturedId: string | null = null; @@ -107,6 +109,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13e - HEARTBEAT with no id is ignored as ping response */ + // UTS: realtime/unit/RTN13e/no-id-heartbeat-ignored-1 it('RTN13e - HEARTBEAT without id is ignored', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -146,6 +149,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13e - Multiple concurrent pings each get their own response */ + // UTS: realtime/unit/RTN13e/concurrent-pings-unique-ids-2 it('RTN13e - concurrent pings disambiguated by id', function (done) { const sentIds: string[] = []; @@ -189,6 +193,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13c - Ping times out if no HEARTBEAT response */ + // UTS: realtime/unit/RTN13c/deferred-ping-timeout-1 it('RTN13c - ping timeout', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -234,6 +239,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13b - Ping errors in INITIALIZED state */ + // UTS: realtime/unit/RTN13b/ping-error-initialized-0 it('RTN13b - ping errors in INITIALIZED', async function () { const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', @@ -256,6 +262,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13b - Ping errors in CLOSED state */ + // UTS: realtime/unit/RTN13b/ping-error-closed-2 it('RTN13b - ping errors in CLOSED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -297,6 +304,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13b - Ping errors in FAILED state */ + // UTS: realtime/unit/RTN13b/deferred-ping-error-failed-4 it('RTN13b - ping errors in FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -331,6 +339,7 @@ describe('uts/realtime/connection/connection_ping', function () { /** * RTN13b - Ping errors in SUSPENDED state */ + // UTS: realtime/unit/RTN13b/deferred-ping-error-suspended-5 it('RTN13b - ping errors in SUSPENDED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -382,6 +391,7 @@ describe('uts/realtime/connection/connection_ping', function () { * is called, the ping is deferred until the connection reaches a state * that can resolve it." */ + // UTS: realtime/unit/RTN13d/ping-deferred-connecting-0 it('RTN13d - ping deferred from CONNECTING until CONNECTED', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js rejects immediately; see #2203 const mock = new MockWebSocket({ @@ -430,6 +440,7 @@ describe('uts/realtime/connection/connection_ping', function () { * Note: ably-js doesn't defer ping(), but the client auto-reconnects * before ping() is called here (connectivity check succeeds immediately). */ + // UTS: realtime/unit/RTN13d/ping-deferred-disconnected-1 it('RTN13d - ping succeeds after auto-reconnect from DISCONNECTED', async function () { let connectionAttemptCount = 0; @@ -498,6 +509,7 @@ describe('uts/realtime/connection/connection_ping', function () { * * Note: ably-js rejects ping() immediately in non-connected states. */ + // UTS: realtime/unit/RTN13b/ping-error-failed-3 it('RTN13b+d - ping from CONNECTING rejects on FAILED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -538,6 +550,7 @@ describe('uts/realtime/connection/connection_ping', function () { * * Note: ably-js rejects ping() immediately in non-connected states. */ + // UTS: realtime/unit/RTN13b/deferred-ping-error-failed-4.1 it('RTN13b+d - ping from DISCONNECTED rejects', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -591,6 +604,7 @@ describe('uts/realtime/connection/connection_ping', function () { * * Note: ably-js rejects ping() immediately in non-connected states. */ + // UTS: realtime/unit/RTN13c/ping-timeout-0 it('RTN13c+d - ping from CONNECTING rejects immediately', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -643,6 +657,7 @@ describe('uts/realtime/connection/connection_ping', function () { * We listen on the connectionManager directly (which emits state * changes synchronously) to catch the CLOSING state and call ping(). */ + // UTS: realtime/unit/RTN13b/ping-error-suspended-1 it('RTN13b - ping errors in CLOSING', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/unit/connection/connection_recovery.test.ts b/test/uts/realtime/unit/connection/connection_recovery.test.ts new file mode 100644 index 000000000..2567b5a55 --- /dev/null +++ b/test/uts/realtime/unit/connection/connection_recovery.test.ts @@ -0,0 +1,609 @@ +/** + * UTS: Connection Recovery Tests (RTN16) + * + * Spec points: RTN16d, RTN16f, RTN16f1, RTN16g, RTN16g1, RTN16g2, RTN16i, RTN16j, RTN16k, RTN16l + * Source: specification/uts/realtime/unit/connection/connection_recovery_test.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { + Ably, + trackClient, + installMockWebSocket, + installMockHttp, + enableFakeTimers, + restoreAll, + flushAsync, +} from '../../../helpers'; + +describe('uts/realtime/unit/connection/connection_recovery', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN16g, RTN16g1 - createRecoveryKey returns string with connectionKey, msgSerial, + * and channel/channelSerial pairs (including unicode channel names) + */ + // UTS: realtime/unit/RTN16g/recovery-key-structure-0 + it('RTN16g, RTN16g1 - createRecoveryKey returns correct structure with unicode channel names', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-abc-123', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + // Respond to ATTACH requests with ATTACHED + if (msg.action === 10) { + // ATTACH + const channelSerials: Record = { + 'channel-alpha': 'serial-a-001', + 'channel-éàü-世界': 'serial-b-002', + }; + conn!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: channelSerials[msg.channel] || 'default-serial', + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Get two channels and attach them (including one with unicode name) + const channelA = client.channels.get('channel-alpha'); + const channelB = client.channels.get('channel-éàü-世界'); + + let attachedCount = 0; + const onAttached = () => { + attachedCount++; + if (attachedCount < 2) return; + + // Both channels attached — create recovery key + const recoveryKeyString = client.connection.createRecoveryKey(); + + // Recovery key is not null + expect(recoveryKeyString).to.not.be.null; + + // Deserialize the recovery key (JSON format) + const recoveryKey = JSON.parse(recoveryKeyString!); + + // Contains connectionKey + expect(recoveryKey.connectionKey).to.equal('key-abc-123'); + + // Contains msgSerial (starts at 0 since no messages were sent) + expect(recoveryKey.msgSerial).to.equal(0); + + // Contains channelSerials map with both channels + expect(recoveryKey.channelSerials).to.exist; + expect(recoveryKey.channelSerials['channel-alpha']).to.equal('serial-a-001'); + + // RTN16g1: Unicode channel name is correctly encoded in the serialized key + expect(recoveryKey.channelSerials['channel-éàü-世界']).to.equal('serial-b-002'); + + // Verify round-trip: re-serializing and deserializing preserves the unicode name + const reSerialized = JSON.stringify(recoveryKey); + const reParsed = JSON.parse(reSerialized); + expect(reParsed.channelSerials['channel-éàü-世界']).to.equal('serial-b-002'); + + done(); + }; + + channelA.once('attached', onAttached); + channelB.once('attached', onAttached); + + channelA.attach(); + channelB.attach(); + }); + + client.connect(); + }); + + /** + * RTN16g2 - createRecoveryKey returns null in inactive states and before first connect + */ + // UTS: realtime/unit/RTN16g2/recovery-key-null-inactive-0 + it('RTN16g2 - createRecoveryKey returns null before connect, in closing, and closed states', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 7) { + // CLOSE -> respond CLOSED + mock.active_connection!.send_to_client({ action: 8 }); // CLOSED + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Before connecting (INITIALIZED state, no connectionKey) + expect(client.connection.createRecoveryKey()).to.be.null; + + client.connection.once('connected', () => { + // Recovery key is available when CONNECTED + expect(client.connection.createRecoveryKey()).to.not.be.null; + + // Listen for closing state + client.connection.once('closing', () => { + expect(client.connection.createRecoveryKey()).to.be.null; + }); + + // Listen for closed state + client.connection.once('closed', () => { + expect(client.connection.createRecoveryKey()).to.be.null; + done(); + }); + + // Transition to CLOSING then CLOSED + client.connection.close(); + }); + + client.connect(); + }); + + /** + * RTN16g2 - createRecoveryKey returns null in FAILED state + */ + // UTS: realtime/unit/RTN16g2/recovery-key-null-inactive-0.1 + it('RTN16g2 - createRecoveryKey returns null in FAILED state', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-f', + connectionDetails: { + connectionKey: 'key-f', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Verify we have a recovery key while connected + expect(client.connection.createRecoveryKey()).to.not.be.null; + + client.connection.once('failed', () => { + expect(client.connection.createRecoveryKey()).to.be.null; + done(); + }); + + // Trigger FAILED via fatal ERROR + mock.active_connection!.send_to_client_and_close({ + action: 9, // ERROR + error: { code: 50000, statusCode: 500, message: 'Fatal error' }, + }); + }); + + client.connect(); + }); + + /** + * RTN16g2 - createRecoveryKey returns null in SUSPENDED state + */ + // UTS: realtime/unit/RTN16g2/recovery-key-null-inactive-0.2 + it('RTN16g2 - createRecoveryKey returns null in SUSPENDED state', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + if (connectionAttemptCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-s', + connectionDetails: { + connectionKey: 'key-s', + maxIdleInterval: 15000, + connectionStateTtl: 2000, + } as any, + }); + } else { + // All subsequent connections fail to force SUSPENDED + conn.respond_with_refused(); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP to prevent real network requests from connectivity checker + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, 'yes'), + }); + installMockHttp(httpMock); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + disconnectedRetryTimeout: 500, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + + // Pump to let initial connection succeed + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('connected'); + + // Simulate disconnect + mock.active_connection!.simulate_disconnect(); + + // Advance time until SUSPENDED (connectionStateTtl expires) + for (let i = 0; i < 10; i++) { + await clock.tickAsync(1500); + for (let j = 0; j < 30; j++) { + clock.tick(0); + await flushAsync(); + } + if (client.connection.state === 'suspended') break; + } + + expect(client.connection.state).to.equal('suspended'); + expect(client.connection.createRecoveryKey()).to.be.null; + }); + + /** + * RTN16k - recover option adds recover query param to WebSocket URL + * + * When instantiated with the `recover` client option, the library should add a + * `recover` querystring param to the first WebSocket request. After successful + * connection, subsequent reconnections use `resume` (not `recover`). + */ + // UTS: realtime/unit/RTN16k/recover-query-param-0 + it('RTN16k - recover option adds recover query param to first connection only', function (done) { + let connectionAttemptCount = 0; + + // Construct a valid recoveryKey + const recoveryKey = JSON.stringify({ + connectionKey: 'recovered-key-xyz', + msgSerial: 5, + channelSerials: {}, + }); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + // First connection: successful recovery + conn.respond_with_connected({ + connectionId: 'recovered-conn-id', + connectionDetails: { + connectionKey: 'new-key-after-recovery', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Subsequent connection: resume after disconnect + conn.respond_with_connected({ + connectionId: 'recovered-conn-id', + connectionDetails: { + connectionKey: 'resumed-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, 'yes'), + }); + installMockHttp(httpMock); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + recover: recoveryKey, + autoConnect: false, + useBinaryProtocol: false, + } as any); + trackClient(client); + + client.connection.once('connected', () => { + // First connection attempt includes recover param with connectionKey from recoveryKey + expect(mock.connect_attempts[0].url.searchParams.get('recover')).to.equal('recovered-key-xyz'); + + // First connection attempt does NOT include resume param + expect(mock.connect_attempts[0].url.searchParams.get('resume')).to.be.null; + + // Listen for second connection (resume after disconnect) + client.connection.on('connected', () => { + // Second connection attempt uses resume (not recover) + expect(mock.connect_attempts[1].url.searchParams.get('resume')).to.equal('new-key-after-recovery'); + expect(mock.connect_attempts[1].url.searchParams.get('recover')).to.be.null; + + done(); + }); + + // Simulate disconnect and reconnection + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN16f - recover option initializes msgSerial from recoveryKey + * + * When instantiated with the `recover` client option, the library should + * initialize its internal msgSerial counter to the msgSerial component of + * the recoveryKey. + */ + // UTS: realtime/unit/RTN16f/recover-initializes-msgserial-0 + it('RTN16f - recover option initializes msgSerial from recoveryKey', async function () { + const capturedMessages: any[] = []; + + // Construct a recoveryKey with msgSerial of 42 + const recoveryKey = JSON.stringify({ + connectionKey: 'old-key', + msgSerial: 42, + channelSerials: { + 'test-channel': 'ch-serial-1', + }, + }); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + + conn.respond_with_connected({ + connectionId: 'recovered-conn', + connectionDetails: { + connectionKey: 'new-key', + maxIdleInterval: 300000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + capturedMessages.push(msg); + + if (msg.action === 10) { + // ATTACH -> ATTACHED + conn!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: 'ch-serial-updated', + }); + } else if (msg.action === 15) { + // MESSAGE -> ACK + conn!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + recover: recoveryKey, + autoConnect: false, + useBinaryProtocol: false, + } as any); + trackClient(client); + + // Connect with recovery + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Attach the recovered channel + const channel = client.channels.get('test-channel'); + channel.attach(); + await new Promise((resolve) => channel.once('attached', resolve)); + + // Publish a message - the msgSerial should start from the recovered value (42) + await channel.publish('event', 'data'); + + // Find the MESSAGE frame sent by the client + const messageFrame = capturedMessages.find((m) => m.action === 15); + + // The first message published uses msgSerial from the recoveryKey + expect(messageFrame).to.exist; + expect(messageFrame.msgSerial).to.equal(42); + }); + + /** + * RTN16f1 - Malformed recoveryKey logs error and connects normally + * + * If the recovery key provided in the `recover` client option cannot be + * deserialized, the connection proceeds as if no `recover` option was provided. + */ + // UTS: realtime/unit/RTN16f1/malformed-recovery-key-0 + it('RTN16f1 - malformed recoveryKey connects normally without recover param', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'fresh-conn', + connectionDetails: { + connectionKey: 'fresh-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Use a malformed (non-JSON) recover string + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + recover: 'this-is-not-valid-json!!!', + autoConnect: false, + useBinaryProtocol: false, + } as any); + trackClient(client); + + client.connection.once('connected', () => { + // Connection succeeded normally + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.equal('fresh-conn'); + expect(client.connection.key).to.equal('fresh-key'); + + // No recover param was sent (malformed key was rejected) + expect(mock.connect_attempts[0].url.searchParams.get('recover')).to.be.null; + + // Also no resume param (this is a fresh connection) + expect(mock.connect_attempts[0].url.searchParams.get('resume')).to.be.null; + + // Only one connection attempt (normal connection, no retries) + expect(connectionAttemptCount).to.equal(1); + + done(); + }); + + client.connect(); + }); + + /** + * RTN16j - recover option instantiates channels from recoveryKey with correct channelSerials + * + * When instantiated with the `recover` client option, for every channel/channelSerial + * pair in the recoveryKey, the library instantiates a corresponding channel and sets + * its channelSerial (RTL15b). + */ + // UTS: realtime/unit/RTN16j/recover-channel-serials-0 + it('RTN16j - channels from recoveryKey are instantiated with channelSerials', function (done) { + const capturedMessages: any[] = []; + + // Construct a recoveryKey with multiple channels + const recoveryKey = JSON.stringify({ + connectionKey: 'old-key-abc', + msgSerial: 10, + channelSerials: { + 'channel-one': 'serial-1-abc', + 'channel-two': 'serial-2-def', + 'channel-üñîçöðé': 'serial-3-unicode', + }, + }); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'recovered-conn', + connectionDetails: { + connectionKey: 'new-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + capturedMessages.push(msg); + if (msg.action === 10) { + // ATTACH -> ATTACHED + conn!.send_to_client({ + action: 11, + channel: msg.channel, + channelSerial: msg.channel === 'channel-one' ? 'serial-1-abc-updated' : 'serial-updated', + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + recover: recoveryKey, + autoConnect: false, + useBinaryProtocol: false, + } as any); + trackClient(client); + + client.connection.once('connected', () => { + // RTN16j: Channels from the recoveryKey are instantiated + const channelOne = client.channels.get('channel-one'); + const channelTwo = client.channels.get('channel-two'); + const channelUnicode = client.channels.get('channel-üñîçöðé'); + + // Each channel has its channelSerial set from the recoveryKey + expect(channelOne.properties.channelSerial).to.equal('serial-1-abc'); + expect(channelTwo.properties.channelSerial).to.equal('serial-2-def'); + expect(channelUnicode.properties.channelSerial).to.equal('serial-3-unicode'); + + // RTN16i: Channels are NOT automatically attached — they should be in INITIALIZED state + expect(channelOne.state).to.equal('initialized'); + expect(channelTwo.state).to.equal('initialized'); + expect(channelUnicode.state).to.equal('initialized'); + + // When the user attaches, the ATTACH message should include the channelSerial + channelOne.once('attached', () => { + // Find the ATTACH frame sent for channel-one + const attachFrame = capturedMessages.find( + (m) => m.action === 10 && m.channel === 'channel-one', + ); + expect(attachFrame).to.exist; + expect(attachFrame.channelSerial).to.equal('serial-1-abc'); + + done(); + }); + + channelOne.attach(); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/error_reason.test.ts b/test/uts/realtime/unit/connection/error_reason.test.ts similarity index 93% rename from test/uts/realtime/connection/error_reason.test.ts rename to test/uts/realtime/unit/connection/error_reason.test.ts index 306329062..a780e21d7 100644 --- a/test/uts/realtime/connection/error_reason.test.ts +++ b/test/uts/realtime/unit/connection/error_reason.test.ts @@ -6,11 +6,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll } from '../../../helpers'; -describe('uts/realtime/connection/error_reason', function () { +describe('uts/realtime/unit/connection/error_reason', function () { afterEach(function () { restoreAll(); }); @@ -18,6 +18,7 @@ describe('uts/realtime/connection/error_reason', function () { /** * RTN25 - errorReason set on connection errors (FAILED state) */ + // UTS: realtime/unit/RTN25/error-reason-on-failed-0.1 it('RTN25 - errorReason set on fatal error', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -52,6 +53,7 @@ describe('uts/realtime/connection/error_reason', function () { /** * RTN25 - errorReason on DISCONNECTED state */ + // UTS: realtime/unit/RTN25/error-reason-disconnected-1 it('RTN25 - errorReason set on DISCONNECTED', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -87,6 +89,7 @@ describe('uts/realtime/connection/error_reason', function () { /** * RTN25 - errorReason on SUSPENDED state */ + // UTS: realtime/unit/RTN25/error-reason-suspended-2 it('RTN25 - errorReason set on SUSPENDED', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -133,6 +136,7 @@ describe('uts/realtime/connection/error_reason', function () { * Per RTN14b: token ERROR during connection, no means to renew → RSA4a applies. * Per RSA4a2: transition to FAILED with error code 40171. */ + // UTS: realtime/unit/RTN25/error-reason-token-error-3 it('RTN25 - errorReason on token error (non-renewable)', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -164,6 +168,7 @@ describe('uts/realtime/connection/error_reason', function () { /** * RTN25 - errorReason cleared on successful reconnection */ + // UTS: realtime/unit/RTN25/error-reason-cleared-on-connect-4 it('RTN25 - errorReason cleared on successful reconnect', function (done) { let connectionAttemptCount = 0; @@ -218,6 +223,7 @@ describe('uts/realtime/connection/error_reason', function () { /** * RTN25 - errorReason on protocol-level ERROR message */ + // UTS: realtime/unit/RTN25/error-reason-protocol-error-5 it('RTN25 - errorReason on protocol ERROR message', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -250,6 +256,7 @@ describe('uts/realtime/connection/error_reason', function () { /** * RTN25 - errorReason propagated to ConnectionStateChange events */ + // UTS: realtime/unit/RTN25/error-reason-in-state-change-6 it('RTN25 - errorReason in ConnectionStateChange', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -291,6 +298,7 @@ describe('uts/realtime/connection/error_reason', function () { * Connection#errorReason is set. This tests that errorReason captures the * token error details in this scenario. */ + // UTS: realtime/unit/RTN25/error-reason-on-failed-0 it('RTN25 - errorReason set on token error while connected (RTN15h1)', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/connection/fallback_hosts.test.ts b/test/uts/realtime/unit/connection/fallback_hosts.test.ts similarity index 95% rename from test/uts/realtime/connection/fallback_hosts.test.ts rename to test/uts/realtime/unit/connection/fallback_hosts.test.ts index 4e677bdc4..25c6179b2 100644 --- a/test/uts/realtime/connection/fallback_hosts.test.ts +++ b/test/uts/realtime/unit/connection/fallback_hosts.test.ts @@ -9,11 +9,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, restoreAll } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/realtime/connection/fallback_hosts', function () { +describe('uts/realtime/unit/connection/fallback_hosts', function () { afterEach(function () { restoreAll(); }); @@ -21,6 +21,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { /** * RTN17i - Always prefer primary domain first */ + // UTS: realtime/unit/RTN17i/prefer-primary-domain-0 it('RTN17i - primary domain tried first', function (done) { const connectionHosts: string[] = []; @@ -77,6 +78,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { /** * RTN17f - Network errors trigger fallback host usage */ + // UTS: realtime/unit/RTN17f/fallback-on-error-0 it('RTN17f - connection refused triggers fallback', function (done) { const connectionHosts: string[] = []; @@ -129,6 +131,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { /** * RTN17f1 - DISCONNECTED with 5xx triggers fallback */ + // UTS: realtime/unit/RTN17f1/disconnected-5xx-fallback-0 it('RTN17f1 - DISCONNECTED with 503 triggers fallback', function (done) { const connectionHosts: string[] = []; @@ -196,6 +199,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { * DISCONNECTED (not immediate error), then retries. We verify only the primary * host was tried and no fallback hosts were used. */ + // UTS: realtime/unit/RTN17g/empty-fallback-set-error-0 it('RTN17g - custom host with no fallbacks does not try fallbacks', function (done) { const connectionHosts: string[] = []; @@ -235,6 +239,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { /** * RTN17h - Default fallback hosts match spec (REC2) */ + // UTS: realtime/unit/RTN17h/fallback-domains-from-rec2-0 it('RTN17h - uses default fallback hosts from REC2', function (done) { const connectionHosts: string[] = []; @@ -287,6 +292,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { /** * RTN17j - Connectivity check before fallback */ + // UTS: realtime/unit/RTN17j/connectivity-check-before-fallback-0 it('RTN17j - connectivity check performed before fallback', function (done) { const connectionHosts: string[] = []; const mock = new MockWebSocket({ @@ -343,6 +349,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { * This test is inherently probabilistic. We run multiple iterations and check * that not all fallback host orders are identical. */ + // UTS: realtime/unit/RTN17j/fallback-random-order-1 it('RTN17j - fallback hosts tried in random order', function (done) { const fallbackOrders: string[][] = []; let iterationsCompleted = 0; @@ -413,6 +420,7 @@ describe('uts/realtime/connection/fallback_hosts', function () { * Spec: If the realtime client is connected to a fallback host endpoint, * HTTP requests should first be attempted to the same datacenter. */ + // UTS: realtime/unit/RTN17e/http-uses-same-fallback-0 it('RTN17e - HTTP requests use same fallback host as realtime connection', async function () { const connectionHosts: string[] = []; diff --git a/test/uts/realtime/unit/connection/forwards_compatibility.test.ts b/test/uts/realtime/unit/connection/forwards_compatibility.test.ts new file mode 100644 index 000000000..648cc653c --- /dev/null +++ b/test/uts/realtime/unit/connection/forwards_compatibility.test.ts @@ -0,0 +1,315 @@ +/** + * UTS: Forwards Compatibility Tests + * + * Spec points: RTF1, RSF1 + * Source: specification/uts/realtime/unit/connection/forwards_compatibility_test.md + * + * The Ably client library must apply the robustness principle to deserialization: + * - RTF1: ProtocolMessages must tolerate unrecognised attributes (ignored) and + * unknown enum values (handled gracefully). + * - RSF1: Messages must tolerate unrecognised attributes (ignored) and unknown + * enum values (ignored). + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../../helpers'; + +async function pumpTimers(clock: any, iterations = 30) { + for (let i = 0; i < iterations; i++) { + await flushAsync(); + } +} + +describe('uts/realtime/unit/connection/forwards_compatibility', function () { + afterEach(function () { + restoreAll(); + }); + + // --- RTF1: Unrecognised attributes on ProtocolMessage --- + + /** + * RTF1 - ProtocolMessage with unrecognised attributes is deserialized without error + * + * Tests that the client correctly processes a ProtocolMessage containing extra + * unknown fields that are not part of the current spec, without throwing errors. + * A MESSAGE with extra ProtocolMessage-level fields should still deliver to + * subscribers normally. + */ + // UTS: realtime/unit/RTF1/unrecognised-attributes-ignored-0 + it('RTF1 - ProtocolMessage with unrecognised attributes is deserialized without error', async function () { + const channelName = 'test-RTF1-extra-attrs'; + const receivedMessages: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { // ATTACH + conn!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await flushAsync(); + await flushAsync(); + await flushAsync(); + + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => { + receivedMessages.push(msg); + }); + channel.attach(); + await flushAsync(); + await flushAsync(); + await flushAsync(); + + expect(channel.state).to.equal('attached'); + + // Send a MESSAGE ProtocolMessage with extra unknown attributes. + // The raw JSON includes fields that don't exist in the current spec. + // Using ws._fireMessage to inject raw JSON with unknown fields. + mock.active_connection!.ws._fireMessage({ + action: 15, // MESSAGE + channel: channelName, + 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 + for (let i = 0; i < 20; i++) { + await flushAsync(); + if (receivedMessages.length >= 1) break; + } + + // Message was delivered successfully despite unknown fields + expect(receivedMessages.length).to.equal(1); + expect(receivedMessages[0].name).to.equal('test-event'); + expect(receivedMessages[0].data).to.equal('hello'); + + // Connection remains healthy + expect(client.connection.state).to.equal('connected'); + expect(channel.state).to.equal('attached'); + client.close(); + }); + + // --- RTF1: Unknown action enum value --- + + /** + * RTF1 - ProtocolMessage with unknown action enum value is handled gracefully + * + * 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. + */ + // UTS: realtime/unit/RTF1/unknown-action-handled-1 + it('RTF1 - ProtocolMessage with unknown action enum value is handled gracefully', async function () { + const stateChanges: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Record connection state changes to detect unexpected disconnections + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await flushAsync(); + await flushAsync(); + await flushAsync(); + + expect(client.connection.state).to.equal('connected'); + + // Send a ProtocolMessage with an unknown action value. + // Action 254 is not defined in the current spec. + mock.active_connection!.ws._fireMessage({ + action: 254, + channel: 'test-RTF1-unknown-action', + unknownPayload: 'future-feature-data', + }); + + // Send a normal HEARTBEAT to verify the connection is still processing messages + mock.active_connection!.send_to_client({ + action: 0, // HEARTBEAT + }); + + // Give the client time to process both messages + for (let i = 0; i < 10; i++) { + await flushAsync(); + } + + // Connection should still be CONNECTED - the unknown action was silently ignored + expect(client.connection.state).to.equal('connected'); + + // No unexpected state transitions occurred (only the initial connecting -> connected) + expect(stateChanges).to.deep.equal(['connecting', 'connected']); + + // Verify no disconnected or failed states appeared + expect(stateChanges).to.not.include('disconnected'); + expect(stateChanges).to.not.include('failed'); + + client.close(); + }); + + // --- RSF1: Unrecognised attributes on Message --- + + /** + * RSF1 - Message with unrecognised attributes is deserialized without error + * + * Tests that a Message containing extra unknown fields is delivered to + * subscribers without error, and the known fields are correctly parsed. + */ + // UTS: realtime/unit/RSF1/message-unrecognised-attrs-0 + it('RSF1 - Message with unrecognised attributes is deserialized without error', async function () { + const channelName = 'test-RSF1-extra-attrs'; + const receivedMessages: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { // ATTACH + conn!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await flushAsync(); + await flushAsync(); + await flushAsync(); + + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => { + receivedMessages.push(msg); + }); + channel.attach(); + await flushAsync(); + await flushAsync(); + await flushAsync(); + + expect(channel.state).to.equal('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.active_connection!.ws._fireMessage({ + action: 15, // MESSAGE + channel: channelName, + 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 + for (let i = 0; i < 20; i++) { + await flushAsync(); + if (receivedMessages.length >= 2) break; + } + + // Both messages were delivered successfully despite unknown fields + expect(receivedMessages.length).to.equal(2); + + // Known fields were correctly parsed + expect(receivedMessages[0].name).to.equal('event-1'); + expect(receivedMessages[0].data).to.equal('payload-1'); + + expect(receivedMessages[1].name).to.equal('event-2'); + expect(receivedMessages[1].data).to.equal('payload-2'); + + // Connection and channel remain healthy + expect(client.connection.state).to.equal('connected'); + expect(channel.state).to.equal('attached'); + client.close(); + }); +}); diff --git a/test/uts/realtime/connection/heartbeat.test.ts b/test/uts/realtime/unit/connection/heartbeat.test.ts similarity index 87% rename from test/uts/realtime/connection/heartbeat.test.ts rename to test/uts/realtime/unit/connection/heartbeat.test.ts index 86cb5edbe..71d1ad9e1 100644 --- a/test/uts/realtime/connection/heartbeat.test.ts +++ b/test/uts/realtime/unit/connection/heartbeat.test.ts @@ -13,9 +13,9 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, Platform, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, Platform, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; async function pumpTimers(clock: any, iterations = 30) { for (let i = 0; i < iterations; i++) { @@ -24,7 +24,7 @@ async function pumpTimers(clock: any, iterations = 30) { } } -describe('uts/realtime/connection/heartbeat', function () { +describe('uts/realtime/unit/connection/heartbeat', function () { afterEach(function () { restoreAll(); }); @@ -38,6 +38,7 @@ describe('uts/realtime/connection/heartbeat', function () { * (useProtocolHeartbeats=true), the client sends heartbeats=true * in the connection URL to request HEARTBEAT protocol messages. */ + // UTS: realtime/unit/RTN23a/heartbeats-true-query-param-0 it('RTN23a - heartbeats=true in connection URL when ping frames not observable', function (done) { const savedUseProtocolHeartbeats = Platform.Config.useProtocolHeartbeats; Platform.Config.useProtocolHeartbeats = true; @@ -71,6 +72,7 @@ describe('uts/realtime/connection/heartbeat', function () { * ably-js Node.js can observe ping frames via ws library's 'ping' event, * so it sends heartbeats=false in the connection URL. */ + // UTS: realtime/unit/RTN23b/heartbeats-false-query-param-0 it('RTN23b - heartbeats=false in connection URL', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -100,6 +102,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23a/b - Disconnect after maxIdleInterval + realtimeRequestTimeout */ + // UTS: realtime/unit/RTN23a/idle-timeout-reconnect-1 it('RTN23a - disconnect after idle timeout', async function () { let connectionAttemptCount = 0; const stateChanges: string[] = []; @@ -168,6 +171,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23a - HEARTBEAT protocol message resets idle timer */ + // UTS: realtime/unit/RTN23a/heartbeat-resets-timer-2 it('RTN23a - HEARTBEAT resets idle timer', async function () { let connectionAttemptCount = 0; @@ -237,6 +241,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23a - Any protocol message resets idle timer */ + // UTS: realtime/unit/RTN23a/any-message-resets-timer-3 it('RTN23a - any message resets idle timer', async function () { let connectionAttemptCount = 0; @@ -305,6 +310,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23a - Heartbeat timeout triggers immediate reconnection */ + // UTS: realtime/unit/RTN23a/timeout-triggers-reconnect-4 it('RTN23a - timeout triggers reconnection with state sequence', async function () { let connectionAttemptCount = 0; const stateChanges: string[] = []; @@ -365,6 +371,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23a - Reconnection after timeout uses resume */ + // UTS: realtime/unit/RTN23a/reconnect-uses-resume-5 it('RTN23a - reconnection after timeout uses resume', async function () { let connectionAttemptCount = 0; @@ -424,6 +431,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23b - Disconnect after idle timeout (no ping frames sent) */ + // UTS: realtime/unit/RTN23b/multiple-pings-keep-alive-6 it('RTN23b - disconnect when no ping frames received', async function () { let connectionAttemptCount = 0; const stateChanges: string[] = []; @@ -482,6 +490,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23b - Ping frame resets idle timer */ + // UTS: realtime/unit/RTN23b/ping-frame-resets-timer-2 it('RTN23b - ping frame resets idle timer', async function () { let connectionAttemptCount = 0; @@ -549,6 +558,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23b - Protocol messages also reset timer (not just ping frames) */ + // UTS: realtime/unit/RTN23b/any-message-resets-timer-3 it('RTN23b - protocol message resets idle timer', async function () { let connectionAttemptCount = 0; @@ -637,6 +647,7 @@ describe('uts/realtime/connection/heartbeat', function () { /** * RTN23b - Ping frame timeout triggers immediate reconnection with resume */ + // UTS: realtime/unit/RTN23b/timeout-triggers-reconnect-4 it('RTN23b - timeout triggers reconnection with resume', async function () { let connectionAttemptCount = 0; const stateChanges: string[] = []; @@ -694,9 +705,76 @@ describe('uts/realtime/connection/heartbeat', function () { client.close(); }); + /** + * RTN23b - Reconnect after ping timeout uses resume + * + * When a connection drops due to ping frame timeout (no activity within + * maxIdleInterval + realtimeRequestTimeout), the reconnection attempt + * must include the resume query parameter set to the previous connection's + * connectionKey, enabling the server to resume the connection. + */ + // UTS: realtime/unit/RTN23b/reconnect-uses-resume-5 + it('RTN23b - reconnect after ping timeout uses resume', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `connection-id-${connectionAttemptCount}`, + connectionDetails: { + connectionKey: `connection-key-${connectionAttemptCount}`, + maxIdleInterval: 2000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, 'yes'), + }); + installMockHttp(httpMock); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + realtimeRequestTimeout: 1000, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + expect(connectionAttemptCount).to.equal(1); + + // Advance past ping timeout (maxIdleInterval + realtimeRequestTimeout = 3000ms) + await clock.tickAsync(3100); + await pumpTimers(clock); + + expect(connectionAttemptCount).to.equal(2); + + // First connection should not have resume + const firstUrl = mock.connect_attempts[0].url; + expect(firstUrl.searchParams.has('resume')).to.be.false; + + // Second connection should include resume with first connectionKey + const secondUrl = mock.connect_attempts[1].url; + expect(secondUrl.searchParams.get('resume')).to.equal('connection-key-1'); + client.close(); + }); + /** * RTN23b - Multiple ping frames keep connection alive */ + // UTS: realtime/unit/RTN23b/idle-timeout-reconnect-1 it('RTN23b - regular ping frames prevent timeout', async function () { let connectionAttemptCount = 0; diff --git a/test/uts/realtime/unit/connection/network_change.test.ts b/test/uts/realtime/unit/connection/network_change.test.ts new file mode 100644 index 000000000..b3b4a571b --- /dev/null +++ b/test/uts/realtime/unit/connection/network_change.test.ts @@ -0,0 +1,73 @@ +/** + * UTS: Network Change Tests + * + * Spec points: RTN20, RTN20a, RTN20b, RTN20c + * Source: specification/uts/realtime/unit/connection/network_change_test.md + * + * RTN20 defines how the client should respond to OS-level network connectivity + * change events. The spec begins with "When the client library can subscribe to + * OS events for network/internet connectivity changes" -- this means the feature + * is optional for platforms where network monitoring is not feasible. + * + * ably-js Node.js does not subscribe to OS network change events. The RTN20 + * functionality is browser-only (using navigator.onLine and online/offline + * window events). Since these tests run in Node.js, all RTN20 tests are + * marked as pending. + */ + +import { expect } from 'chai'; + +describe('uts/realtime/unit/connection/network_change', function () { + + /** + * RTN20a - Network loss while CONNECTED triggers immediate DISCONNECTED transition + * + * When CONNECTED, if the OS indicates that the underlying internet connection + * is no longer available, the client should immediately transition to DISCONNECTED. + */ + // UTS: realtime/unit/RTN20a/network-loss-connected-disconnects-0 + it('RTN20a - network loss while connected triggers disconnected', function () { + // ably-js Node.js does not subscribe to OS network change events (RTN20 is browser-only). + // In the browser, ably-js uses window.addEventListener('online'/'offline') events, + // which are not available in Node.js. + this.skip(); + }); + + /** + * RTN20a - Network loss while CONNECTING triggers DISCONNECTED transition + * + * When CONNECTING, if the OS indicates that the underlying internet connection + * is no longer available, the client should immediately transition to DISCONNECTED. + */ + // UTS: realtime/unit/RTN20a/network-loss-connecting-disconnects-1 + it('RTN20a - network loss while connecting triggers disconnected', function () { + // ably-js Node.js does not subscribe to OS network change events (RTN20 is browser-only). + this.skip(); + }); + + /** + * RTN20b - Network available while DISCONNECTED triggers immediate connect attempt + * + * When DISCONNECTED, if the OS indicates that the underlying internet connection + * is now available, the client should immediately attempt to connect, bypassing + * the disconnectedRetryTimeout timer. + */ + // UTS: realtime/unit/RTN20b/network-available-disconnected-connects-0 + it('RTN20b - network available while disconnected triggers immediate connect', function () { + // ably-js Node.js does not subscribe to OS network change events (RTN20 is browser-only). + this.skip(); + }); + + /** + * RTN20c - Network available while CONNECTING restarts the connection attempt + * + * When CONNECTING, if the OS indicates that the underlying internet connection + * is now available, the client should restart (abandon and retry) the pending + * connection attempt. + */ + // UTS: realtime/unit/RTN20c/network-available-connecting-restarts-0 + it('RTN20c - network available while connecting restarts connection attempt', function () { + // ably-js Node.js does not subscribe to OS network change events (RTN20 is browser-only). + this.skip(); + }); +}); diff --git a/test/uts/realtime/connection/server_initiated_reauth.test.ts b/test/uts/realtime/unit/connection/server_initiated_reauth.test.ts similarity index 94% rename from test/uts/realtime/connection/server_initiated_reauth.test.ts rename to test/uts/realtime/unit/connection/server_initiated_reauth.test.ts index 835d5c965..6b8ec67c5 100644 --- a/test/uts/realtime/connection/server_initiated_reauth.test.ts +++ b/test/uts/realtime/unit/connection/server_initiated_reauth.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, trackClient, installMockWebSocket, restoreAll } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll } from '../../../helpers'; -describe('uts/realtime/connection/server_initiated_reauth', function () { +describe('uts/realtime/unit/connection/server_initiated_reauth', function () { afterEach(function () { restoreAll(); }); @@ -17,6 +17,7 @@ describe('uts/realtime/connection/server_initiated_reauth', function () { /** * RTN22 - Server sends AUTH, client re-authenticates */ + // UTS: realtime/unit/RTN22/server-auth-triggers-reauth-0 it('RTN22 - server AUTH triggers client reauth', function (done) { let authCallbackCount = 0; const capturedAuthMessages: any[] = []; @@ -95,6 +96,7 @@ describe('uts/realtime/connection/server_initiated_reauth', function () { /** * RTN22 - Connection remains CONNECTED during server-initiated reauth */ + // UTS: realtime/unit/RTN22/stays-connected-during-reauth-1 it('RTN22 - connection stays CONNECTED during reauth', function (done) { let authCallbackCount = 0; @@ -167,6 +169,7 @@ describe('uts/realtime/connection/server_initiated_reauth', function () { /** * RTN22a - Forced disconnect on reauth failure */ + // UTS: realtime/unit/RTN22a/forced-disconnect-reauth-failure-0 it('RTN22a - forced disconnect with token error', function (done) { let authCallbackCount = 0; diff --git a/test/uts/realtime/connection/update_events.test.ts b/test/uts/realtime/unit/connection/update_events.test.ts similarity index 83% rename from test/uts/realtime/connection/update_events.test.ts rename to test/uts/realtime/unit/connection/update_events.test.ts index db843dc84..a704a8cc6 100644 --- a/test/uts/realtime/connection/update_events.test.ts +++ b/test/uts/realtime/unit/connection/update_events.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, trackClient, installMockWebSocket, restoreAll } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll } from '../../../helpers'; -describe('uts/realtime/connection/update_events', function () { +describe('uts/realtime/unit/connection/update_events', function () { let mock: MockWebSocket; afterEach(function () { @@ -49,6 +49,7 @@ describe('uts/realtime/connection/update_events', function () { /** * RTN24 - CONNECTED while already CONNECTED emits UPDATE event, not CONNECTED */ + // UTS: realtime/unit/RTN24/connected-emits-update-0 it('RTN24 - CONNECTED while connected emits UPDATE not state change', function (done) { setupConnectedClient((client) => { const connectedEvents: any[] = []; @@ -84,6 +85,7 @@ describe('uts/realtime/connection/update_events', function () { /** * RTN24 - UPDATE event with error reason */ + // UTS: realtime/unit/RTN24/update-event-with-error-1 it('RTN24 - UPDATE event carries error reason', function (done) { setupConnectedClient((client) => { client.connection.on('update', (change: any) => { @@ -117,9 +119,14 @@ describe('uts/realtime/connection/update_events', function () { /** * RTN24 - ConnectionDetails override + * + * connectionId is a top-level ProtocolMessage field, NOT inside + * connectionDetails, so RTN24's "connectionDetails must override stored + * details" does not apply to it. connection.id and connection.key stay + * the same; only internal connectionDetails fields are overridden. */ - it('RTN24 - ConnectionDetails updated on new CONNECTED message', async function () { - if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js doesn't update connection.id on subsequent CONNECTED + // UTS: realtime/unit/RTN24/connection-details-override-2 + it('RTN24 - ConnectionDetails overridden, connection.id unchanged', async function () { mock = new MockWebSocket({ onConnectionAttempt: (conn) => { mock.active_connection = conn; @@ -152,12 +159,13 @@ describe('uts/realtime/connection/update_events', function () { client.connection.once('update', (change: any) => resolve(change)), ); + // Server sends CONNECTED with different connectionDetails but same + // connectionId (the server never changes it for an in-progress connection) mock.active_connection!.send_to_client({ action: 4, // CONNECTED - connectionId: 'connection-id-2', - connectionKey: 'connection-key-2', + connectionId: 'connection-id-1', connectionDetails: { - connectionKey: 'connection-key-2', + connectionKey: 'connection-key-1', maxIdleInterval: 20000, connectionStateTtl: 120000, maxMessageSize: 32768, @@ -167,9 +175,10 @@ describe('uts/realtime/connection/update_events', function () { await updatePromise; + // connection.id unchanged (not inside connectionDetails) expect(client.connection.state).to.equal('connected'); - expect(client.connection.id).to.equal('connection-id-2'); - expect(client.connection.key).to.equal('connection-key-2'); + expect(client.connection.id).to.equal('connection-id-1'); + expect(client.connection.key).to.equal('connection-key-1'); client.close(); }); @@ -177,6 +186,7 @@ describe('uts/realtime/connection/update_events', function () { /** * RTN24 - No duplicate CONNECTED event */ + // UTS: realtime/unit/RTN24/no-duplicate-connected-event-3 it('RTN24 - no duplicate CONNECTED state events', function (done) { setupConnectedClient((client) => { const connectedEvents: any[] = []; diff --git a/test/uts/realtime/connection/when_state.test.ts b/test/uts/realtime/unit/connection/when_state.test.ts similarity index 93% rename from test/uts/realtime/connection/when_state.test.ts rename to test/uts/realtime/unit/connection/when_state.test.ts index 21547d0ee..ce73f72e1 100644 --- a/test/uts/realtime/connection/when_state.test.ts +++ b/test/uts/realtime/unit/connection/when_state.test.ts @@ -10,11 +10,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, trackClient, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/connection/when_state', function () { +describe('uts/realtime/unit/connection/when_state', function () { afterEach(function () { restoreAll(); }); @@ -22,6 +22,7 @@ describe('uts/realtime/connection/when_state', function () { /** * RTN26a - whenState resolves immediately if already in state */ + // UTS: realtime/unit/RTN26a/immediate-callback-current-state-0 it('RTN26a - whenState resolves immediately for current state', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -55,6 +56,7 @@ describe('uts/realtime/connection/when_state', function () { /** * RTN26b - whenState waits for state if not already in it */ + // UTS: realtime/unit/RTN26b/deferred-callback-future-state-0 it('RTN26b - whenState waits for target state', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -89,6 +91,7 @@ describe('uts/realtime/connection/when_state', function () { /** * RTN26b - whenState only fires once */ + // UTS: realtime/unit/RTN26b/fires-only-once-1 it('RTN26b - whenState only fires once across reconnection', async function () { let connectionAttemptCount = 0; @@ -172,6 +175,7 @@ describe('uts/realtime/connection/when_state', function () { /** * RTN26a - Multiple whenState calls for same state */ + // UTS: realtime/unit/RTN26a/multiple-whenstate-calls-1 it('RTN26a - multiple whenState calls all resolve', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -203,6 +207,7 @@ describe('uts/realtime/connection/when_state', function () { /** * RTN26a - whenState does NOT fire for already-passed state */ + // UTS: realtime/unit/RTN26a/no-fire-for-past-state-2 it('RTN26a - whenState does not fire for past state', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -234,6 +239,7 @@ describe('uts/realtime/connection/when_state', function () { /** * RTN26 - whenState with different states */ + // UTS: realtime/unit/RTN26/whenstate-different-states-0 it('RTN26 - whenState works across state transitions', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -284,6 +290,7 @@ describe('uts/realtime/connection/when_state', function () { * Tests that whenState registered for 'closed' before closing the client * resolves with a ConnectionStateChange when the client transitions to closed. */ + // UTS: realtime/unit/RTN26b/deferred-callback-future-state-0.1 it('RTN26b - whenState waits for closed state', function (done) { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/presence/local_presence_map.test.ts b/test/uts/realtime/unit/presence/local_presence_map.test.ts similarity index 93% rename from test/uts/realtime/presence/local_presence_map.test.ts rename to test/uts/realtime/unit/presence/local_presence_map.test.ts index a439fab9b..86f7ab6ef 100644 --- a/test/uts/realtime/presence/local_presence_map.test.ts +++ b/test/uts/realtime/unit/presence/local_presence_map.test.ts @@ -15,9 +15,9 @@ */ import { expect } from 'chai'; -import { PresenceMap } from '../../../../src/common/lib/client/presencemap'; -import PresenceMessage from '../../../../src/common/lib/types/presencemessage'; -import Logger from '../../../../src/common/lib/util/logger'; +import { PresenceMap } from '../../../../../src/common/lib/client/presencemap'; +import PresenceMessage from '../../../../../src/common/lib/types/presencemessage'; +import Logger from '../../../../../src/common/lib/util/logger'; /** * Create a minimal mock RealtimePresence that satisfies PresenceMap's constructor. @@ -64,7 +64,7 @@ function createLocalPresenceMap(): PresenceMap { return new PresenceMap(mockPresence, (item) => item.clientId!); } -describe('uts/realtime/presence/local_presence_map', function () { +describe('uts/realtime/unit/presence/local_presence_map', function () { /** * RTP17h - Keyed by clientId, not memberKey * @@ -72,6 +72,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * must be keyed only by clientId. A second put for the same clientId but * different connectionId overwrites the first. */ + // UTS: realtime/unit/RTP17h/keyed-by-clientid-0 it('RTP17h - keyed by clientId, not memberKey', function () { const map = createLocalPresenceMap(); @@ -110,6 +111,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * Any ENTER event with a connectionId matching the current client's * connectionId should be applied to the RTP17 presence map. */ + // UTS: realtime/unit/RTP17b/enter-adds-to-map-0 it('RTP17b - ENTER adds to map', function () { const map = createLocalPresenceMap(); @@ -137,6 +139,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * * ENTER and UPDATE are interchangeable -- both add a member to the map. */ + // UTS: realtime/unit/RTP17b/update-adds-to-map-1 it('RTP17b - UPDATE with no prior entry adds to map', function () { const map = createLocalPresenceMap(); @@ -162,6 +165,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * * A second ENTER for the same clientId overwrites the first. */ + // UTS: realtime/unit/RTP17b/enter-overwrites-enter-2 it('RTP17b - ENTER after ENTER overwrites', function () { const map = createLocalPresenceMap(); @@ -194,6 +198,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * * UPDATE overwrites a prior ENTER for the same clientId. */ + // UTS: realtime/unit/RTP17b/update-overwrites-enter-3 it('RTP17b - UPDATE after ENTER overwrites', function () { const map = createLocalPresenceMap(); @@ -226,6 +231,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * * Any PRESENT event with a matching connectionId should be applied. */ + // UTS: realtime/unit/RTP17b/present-adds-to-map-4 it('RTP17b - PRESENT adds to map', function () { const map = createLocalPresenceMap(); @@ -257,6 +263,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * removes. The filtering of synthesized leaves must be done by the caller. * This test verifies that remove() works correctly for a non-synthesized leave. */ + // UTS: realtime/unit/RTP17b/non-synthesized-leave-removes-5 it('RTP17b - non-synthesized LEAVE removes from map', function () { const map = createLocalPresenceMap(); @@ -301,6 +308,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * synthesized leave -- it will use timestamp comparison (RTP2b1) since the * connectionId is not a prefix of the id. */ + // UTS: realtime/unit/RTP17b/synthesized-leave-ignored-6 it('RTP17b - synthesized LEAVE behavior', function () { const map = createLocalPresenceMap(); @@ -340,6 +348,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * * The local presence map can contain multiple members with different clientIds. */ + // UTS: realtime/unit/RTP17/multiple-clientids-coexist-0 it('RTP17 - multiple clientIds coexist', function () { const map = createLocalPresenceMap(); @@ -359,6 +368,7 @@ describe('uts/realtime/presence/local_presence_map', function () { /** * RTP17 - Remove one of multiple members */ + // UTS: realtime/unit/RTP17/remove-one-of-multiple-1 it('RTP17 - remove one of multiple members', function () { const map = createLocalPresenceMap(); @@ -378,6 +388,7 @@ describe('uts/realtime/presence/local_presence_map', function () { * When the channel enters DETACHED or FAILED state, the internal PresenceMap * is cleared. */ + // UTS: realtime/unit/RTP17/clear-resets-state-2 it('RTP5a - clear() resets all state', function () { const map = createLocalPresenceMap(); @@ -396,6 +407,7 @@ describe('uts/realtime/presence/local_presence_map', function () { /** * RTP17 - Get returns undefined for unknown clientId */ + // UTS: realtime/unit/RTP17/get-null-unknown-clientid-3 it('RTP17 - get returns undefined for unknown clientId', function () { const map = createLocalPresenceMap(); @@ -407,6 +419,7 @@ describe('uts/realtime/presence/local_presence_map', function () { /** * RTP17 - Remove for unknown clientId is a no-op */ + // UTS: realtime/unit/RTP17/remove-unknown-noop-4 it('RTP17 - remove for unknown clientId is a no-op', function () { const map = createLocalPresenceMap(); diff --git a/test/uts/realtime/presence/presence_map.test.ts b/test/uts/realtime/unit/presence/presence_map.test.ts similarity index 93% rename from test/uts/realtime/presence/presence_map.test.ts rename to test/uts/realtime/unit/presence/presence_map.test.ts index 023e818ca..1953ba101 100644 --- a/test/uts/realtime/presence/presence_map.test.ts +++ b/test/uts/realtime/unit/presence/presence_map.test.ts @@ -17,9 +17,9 @@ */ import { expect } from 'chai'; -import { PresenceMap } from '../../../../src/common/lib/client/presencemap'; -import PresenceMessage from '../../../../src/common/lib/types/presencemessage'; -import Logger from '../../../../src/common/lib/util/logger'; +import { PresenceMap } from '../../../../../src/common/lib/client/presencemap'; +import PresenceMessage from '../../../../../src/common/lib/types/presencemessage'; +import Logger from '../../../../../src/common/lib/util/logger'; /** * Create a minimal mock RealtimePresence that satisfies PresenceMap's constructor. @@ -67,7 +67,7 @@ function createPresenceMap(): PresenceMap { return new PresenceMap(mockPresence, (item) => item.connectionId + ':' + item.clientId); } -describe('uts/realtime/presence/presence_map', function () { +describe('uts/realtime/unit/presence/presence_map', function () { /** * RTP2 - Basic put and get @@ -75,6 +75,7 @@ describe('uts/realtime/presence/presence_map', function () { * Use a PresenceMap to maintain a list of members present on a channel, * a map of memberKeys to presence messages. */ + // UTS: realtime/unit/RTP2/basic-put-and-get-0 it('RTP2 - basic put and get', function () { const map = createPresenceMap(); @@ -99,6 +100,7 @@ describe('uts/realtime/presence/presence_map', function () { * When an ENTER, UPDATE, or PRESENT message is received, add to the * presence map with action set to PRESENT. */ + // UTS: realtime/unit/RTP2d2/enter-stored-as-present-0 it('RTP2d2 - ENTER stored as PRESENT', function () { const map = createPresenceMap(); @@ -122,6 +124,7 @@ describe('uts/realtime/presence/presence_map', function () { * * UPDATE messages are also stored with action PRESENT. */ + // UTS: realtime/unit/RTP2d2/update-stored-as-present-1 it('RTP2d2 - UPDATE stored as PRESENT', function () { const map = createPresenceMap(); @@ -155,6 +158,7 @@ describe('uts/realtime/presence/presence_map', function () { * * PRESENT messages (from SYNC) are stored with action PRESENT. */ + // UTS: realtime/unit/RTP2d2/present-stored-as-present-2 it('RTP2d2 - PRESENT stored as PRESENT', function () { const map = createPresenceMap(); @@ -183,6 +187,7 @@ describe('uts/realtime/presence/presence_map', function () { * higher level (RealtimePresence), not inside PresenceMap.put(). * This test verifies the ably-js behavior: put() returns true for accepted messages. */ + // UTS: realtime/unit/RTP2d1/put-returns-original-action-0 it('RTP2d1 - put returns true for accepted messages', function () { const map = createPresenceMap(); @@ -214,6 +219,7 @@ describe('uts/realtime/presence/presence_map', function () { * When a LEAVE message is received and SYNC is NOT in progress, * emit LEAVE and delete from presence map. */ + // UTS: realtime/unit/RTP2h1/leave-outside-sync-removes-0 it('RTP2h1 - LEAVE outside sync removes member', function () { const map = createPresenceMap(); @@ -249,6 +255,7 @@ describe('uts/realtime/presence/presence_map', function () { * If there is no matching memberKey in the map, there is nothing to remove. * In ably-js, remove() returns false when no existing item is found. */ + // UTS: realtime/unit/RTP2h1/leave-nonexistent-returns-null-1 it('RTP2h1 - LEAVE for non-existent member returns false', function () { const map = createPresenceMap(); @@ -274,6 +281,7 @@ describe('uts/realtime/presence/presence_map', function () { * (i.e. remove returns null). In ably-js, the return is boolean indicating * whether an existing member was found. */ + // UTS: realtime/unit/RTP2h2a/leave-during-sync-stores-absent-0 it('RTP2h2a - LEAVE during sync stores as ABSENT', function () { const map = createPresenceMap(); @@ -314,6 +322,7 @@ describe('uts/realtime/presence/presence_map', function () { * Additionally, residual members (present at start of sync but not seen during sync) * are also removed. */ + // UTS: realtime/unit/RTP2h2b/absent-deleted-on-endsync-0 it('RTP2h2b - ABSENT members deleted on endSync', function () { const map = createPresenceMap(); @@ -357,6 +366,7 @@ describe('uts/realtime/presence/presence_map', function () { * split the id into connectionId:msgSerial:index and compare msgSerial * then index numerically. Larger values are newer. */ + // UTS: realtime/unit/RTP2b2/newness-by-msgserial-index-0 it('RTP2b2 - newness comparison by id (msgSerial:index)', function () { const map = createPresenceMap(); @@ -404,6 +414,7 @@ describe('uts/realtime/presence/presence_map', function () { * * When msgSerial values are equal, compare by index. */ + // UTS: realtime/unit/RTP2b2/newness-by-index-same-serial-1 it('RTP2b2 - newness comparison by index when msgSerial equal', function () { const map = createPresenceMap(); @@ -448,6 +459,7 @@ describe('uts/realtime/presence/presence_map', function () { * of its id, compare by timestamp. This handles "synthesized leave" events * where the server generates a LEAVE on behalf of a disconnected client. */ + // UTS: realtime/unit/RTP2b1/newness-by-timestamp-0 it('RTP2b1 - newness comparison by timestamp (synthesized leave)', function () { const map = createPresenceMap(); @@ -480,6 +492,7 @@ describe('uts/realtime/presence/presence_map', function () { * * When comparing by timestamp, an older synthesized leave is rejected. */ + // UTS: realtime/unit/RTP2b1/older-synth-leave-rejected-1 it('RTP2b1 - synthesized leave rejected when older by timestamp', function () { const map = createPresenceMap(); @@ -512,6 +525,7 @@ describe('uts/realtime/presence/presence_map', function () { * * If timestamps are equal, the newly-incoming message is considered newer. */ + // UTS: realtime/unit/RTP2b1a/equal-timestamps-incoming-wins-0 it('RTP2b1a - equal timestamps: incoming message is newer', function () { const map = createPresenceMap(); @@ -544,6 +558,7 @@ describe('uts/realtime/presence/presence_map', function () { * Presence events from a SYNC must be compared for newness * the same way as PRESENCE messages. */ + // UTS: realtime/unit/RTP2c/sync-uses-same-newness-0 it('RTP2c - SYNC messages use same newness comparison', function () { const map = createPresenceMap(); @@ -589,6 +604,7 @@ describe('uts/realtime/presence/presence_map', function () { * * The presence map maintains multiple members with different memberKeys. */ + // UTS: realtime/unit/RTP2/multiple-members-coexist-1 it('RTP2 - multiple members coexist', function () { const map = createPresenceMap(); @@ -608,6 +624,7 @@ describe('uts/realtime/presence/presence_map', function () { * * The values() method returns only PRESENT members. */ + // UTS: realtime/unit/RTP2/values-excludes-absent-2 it('RTP2 - values() excludes ABSENT members', function () { const map = createPresenceMap(); @@ -632,6 +649,7 @@ describe('uts/realtime/presence/presence_map', function () { * * Verifies that clear() removes all members and resets sync state. */ + // UTS: realtime/unit/RTP2/clear-resets-state-3.1 it('clear() resets all state', function () { const map = createPresenceMap(); @@ -652,6 +670,7 @@ describe('uts/realtime/presence/presence_map', function () { * treated as residual and removed when sync completes. The PresenceMap * calls _synthesizeLeaves with these residual members. */ + // UTS: realtime/unit/RTP2/multiple-members-coexist-1.1 it('RTP2 - residual members removed on endSync', function () { const map = createPresenceMap(); @@ -691,6 +710,7 @@ describe('uts/realtime/presence/presence_map', function () { * If they are not re-confirmed via put() during sync, they are removed * on endSync(). */ + // UTS: realtime/unit/RTP2/multiple-members-coexist-1.2 it('RTP2 - startSync marks all current members as residual', function () { const map = createPresenceMap(); @@ -717,6 +737,7 @@ describe('uts/realtime/presence/presence_map', function () { * When a member is seen during sync (via put()), it is no longer * considered residual and will survive endSync(). */ + // UTS: realtime/unit/RTP2h2a/leave-during-sync-stores-absent-0.1 it('RTP2 - put during sync removes member from residual tracking', function () { const map = createPresenceMap(); @@ -742,6 +763,7 @@ describe('uts/realtime/presence/presence_map', function () { * * Verifies that syncInProgress is true between startSync() and endSync(). */ + // UTS: realtime/unit/RTP2/clear-resets-state-3 it('RTP2 - syncInProgress reflects sync state', function () { const map = createPresenceMap(); @@ -759,6 +781,7 @@ describe('uts/realtime/presence/presence_map', function () { * * A LEAVE with an older id than the existing member is rejected. */ + // UTS: realtime/unit/RTP2b1/older-synth-leave-rejected-1.1 it('RTP2b2 - stale LEAVE is rejected', function () { const map = createPresenceMap(); diff --git a/test/uts/realtime/presence/presence_sync.test.ts b/test/uts/realtime/unit/presence/presence_sync.test.ts similarity index 92% rename from test/uts/realtime/presence/presence_sync.test.ts rename to test/uts/realtime/unit/presence/presence_sync.test.ts index ee25e4eb8..88a4b2007 100644 --- a/test/uts/realtime/presence/presence_sync.test.ts +++ b/test/uts/realtime/unit/presence/presence_sync.test.ts @@ -15,9 +15,9 @@ */ import { expect } from 'chai'; -import { PresenceMap } from '../../../../src/common/lib/client/presencemap'; -import PresenceMessage from '../../../../src/common/lib/types/presencemessage'; -import Logger from '../../../../src/common/lib/util/logger'; +import { PresenceMap } from '../../../../../src/common/lib/client/presencemap'; +import PresenceMessage from '../../../../../src/common/lib/types/presencemessage'; +import Logger from '../../../../../src/common/lib/util/logger'; function createMockPresence(): any { const logger = new Logger(0); @@ -56,11 +56,12 @@ function createPresenceMap(mockPresence?: any): { map: PresenceMap; mock: any } return { map, mock }; } -describe('uts/realtime/presence/presence_sync', function () { +describe('uts/realtime/unit/presence/presence_sync', function () { /** * RTP18a - startSync sets syncInProgress */ + // UTS: realtime/unit/RTP18a/startsync-sets-flag-0 it('RTP18a - startSync sets syncInProgress', function () { const { map } = createPresenceMap(); @@ -72,6 +73,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP18b - endSync clears syncInProgress */ + // UTS: realtime/unit/RTP18b/endsync-clears-flag-0 it('RTP18b - endSync clears syncInProgress', function () { const { map } = createPresenceMap(); @@ -84,6 +86,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP19 - Stale members get LEAVE events after sync */ + // UTS: realtime/unit/RTP19/stale-members-leave-after-sync-0 it('RTP19 - stale members get LEAVE events after sync', function () { const { map, mock } = createPresenceMap(); @@ -109,6 +112,7 @@ describe('uts/realtime/presence/presence_sync', function () { * the LEAVE event synthesis (setting id=null, timestamp=now) is done by * _synthesizeLeaves, not by endSync. We verify the residual member is passed. */ + // UTS: realtime/unit/RTP19/synth-leave-null-id-timestamp-1 it('RTP19 - synthesized LEAVE preserves original attributes', function () { const { map, mock } = createPresenceMap(); @@ -134,6 +138,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP19 - Members updated during sync survive */ + // UTS: realtime/unit/RTP19/updated-members-survive-sync-2 it('RTP19 - members updated during sync survive', function () { const { map, mock } = createPresenceMap(); @@ -160,6 +165,7 @@ describe('uts/realtime/presence/presence_sync', function () { * DEVIATION: In ably-js, startSync() during an active sync is a no-op * (does not reset residualMembers). This test verifies ably-js behavior. */ + // UTS: realtime/unit/RTP18a/new-sync-discards-previous-1 it('RTP18a - new sync discards previous in-flight sync', function () { if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -184,6 +190,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP18c - Single-message sync (no channelSerial) */ + // UTS: realtime/unit/RTP18c/single-message-sync-0 it('RTP18c - single-message sync', function () { const { map, mock } = createPresenceMap(); @@ -206,6 +213,7 @@ describe('uts/realtime/presence/presence_sync', function () { * * At the PresenceMap level: startSync() + endSync() with no puts. */ + // UTS: realtime/unit/RTP19a/no-has-presence-clears-members-0 it('RTP19a - ATTACHED without HAS_PRESENCE clears all members', function () { const { map, mock } = createPresenceMap(); @@ -239,6 +247,7 @@ describe('uts/realtime/presence/presence_sync', function () { * residualMembers, so bob remains in residuals and gets a synthesized LEAVE. * The core assertions (ABSENT storage, cleanup on endSync) still hold. */ + // UTS: realtime/unit/RTP2h2a/leave-during-sync-absent-cleanup-0 it('RTP2h2a - LEAVE during sync stored as ABSENT', function () { const { map, mock } = createPresenceMap(); @@ -268,6 +277,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP19 - Empty map sync produces no leave events */ + // UTS: realtime/unit/RTP19/empty-map-sync-no-leaves-3 it('RTP19 - empty map sync produces no leave events', function () { const { map, mock } = createPresenceMap(); @@ -283,6 +293,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP18 - endSync without startSync is a no-op */ + // UTS: realtime/unit/RTP18/endsync-without-startsync-noop-0 it('RTP18 - endSync without startSync is a no-op', function () { const { map, mock } = createPresenceMap(); @@ -298,6 +309,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP19 - Stale SYNC message still removes member from residuals */ + // UTS: realtime/unit/RTP19/stale-sync-removes-from-residuals-4 it('RTP19 - stale SYNC message still removes member from residuals', function () { const { map, mock } = createPresenceMap(); @@ -317,6 +329,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP19 - PRESENCE echoes followed by SYNC preserves all members */ + // UTS: realtime/unit/RTP19/presence-echoes-then-sync-preserves-5 it('RTP19 - PRESENCE echoes followed by SYNC preserves all members', function () { const { map, mock } = createPresenceMap(); @@ -343,6 +356,7 @@ describe('uts/realtime/presence/presence_sync', function () { /** * RTP19 - New member added during sync is not stale */ + // UTS: realtime/unit/RTP19/new-member-during-sync-survives-6 it('RTP19 - new member added during sync is not stale', function () { const { map, mock } = createPresenceMap(); diff --git a/test/uts/realtime/presence/realtime_presence_channel_state.test.ts b/test/uts/realtime/unit/presence/realtime_presence_channel_state.test.ts similarity index 96% rename from test/uts/realtime/presence/realtime_presence_channel_state.test.ts rename to test/uts/realtime/unit/presence/realtime_presence_channel_state.test.ts index 3ff6f6759..125acc821 100644 --- a/test/uts/realtime/presence/realtime_presence_channel_state.test.ts +++ b/test/uts/realtime/unit/presence/realtime_presence_channel_state.test.ts @@ -10,11 +10,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/presence/realtime_presence_channel_state', function () { +describe('uts/realtime/unit/presence/realtime_presence_channel_state', function () { afterEach(function () { restoreAll(); }); @@ -26,6 +26,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * will perform a SYNC operation. After sync completes, presence.get() returns * the synced members. */ + // UTS: realtime/unit/RTP1/has-presence-triggers-sync-0 it('RTP1 - HAS_PRESENCE flag triggers sync', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -79,6 +80,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * If the flag is 0 or absent, the presence map should be considered in sync * immediately with no members. */ + // UTS: realtime/unit/RTP1/no-has-presence-empty-1 it('RTP1 - no HAS_PRESENCE flag means empty presence', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -123,6 +125,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * without a HAS_PRESENCE flag, emit a LEAVE event for each existing member and * remove all members from the PresenceMap. */ + // UTS: realtime/unit/RTP1/no-has-presence-clears-existing-2 it('RTP1, RTP19a - no HAS_PRESENCE clears existing members with LEAVE events', async function () { let connectionCount = 0; const mock = new MockWebSocket({ @@ -227,6 +230,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * immediately, and both the PresenceMap and internal PresenceMap are cleared. * LEAVE events should NOT be emitted when clearing. */ + // UTS: realtime/unit/RTP5a/detached-clears-presence-maps-0 it('RTP5a - DETACHED clears presence maps without LEAVE events', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -296,6 +300,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * * Same as DETACHED -- FAILED state clears both maps, no LEAVE emitted. */ + // UTS: realtime/unit/RTP5a/failed-clears-presence-maps-1 it('RTP5a - FAILED clears presence maps without LEAVE events', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -370,6 +375,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * If a channel enters the ATTACHED state then all queued presence messages * will be sent immediately. */ + // UTS: realtime/unit/RTP5b/attached-sends-queued-presence-0 it('RTP5b - ATTACHED sends queued presence messages', async function () { const capturedPresence: any[] = []; const mock = new MockWebSocket({ @@ -442,6 +448,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * If the channel enters SUSPENDED, all queued presence messages fail * immediately, but the PresenceMap is maintained. */ + // UTS: realtime/unit/RTP5f/suspended-maintains-presence-map-0 it('RTP5f - SUSPENDED maintains presence map', async function () { let connectCount = 0; @@ -539,6 +546,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * RealtimePresence#syncComplete is true if the initial SYNC operation has * completed for the members present on the channel. */ + // UTS: realtime/unit/RTP13/sync-complete-attribute-0 it('RTP13 - syncComplete attribute tracks sync state', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -608,6 +616,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * Returns the RealtimePresence object for this channel. Same instance * returned each time. */ + // UTS: realtime/unit/RTL9/presence-attribute-0 it('RTL9, RTL9a - channel.presence returns RealtimePresence object', function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -640,6 +649,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * * Getting channel.presence multiple times returns the exact same instance. */ + // UTS: realtime/unit/RTL9/presence-attribute-0.1 it('RTL9a - same presence object returned for same channel', function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -674,6 +684,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * ably-js successfully re-attaches and sends the presence message, which is * the correct behavior per RTP5b and RTP16b. */ + // UTS: realtime/unit/RTL11/queued-presence-fail-detached-0 it('RTL11 - presence on DETACHED channel triggers re-attach', async function () { const capturedPresence: any[] = []; const mock = new MockWebSocket({ @@ -742,6 +753,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * * Presence actions queued while ATTACHING fail when channel goes SUSPENDED. */ + // UTS: realtime/unit/RTL11/queued-presence-fail-suspended-1 it('RTL11 - queued presence actions fail on SUSPENDED', async function () { let connectCount = 0; const capturedPresence: any[] = []; @@ -846,6 +858,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * * Presence actions queued while ATTACHING fail when channel goes FAILED. */ + // UTS: realtime/unit/RTL11/queued-presence-fail-failed-2 it('RTL11 - queued presence actions fail on FAILED', async function () { const capturedPresence: any[] = []; const mock = new MockWebSocket({ @@ -924,6 +937,7 @@ describe('uts/realtime/presence/realtime_presence_channel_state', function () { * A channel that becomes detached may still receive an ACK for messages * published on that channel. */ + // UTS: realtime/unit/RTL11a/ack-nack-unaffected-by-state-0 it('RTL11a - ACK/NACK unaffected by channel state changes', async function () { const capturedPresence: any[] = []; const mock = new MockWebSocket({ diff --git a/test/uts/realtime/presence/realtime_presence_enter.test.ts b/test/uts/realtime/unit/presence/realtime_presence_enter.test.ts similarity index 96% rename from test/uts/realtime/presence/realtime_presence_enter.test.ts rename to test/uts/realtime/unit/presence/realtime_presence_enter.test.ts index ca0e58a33..70b35cff6 100644 --- a/test/uts/realtime/presence/realtime_presence_enter.test.ts +++ b/test/uts/realtime/unit/presence/realtime_presence_enter.test.ts @@ -18,10 +18,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/presence/realtime_presence_enter', function () { +describe('uts/realtime/unit/presence/realtime_presence_enter', function () { afterEach(function () { restoreAll(); }); @@ -34,6 +34,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * of the PresenceMessage must not be present (implicitly uses the connection's * clientId). */ + // UTS: realtime/unit/RTP8a/enter-sends-presence-enter-0 it('RTP8a, RTP8c - enter sends PRESENCE with ENTER action', async function () { const channelName = 'test-RTP8a-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -91,6 +92,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * Optional data can be included when entering. Data will be encoded * and decoded as with normal messages. */ + // UTS: realtime/unit/RTP8e/enter-with-data-0 it('RTP8e - enter with data', async function () { const channelName = 'test-RTP8e-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -142,6 +144,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * Implicitly attaches the RealtimeChannel if the channel is in the * INITIALIZED state. */ + // UTS: realtime/unit/RTP8d/enter-implicitly-attaches-0 it('RTP8d - enter implicitly attaches channel', async function () { const channelName = 'test-RTP8d-' + String(Math.random()).slice(2); @@ -190,6 +193,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * If the channel is DETACHED or FAILED, the enter request results * in an error immediately. */ + // UTS: realtime/unit/RTP8g/enter-detached-failed-errors-0 it('RTP8g - enter on FAILED channel errors', async function () { const channelName = 'test-RTP8g-' + String(Math.random()).slice(2); @@ -249,6 +253,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * If the connection is CONNECTED and the clientId is null (anonymous), * the enter request results in an error immediately. */ + // UTS: realtime/unit/RTP8j/enter-null-clientid-errors-0 it('RTP8j - enter with null clientId errors', async function () { const channelName = 'test-RTP8j-' + String(Math.random()).slice(2); @@ -300,6 +305,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * with "Can't use '*' as a clientId as that string is reserved." rather than * at enter() time. This test validates that the error occurs at construction. */ + // UTS: realtime/unit/RTP8j/enter-wildcard-clientid-errors-1 it('RTP8j - enter with wildcard clientId errors', async function () { // ably-js rejects wildcard clientId at construction time try { @@ -321,6 +327,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * If the Ably service determines that the client does not have * required presence permission, a NACK is sent resulting in an error. */ + // UTS: realtime/unit/RTP8h/nack-presence-permission-denied-0 it('RTP8h - NACK for missing presence permission', async function () { const channelName = 'test-RTP8h-' + String(Math.random()).slice(2); @@ -376,6 +383,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * Updates the data for the present member. A PRESENCE ProtocolMessage * with action UPDATE is sent. The clientId must not be present. */ + // UTS: realtime/unit/RTP9a/update-sends-presence-update-0 it('RTP9a, RTP9d - update sends PRESENCE with UPDATE action', async function () { const channelName = 'test-RTP9a-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -429,6 +437,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * Leaves this client from the channel. A PRESENCE ProtocolMessage * with action LEAVE is sent. The clientId must not be present. */ + // UTS: realtime/unit/RTP10a/leave-sends-presence-leave-0 it('RTP10a, RTP10c - leave sends PRESENCE with LEAVE action', async function () { const channelName = 'test-RTP10a-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -480,6 +489,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * * The data will be updated with the values provided when leaving. */ + // UTS: realtime/unit/RTP10a/leave-with-data-1 it('RTP10a - leave with data', async function () { const channelName = 'test-RTP10a-data-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -534,6 +544,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * key auth without clientId. enterClient() works with key auth and sends * the explicit clientId in each presence message. */ + // UTS: realtime/unit/RTP14a/enterclient-on-behalf-0 it('RTP14a - enterClient enters on behalf of another clientId', async function () { const channelName = 'test-RTP14a-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -592,6 +603,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * Performs update or leave for a given clientId. Functionally * equivalent to the corresponding enter, update, and leave methods. */ + // UTS: realtime/unit/RTP15a/updateclient-leaveclient-0 it('RTP15a - updateClient and leaveClient', async function () { const channelName = 'test-RTP15a-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -653,6 +665,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * Implicitly attaches the RealtimeChannel if the channel is in the * INITIALIZED state. */ + // UTS: realtime/unit/RTP15e/enterclient-implicitly-attaches-0 it('RTP15e - enterClient implicitly attaches channel', async function () { const channelName = 'test-RTP15e-' + String(Math.random()).slice(2); @@ -705,6 +718,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * clientIds via NACK. This test simulates a server NACK to validate the * error propagation path. */ + // UTS: realtime/unit/RTP15f/enterclient-mismatched-clientid-0 it('RTP15f - enterClient with mismatched clientId errors', async function () { const channelName = 'test-RTP15f-' + String(Math.random()).slice(2); @@ -765,6 +779,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * If the channel is ATTACHED then presence messages are sent * immediately to the connection. */ + // UTS: realtime/unit/RTP16a/presence-sent-when-attached-0 it('RTP16a - presence message sent when channel is ATTACHED', async function () { const channelName = 'test-RTP16a-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -814,6 +829,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * true, presence messages are queued at channel level, sent once * channel becomes ATTACHED. */ + // UTS: realtime/unit/RTP16b/presence-queued-when-attaching-0 it('RTP16b - presence message queued when channel is ATTACHING', async function () { const channelName = 'test-RTP16b-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -877,6 +893,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * In any other case (channel not ATTACHED, ATTACHING, or INITIALIZED * with queueMessages) the operation should result in an error. */ + // UTS: realtime/unit/RTP16c/presence-errors-other-states-0 it('RTP16c - presence message errors in other channel states', async function () { const channelName = 'test-RTP16c-' + String(Math.random()).slice(2); @@ -941,6 +958,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * plus enterClient()/leaveClient() for other users. enterClient with * the same clientId as the connection works in ably-js. */ + // UTS: realtime/unit/RTP15c/enterclient-no-side-effects-0 it('RTP15c - enterClient has no side effects on normal enter', async function () { const channelName = 'test-RTP15c-' + String(Math.random()).slice(2); const capturedPresence: any[] = []; @@ -1014,6 +1032,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * * Note: The spec says 250 but we use 50 as a practical test size. */ + // UTS: realtime/unit/RTP4/bulk-enterclient-same-connection-0 it('RTP4 - 50 members via enterClient (same connection)', async function () { this.timeout(30000); const channelName = 'test-RTP4-same-' + String(Math.random()).slice(2); @@ -1150,6 +1169,7 @@ describe('uts/realtime/presence/realtime_presence_enter', function () { * all members, then we set up client B with its own mock to observe presence * via SYNC delivery and verify via get(). */ + // UTS: realtime/unit/RTP4/bulk-enterclient-diff-connections-1 it('RTP4 - 50 members via enterClient (different connections)', async function () { this.timeout(30000); const channelName = 'test-RTP4-diff-' + String(Math.random()).slice(2); diff --git a/test/uts/realtime/presence/realtime_presence_get.test.ts b/test/uts/realtime/unit/presence/realtime_presence_get.test.ts similarity index 95% rename from test/uts/realtime/presence/realtime_presence_get.test.ts rename to test/uts/realtime/unit/presence/realtime_presence_get.test.ts index 029a37723..fb74c9cab 100644 --- a/test/uts/realtime/presence/realtime_presence_get.test.ts +++ b/test/uts/realtime/unit/presence/realtime_presence_get.test.ts @@ -11,11 +11,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../../helpers'; -describe('uts/realtime/presence/realtime_presence_get', function () { +describe('uts/realtime/unit/presence/realtime_presence_get', function () { afterEach(function () { restoreAll(); }); @@ -27,6 +27,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * for the SYNC to be completed. A single-message sync has ATTACHED with * HAS_PRESENCE, followed by a SYNC with empty cursor. */ + // UTS: realtime/unit/RTP11a/get-returns-members-single-sync-0 it('RTP11a - get returns current members after single-message sync', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -97,6 +98,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * complete before returning. A multi-message sync has a non-empty cursor in * the first message and an empty cursor in the final message. */ + // UTS: realtime/unit/RTP11a/get-waits-for-multi-sync-1 it('RTP11a, RTP11c1 - get waits for multi-message sync', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -180,6 +182,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * When waitForSync is false, the known set of presence members is returned * immediately, which may be incomplete if the SYNC is not finished. */ + // UTS: realtime/unit/RTP11c1/get-no-wait-returns-immediately-0 it('RTP11c1 - get with waitForSync=false returns immediately', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -239,6 +242,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * * clientId param filters members by the provided clientId. */ + // UTS: realtime/unit/RTP11c2/get-filtered-by-clientid-0 it('RTP11c2 - get filtered by clientId', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -293,6 +297,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * * connectionId param filters members by the provided connectionId. */ + // UTS: realtime/unit/RTP11c3/get-filtered-by-connectionid-0 it('RTP11c3 - get filtered by connectionId', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -348,6 +353,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * Implicitly attaches the RealtimeChannel if the channel is in the * INITIALIZED state. */ + // UTS: realtime/unit/RTP11b/get-implicitly-attaches-0 it('RTP11b - get implicitly attaches channel', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -391,6 +397,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * If the RealtimeChannel is SUSPENDED, get will by default (or if * waitForSync is true) result in an error with code 91005. */ + // UTS: realtime/unit/RTP11d/get-suspended-errors-default-0 it('RTP11d - get on SUSPENDED channel errors by default', async function () { let connectCount = 0; @@ -483,6 +490,7 @@ describe('uts/realtime/presence/realtime_presence_get', function () { * If waitForSync is false on a SUSPENDED channel, return the members * currently in the PresenceMap. */ + // UTS: realtime/unit/RTP11d/get-suspended-no-wait-returns-1 it('RTP11d - get on SUSPENDED channel with waitForSync=false returns members', async function () { let connectCount = 0; diff --git a/test/uts/realtime/presence/realtime_presence_history.test.ts b/test/uts/realtime/unit/presence/realtime_presence_history.test.ts similarity index 92% rename from test/uts/realtime/presence/realtime_presence_history.test.ts rename to test/uts/realtime/unit/presence/realtime_presence_history.test.ts index 6a3b6ee5f..eb5997571 100644 --- a/test/uts/realtime/presence/realtime_presence_history.test.ts +++ b/test/uts/realtime/unit/presence/realtime_presence_history.test.ts @@ -10,11 +10,11 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockWebSocket, installMockHttp, restoreAll, trackClient } from '../../../helpers'; -describe('uts/realtime/presence/realtime_presence_history', function () { +describe('uts/realtime/unit/presence/realtime_presence_history', function () { afterEach(function () { restoreAll(); }); @@ -25,6 +25,7 @@ describe('uts/realtime/presence/realtime_presence_history', function () { * Supports all the same params: start, end, direction, limit. * Verifies the correct REST endpoint is called with the right params. */ + // UTS: realtime/unit/RTP12a/history-supports-rest-params-0 it('RTP12a - history supports same params as RestPresence#history', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { @@ -94,6 +95,7 @@ describe('uts/realtime/presence/realtime_presence_history', function () { * Returns a PaginatedResult page containing the first page of messages * in the PaginatedResult#items attribute. */ + // UTS: realtime/unit/RTP12c/history-returns-paginated-result-0 it('RTP12c - history returns PaginatedResult with presence messages', async function () { const mock = new MockWebSocket({ onConnectionAttempt: (conn) => { diff --git a/test/uts/realtime/presence/realtime_presence_reentry.test.ts b/test/uts/realtime/unit/presence/realtime_presence_reentry.test.ts similarity index 97% rename from test/uts/realtime/presence/realtime_presence_reentry.test.ts rename to test/uts/realtime/unit/presence/realtime_presence_reentry.test.ts index 89479f7d0..0108251bb 100644 --- a/test/uts/realtime/presence/realtime_presence_reentry.test.ts +++ b/test/uts/realtime/unit/presence/realtime_presence_reentry.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/presence/realtime_presence_reentry', function () { +describe('uts/realtime/unit/presence/realtime_presence_reentry', function () { afterEach(function () { restoreAll(); }); @@ -21,6 +21,7 @@ describe('uts/realtime/presence/realtime_presence_reentry', function () { * whenever the channel receives an ATTACHED ProtocolMessage, except * when already attached with RESUMED flag set. */ + // UTS: realtime/unit/RTP17i/auto-reentry-on-attached-0 it('RTP17i - automatic re-entry on ATTACHED (non-RESUMED)', async function () { const channelName = `test-RTP17i-${Date.now()}`; let connectionCount = 0; @@ -141,6 +142,7 @@ describe('uts/realtime/presence/realtime_presence_reentry', function () { * PresenceMessage with an ENTER action using the clientId, data, * and id attributes from that member. */ + // UTS: realtime/unit/RTP17g/reentry-publishes-enter-with-data-0 it('RTP17g - re-entry preserves clientId and data', async function () { const channelName = `test-RTP17g-${Date.now()}`; let connectionCount = 0; @@ -273,6 +275,7 @@ describe('uts/realtime/presence/realtime_presence_reentry', function () { * attribute of the stored member, the published PresenceMessage must * not have its id set. */ + // UTS: realtime/unit/RTP17g1/reentry-omits-id-new-connid-0 it('RTP17g1 - re-entry omits id when connectionId changed', async function () { const channelName = `test-RTP17g1-${Date.now()}`; let connectionCount = 0; @@ -390,6 +393,7 @@ describe('uts/realtime/presence/realtime_presence_reentry', function () { * Automatic re-entry is NOT performed when the channel is already * attached and the ProtocolMessage has the RESUMED bit flag set. */ + // UTS: realtime/unit/RTP17i/no-reentry-with-resumed-flag-1 it('RTP17i - no re-entry when ATTACHED with RESUMED flag', async function () { const channelName = `test-RTP17i-resumed-${Date.now()}`; const capturedPresence: any[] = []; @@ -491,6 +495,7 @@ describe('uts/realtime/presence/realtime_presence_reentry', function () { * event on the channel with resumed=true and reason set to ErrorInfo * with code 91004. */ + // UTS: realtime/unit/RTP17e/failed-reentry-emits-update-error-0 it('RTP17e - failed re-entry emits UPDATE with error', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js error message doesn't include clientId const channelName = `test-RTP17e-${Date.now()}`; @@ -625,6 +630,7 @@ describe('uts/realtime/presence/realtime_presence_reentry', function () { * the client has permission to subscribe. The member should be present * in the public presence set via get. */ + // UTS: realtime/unit/RTP17a/server-publishes-without-subscribe-0 it('RTP17a - server publishes member regardless of subscribe capability', async function () { const channelName = `test-RTP17a-${Date.now()}`; diff --git a/test/uts/realtime/presence/realtime_presence_subscribe.test.ts b/test/uts/realtime/unit/presence/realtime_presence_subscribe.test.ts similarity index 96% rename from test/uts/realtime/presence/realtime_presence_subscribe.test.ts rename to test/uts/realtime/unit/presence/realtime_presence_subscribe.test.ts index 2bd03f87e..157e08846 100644 --- a/test/uts/realtime/presence/realtime_presence_subscribe.test.ts +++ b/test/uts/realtime/unit/presence/realtime_presence_subscribe.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockWebSocket } from '../../mock_websocket'; -import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; +import { MockWebSocket } from '../../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../../helpers'; -describe('uts/realtime/presence/realtime_presence_subscribe', function () { +describe('uts/realtime/unit/presence/realtime_presence_subscribe', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * Subscribe with a single listener argument subscribes a listener to * all presence messages. */ + // UTS: realtime/unit/RTP6a/subscribe-all-presence-events-0 it('RTP6a - subscribe to all presence events', async function () { const channelName = `test-RTP6a-${Date.now()}`; @@ -107,6 +108,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * Subscribe with an action argument and a listener subscribes the * listener to receive only presence messages with that action. */ + // UTS: realtime/unit/RTP6b/subscribe-filtered-by-action-0 it('RTP6b - subscribe filtered by single action', async function () { const channelName = `test-RTP6b-${Date.now()}`; @@ -180,6 +182,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * * The action argument may also be an array of actions. */ + // UTS: realtime/unit/RTP6b/subscribe-filtered-multiple-actions-1 it('RTP6b - subscribe filtered by multiple actions', async function () { const channelName = `test-RTP6b-multi-${Date.now()}`; @@ -244,6 +247,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * If the attachOnSubscribe channel option is true (default), * implicitly attach the RealtimeChannel. */ + // UTS: realtime/unit/RTP6d/subscribe-implicitly-attaches-0 it('RTP6d - subscribe implicitly attaches channel', async function () { const channelName = `test-RTP6d-${Date.now()}`; let attachCount = 0; @@ -299,6 +303,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * If the attachOnSubscribe channel option is false, do not * implicitly attach. */ + // UTS: realtime/unit/RTP6e/subscribe-no-attach-option-0 it('RTP6e - subscribe with attachOnSubscribe=false does not attach', async function () { const channelName = `test-RTP6e-${Date.now()}`; let attachCount = 0; @@ -346,6 +351,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * * Unsubscribe with no arguments unsubscribes all listeners. */ + // UTS: realtime/unit/RTP7c/unsubscribe-all-listeners-0 it('RTP7c - unsubscribe all listeners', async function () { const channelName = `test-RTP7c-${Date.now()}`; @@ -425,6 +431,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * Unsubscribe with a single listener argument unsubscribes that * specific listener. */ + // UTS: realtime/unit/RTP7a/unsubscribe-specific-listener-0 it('RTP7a - unsubscribe specific listener', async function () { const channelName = `test-RTP7a-${Date.now()}`; @@ -492,6 +499,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * Unsubscribe with an action argument and a listener unsubscribes * the listener for that action only. */ + // UTS: realtime/unit/RTP7b/unsubscribe-for-specific-action-0 it('RTP7b - unsubscribe listener for specific action', async function () { const channelName = `test-RTP7b-${Date.now()}`; @@ -559,6 +567,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * Incoming presence messages are applied to the PresenceMap (RTP2) * before being emitted to subscribers. */ + // UTS: realtime/unit/RTP6/presence-events-update-map-0 it('RTP6 - presence events update the PresenceMap', async function () { const channelName = `test-RTP6-map-${Date.now()}`; @@ -620,6 +629,7 @@ describe('uts/realtime/presence/realtime_presence_subscribe', function () { * * A PRESENCE ProtocolMessage may contain multiple PresenceMessages. */ + // UTS: realtime/unit/RTP6/multiple-presence-in-single-message-1 it('RTP6 - multiple presence messages in single ProtocolMessage', async function () { const channelName = `test-RTP6-batch-${Date.now()}`; diff --git a/test/uts/realtime/time.test.ts b/test/uts/realtime/unit/time.test.ts similarity index 93% rename from test/uts/realtime/time.test.ts rename to test/uts/realtime/unit/time.test.ts index d882cae64..545b1c8d5 100644 --- a/test/uts/realtime/time.test.ts +++ b/test/uts/realtime/unit/time.test.ts @@ -10,10 +10,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, trackClient, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, trackClient, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/realtime/time', function () { +describe('uts/realtime/unit/time', function () { let mock; afterEach(function () { @@ -23,6 +23,7 @@ describe('uts/realtime/time', function () { /** * RTC6a - time() returns server time (proxied from REST) */ + // UTS: realtime/unit/RTC6/time-proxies-rest-0 it('RTC6a - time() returns server time', async function () { const captured: any[] = []; const serverTimeMs = 1704067200000; @@ -52,6 +53,7 @@ describe('uts/realtime/time', function () { /** * RTC6a - time() request format (proxied from REST) */ + // UTS: rest/unit/RSC16/request-format-get-time-1.1 it('RTC6a - time() request format', async function () { const captured: any[] = []; @@ -83,6 +85,7 @@ describe('uts/realtime/time', function () { /** * RTC6a - time() does not require authentication (proxied from REST) */ + // UTS: rest/unit/RSC16/no-auth-required-2.1 it('RTC6a - time() does not require authentication', async function () { const captured: any[] = []; @@ -109,6 +112,7 @@ describe('uts/realtime/time', function () { /** * RTC6a - time() works without TLS (proxied from REST) */ + // UTS: rest/unit/RSC16/works-without-tls-3.1 it('RTC6a - time() works without TLS', async function () { const captured: any[] = []; @@ -141,6 +145,7 @@ describe('uts/realtime/time', function () { /** * RTC6a - time() error handling (proxied from REST) */ + // UTS: realtime/unit/RTC6/time-proxies-rest-0.1 it('RTC6a - time() error handling', async function () { mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/rest/channel/rest_channel_attributes.test.ts b/test/uts/rest/channel/rest_channel_attributes.test.ts deleted file mode 100644 index 899a61616..000000000 --- a/test/uts/rest/channel/rest_channel_attributes.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * UTS: REST Channel Attributes Tests - * - * Spec points: RSL7, RSL8, RSL8a, RSL9 - * Source: uts/test/rest/unit/channel/rest_channel_attributes.md - */ - -import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; - -describe('uts/rest/channel/rest_channel_attributes', function () { - afterEach(function () { - restoreAll(); - }); - - /** - * RSL9 - channel name attribute - * - * The channel object must expose its name via a name attribute, - * including any namespace prefix. - */ - it('RSL9 - channel name attribute', function () { - const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); - - const ch1 = client.channels.get('my-channel'); - expect(ch1.name).to.equal('my-channel'); - - const ch2 = client.channels.get('namespace:channel-name'); - expect(ch2.name).to.equal('namespace:channel-name'); - }); - - /** - * RSL7 - setOptions completes without error - * - * Calling setOptions with an empty options object must complete - * successfully without throwing. - */ - it('RSL7 - setOptions completes without error', async function () { - const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); - const channel = client.channels.get('test-channel'); - - await channel.setOptions({}); - }); - - /** - * RSL8 - status sends GET to correct path - * - * Calling status() on a channel sends a GET request to - * /channels/. - */ - it('RSL8 - status sends GET to correct path', async function () { - const captured: any[] = []; - const mock = new MockHttpClient({ - onConnectionAttempt: (conn) => conn.respond_with_success(), - onRequest: (req) => { - captured.push(req); - req.respond_with(200, { - channelId: 'test-channel', - status: { - isActive: true, - occupancy: { metrics: { connections: 5 } }, - }, - }); - }, - }); - installMockHttp(mock); - - const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); - const ch = client.channels.get('test-channel'); - await ch.status(); - - expect(captured).to.have.length(1); - expect(captured[0].method).to.equal('get'); - expect(captured[0].path).to.equal('/channels/test-channel'); - }); - - /** - * RSL8 - status URL encodes channel name - * - * Channel names containing special characters (colons, spaces, etc.) - * must be URL-encoded in the request path. - */ - it('RSL8 - status URL encodes channel name', async function () { - const captured: any[] = []; - const mock = new MockHttpClient({ - onConnectionAttempt: (conn) => conn.respond_with_success(), - onRequest: (req) => { - captured.push(req); - req.respond_with(200, { - channelId: 'namespace:my channel', - status: { - isActive: true, - occupancy: { metrics: { connections: 1 } }, - }, - }); - }, - }); - installMockHttp(mock); - - const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); - const ch = client.channels.get('namespace:my channel'); - await ch.status(); - - expect(captured).to.have.length(1); - expect(captured[0].path).to.contain(encodeURIComponent('namespace:my channel')); - }); - - /** - * RSL8a - status returns ChannelDetails - * - * The status() method returns a ChannelDetails object with channelId, - * status.isActive, and status.occupancy.metrics fields. - */ - it('RSL8a - status returns ChannelDetails', async function () { - const mock = new MockHttpClient({ - onConnectionAttempt: (conn) => conn.respond_with_success(), - onRequest: (req) => { - req.respond_with(200, { - channelId: 'test-RSL8a', - status: { - isActive: true, - occupancy: { - metrics: { - connections: 5, - publishers: 2, - subscribers: 3, - }, - }, - }, - }); - }, - }); - installMockHttp(mock); - - const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); - const ch = client.channels.get('test-RSL8a'); - const result = await ch.status(); - - expect(result.channelId).to.equal('test-RSL8a'); - expect(result.status.isActive).to.equal(true); - expect(result.status.occupancy.metrics.connections).to.equal(5); - expect(result.status.occupancy.metrics.publishers).to.equal(2); - expect(result.status.occupancy.metrics.subscribers).to.equal(3); - }); -}); diff --git a/test/uts/rest/integration/auth.test.ts b/test/uts/rest/integration/auth.test.ts index 321afbd6e..62dfb9519 100644 --- a/test/uts/rest/integration/auth.test.ts +++ b/test/uts/rest/integration/auth.test.ts @@ -34,6 +34,7 @@ describe('uts/rest/integration/auth', function () { * * Client can authenticate using an API key via HTTP Basic Auth. */ + // UTS: rest/integration/RSA4/basic-auth-key-0 it('RSA4 - basic auth with API key', async function () { const channelName = uniqueChannelName('test-RSA4'); @@ -53,6 +54,7 @@ describe('uts/rest/integration/auth', function () { * * Client can authenticate using a JWT token. */ + // UTS: rest/integration/RSA8/token-auth-jwt-0 it('RSA8 - token auth with JWT', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); @@ -80,6 +82,7 @@ describe('uts/rest/integration/auth', function () { * * Client can authenticate using an Ably native token obtained via requestToken(). */ + // UTS: rest/integration/RSA8/token-auth-native-1 it('RSA8 - token auth with native token', async function () { const keyClient = new Ably.Rest({ key: getApiKey(), @@ -109,6 +112,7 @@ describe('uts/rest/integration/auth', function () { * * Client can use authCallback to obtain authentication via TokenRequest. */ + // UTS: rest/integration/RSA8/auth-callback-token-request-2 it('RSA8 - authCallback with TokenRequest', async function () { const tokenRequestClient = new Ably.Rest({ key: getApiKey(), @@ -140,6 +144,7 @@ describe('uts/rest/integration/auth', function () { * * Client can use authCallback to obtain JWT tokens dynamically. */ + // UTS: rest/integration/RSA8/auth-callback-jwt-3 it('RSA8 - authCallback with JWT', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); @@ -173,6 +178,7 @@ describe('uts/rest/integration/auth', function () { * * Server rejects requests with invalid API key credentials. */ + // UTS: rest/integration/RSA4/invalid-credentials-rejected-1 it('RSA4 - invalid credentials rejected', async function () { const channelName = uniqueChannelName('test-RSA4-invalid'); @@ -195,6 +201,7 @@ describe('uts/rest/integration/auth', function () { * When a REST request fails with a token error (40140-40149), the client * should automatically renew the token and retry the request. */ + // UTS: rest/integration/RSC10/token-renewal-expired-jwt-0 it('RSC10 - token renewal with expired JWT', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js retry overwrites new auth header with stale one; see #2193 const { keyName, keySecret } = getKeyParts(getApiKey()); @@ -247,6 +254,7 @@ describe('uts/rest/integration/auth', function () { * * Tokens with restricted capabilities should only allow the permitted operations. */ + // UTS: rest/integration/RSA8/capability-restriction-4 it('RSA8 - capability restriction', async function () { const { keyName, keySecret } = getKeyParts(getApiKey()); diff --git a/test/uts/rest/integration/batch_presence.test.ts b/test/uts/rest/integration/batch_presence.test.ts index 1a9085efa..a9064cf74 100644 --- a/test/uts/rest/integration/batch_presence.test.ts +++ b/test/uts/rest/integration/batch_presence.test.ts @@ -21,9 +21,10 @@ import { closeAndWait, uniqueChannelName, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; -describe('uts/rest/integration/batch_presence', function () { - this.timeout(60000); +describeEachProtocol('uts/rest/integration/batch_presence', function (protocol) { + this.timeout(120000); before(async function () { await setupSandbox(); @@ -39,6 +40,7 @@ describe('uts/rest/integration/batch_presence', function () { * Enter members on two channels via Realtime, then query both channels * in a single batchPresence call via REST and verify the returned members. */ + // UTS: rest/integration/RSC24/batch-presence-multiple-channels-0 it('RSC24, BGR2 - batchPresence returns members across multiple channels', async function () { const channelAName = uniqueChannelName('batch-presence-a'); const channelBName = uniqueChannelName('batch-presence-b'); @@ -48,7 +50,7 @@ describe('uts/rest/integration/batch_presence', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); await connectAndWait(realtime); @@ -66,7 +68,7 @@ describe('uts/rest/integration/batch_presence', function () { const rest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); const result = await rest.batchPresence([channelAName, channelBName]); @@ -109,6 +111,7 @@ describe('uts/rest/integration/batch_presence', function () { * an empty presence set. The test still validates the per-channel success vs * failure distinction. */ + // UTS: rest/integration/RSC24/restricted-key-channel-failure-1 it('RSC24, BGF2 - restricted key returns per-channel failure for unauthorized channels', async function () { // Use the fixed channel name matching keys[2] capability from ably-common const allowedChannel = 'channel6'; @@ -119,7 +122,7 @@ describe('uts/rest/integration/batch_presence', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); await connectAndWait(realtime); @@ -140,7 +143,7 @@ describe('uts/rest/integration/batch_presence', function () { const restrictedRest = new Ably.Rest({ key: getApiKey(2), endpoint: SANDBOX_ENDPOINT, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); const result = await restrictedRest.batchPresence([allowedChannel, deniedChannel]); @@ -171,6 +174,7 @@ describe('uts/rest/integration/batch_presence', function () { * A channel with no presence members returns a success result with an empty * presence array (or no presence field, depending on server behaviour). */ + // UTS: rest/integration/RSC24/empty-channel-presence-2 it('RSC24 - batchPresence with empty channel returns empty presence array', async function () { const emptyChannel = uniqueChannelName('batch-empty'); const populatedChannel = uniqueChannelName('batch-populated'); @@ -180,7 +184,7 @@ describe('uts/rest/integration/batch_presence', function () { key: getApiKey(), endpoint: SANDBOX_ENDPOINT, autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); await connectAndWait(realtime); @@ -193,7 +197,7 @@ describe('uts/rest/integration/batch_presence', function () { const rest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); const result = await rest.batchPresence([emptyChannel, populatedChannel]); diff --git a/test/uts/rest/integration/history.test.ts b/test/uts/rest/integration/history.test.ts index d28828ed4..5b5be7dcc 100644 --- a/test/uts/rest/integration/history.test.ts +++ b/test/uts/rest/integration/history.test.ts @@ -15,8 +15,9 @@ import { uniqueChannelName, pollUntil, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; -describe('uts/rest/integration/history', function () { +describeEachProtocol('uts/rest/integration/history', function (protocol) { this.timeout(30000); before(async function () { @@ -30,10 +31,12 @@ describe('uts/rest/integration/history', function () { /** * RSL2a - History returns published messages in backwards order (newest first) */ + // UTS: rest/integration/RSL2a/history-returns-messages-0 it('RSL2a - history returns published messages', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('history-test-RSL2a'); @@ -72,10 +75,12 @@ describe('uts/rest/integration/history', function () { /** * RSL2b1 - History direction forwards returns messages oldest first */ + // UTS: rest/integration/RSL2b1/history-direction-forwards-0 it('RSL2b1 - history direction forwards', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('history-direction'); @@ -103,10 +108,12 @@ describe('uts/rest/integration/history', function () { /** * RSL2b2 - History limit parameter restricts number of returned messages */ + // UTS: rest/integration/RSL2b2/history-limit-parameter-0 it('RSL2b2 - history limit parameter', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('history-limit'); @@ -135,60 +142,66 @@ describe('uts/rest/integration/history', function () { /** * RSL2b3 - History time range parameters filter messages by timestamp */ + // UTS: rest/integration/RSL2b3/history-time-range-0 it('RSL2b3 - history time range parameters', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('history-timerange'); const channel = client.channels.get(channelName); - // Record start time - const timeBefore = Date.now(); - - // Publish some early messages + // Publish early messages await channel.publish('early1', 'e1'); await channel.publish('early2', 'e2'); - // Record middle time - const timeMiddle = Date.now(); + // Small delay to ensure server timestamps differ between batches + await new Promise((r) => setTimeout(r, 2)); - // Publish some late messages + // Publish late messages await channel.publish('late1', 'l1'); await channel.publish('late2', 'l2'); - // Record end time - const timeAfter = Date.now(); - - // Poll until all messages appear - await pollUntil(async () => { + // Poll until all messages appear and retrieve with timestamps + const allMessages: any[] = await pollUntil(async () => { const result = await channel.history(); - return result.items.length === 4 ? result : null; + return result.items.length === 4 ? result.items : null; }, { interval: 500, timeout: 10000 }); - // Query only early messages + // Use server-assigned timestamps to define the time boundary + const earlyTimestamps = allMessages + .filter((m: any) => m.name.startsWith('early')) + .map((m: any) => m.timestamp); + const lateTimestamps = allMessages + .filter((m: any) => m.name.startsWith('late')) + .map((m: any) => m.timestamp); + + const maxEarlyTs = Math.max(...earlyTimestamps); + const minLateTs = Math.min(...lateTimestamps); + + // The boundary is between the two batches + const timeBoundary = Math.floor((maxEarlyTs + minLateTs) / 2); + + // Query only early messages (up to the boundary) const earlyHistory = await channel.history({ - start: timeBefore, - end: timeMiddle, + start: maxEarlyTs - 1000, + end: timeBoundary, }); - // Query only late messages + // Query only late messages (from the boundary onwards) const lateHistory = await channel.history({ - start: timeMiddle, - end: timeAfter, + start: timeBoundary + 1, + end: minLateTs + 1000, }); - // Due to timing precision, exact counts may vary - // The key test is that filtering by time range works expect(earlyHistory.items.length).to.be.at.least(1); expect(lateHistory.items.length).to.be.at.least(1); - // Early messages should contain "early" names const hasEarly = earlyHistory.items.some((msg: any) => msg.name.startsWith('early')); expect(hasEarly).to.be.true; - // Late messages should contain "late" names const hasLate = lateHistory.items.some((msg: any) => msg.name.startsWith('late')); expect(hasLate).to.be.true; }); @@ -196,10 +209,12 @@ describe('uts/rest/integration/history', function () { /** * RSL2 - History on channel with no messages returns empty result */ + // UTS: rest/integration/RSL2/history-empty-channel-0 it('RSL2 - history on empty channel returns empty result', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // Use a fresh channel with no messages diff --git a/test/uts/rest/integration/mutable_messages.test.ts b/test/uts/rest/integration/mutable_messages.test.ts index ec2228342..ed4074e78 100644 --- a/test/uts/rest/integration/mutable_messages.test.ts +++ b/test/uts/rest/integration/mutable_messages.test.ts @@ -15,9 +15,10 @@ import { uniqueChannelName, pollUntil, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; -describe('uts/rest/integration/mutable_messages', function () { - this.timeout(60000); +describeEachProtocol('uts/rest/integration/mutable_messages', function (protocol) { + this.timeout(120000); before(async function () { await setupSandbox(); @@ -32,10 +33,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * On success, returns a PublishResult containing message serials. */ + // UTS: rest/integration/RSL1n/publish-returns-serials-0.1 it('RSL1n - single message publish returns result with serial', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL1n-serials'); @@ -55,10 +58,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * Multiple message publish returns matching count, all unique. */ + // UTS: rest/integration/RSL1n/publish-returns-serials-0 it('RSL1n - multiple message publish returns unique serials', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL1n-serials-multi'); @@ -88,10 +93,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * A published message can be retrieved by its serial. */ + // UTS: rest/integration/RSL11/get-message-by-serial-0 it('RSL11 - getMessage retrieves a published message by serial', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL11-getMessage'); @@ -117,10 +124,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * A published message can be updated and the update is visible via getMessage(). */ + // UTS: rest/integration/RSL15/update-message-0 it('RSL15 - updateMessage updates a published message', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL15-update'); @@ -163,10 +172,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * A published message can be deleted and the delete is visible via getMessage(). */ + // UTS: rest/integration/RSL15/delete-message-1 it('RSL15 - deleteMessage deletes a published message', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL15-delete'); @@ -201,10 +212,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * Version history contains the original and all updates. */ + // UTS: rest/integration/RSL14/get-message-versions-0 it('RSL14 - getMessageVersions returns version history', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL14-versions'); @@ -248,10 +261,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * A message can be appended to. */ + // UTS: rest/integration/RSL15/append-message-2 it('RSL15 - appendMessage appends to a published message', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSL15-append'); @@ -277,10 +292,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * Tests the full annotation lifecycle: create, verify, delete. */ + // UTS: rest/integration/RSAN1/annotation-lifecycle-0 it('RSAN1/RSAN2/RSAN3 - annotation lifecycle: publish, get, delete', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSAN-lifecycle'); @@ -330,10 +347,12 @@ describe('uts/rest/integration/mutable_messages', function () { * * Multiple annotations can be retrieved as a paginated result. */ + // UTS: rest/integration/RSAN3/get-annotations-paginated-0 it('RSAN3 - paginated annotations for multiple annotation types', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('mutable:test-RSAN3-paginated'); diff --git a/test/uts/rest/integration/pagination.test.ts b/test/uts/rest/integration/pagination.test.ts index 0d44f026f..eb6f292c3 100644 --- a/test/uts/rest/integration/pagination.test.ts +++ b/test/uts/rest/integration/pagination.test.ts @@ -17,7 +17,7 @@ import { } from './sandbox'; describe('uts/rest/integration/pagination', function () { - this.timeout(60000); + this.timeout(120000); before(async function () { await setupSandbox(); @@ -34,6 +34,7 @@ describe('uts/rest/integration/pagination', function () { * TG1: items contains array of results for current page. * TG2: hasNext() and isLast() indicate availability of more pages. */ + // UTS: rest/integration/TG1/items-and-navigation-0 it('TG1, TG2 - PaginatedResult items and navigation', async function () { const channelName = uniqueChannelName('pagination-basic'); @@ -77,6 +78,7 @@ describe('uts/rest/integration/pagination', function () { * Page 1: 5 items, page 2: 5 items, page 3: 2 items. * Verify no duplicate IDs across pages, total 12. */ + // UTS: rest/integration/TG3/next-retrieves-page-0 it('TG3 - next() retrieves subsequent pages', async function () { const channelName = uniqueChannelName('pagination-next'); @@ -127,6 +129,7 @@ describe('uts/rest/integration/pagination', function () { * Publish 10 messages, get page1 (limit 3), get page2 via next(), * get firstPage via page2.first(). firstPage items should match page1 items by id. */ + // UTS: rest/integration/TG4/first-retrieves-page-0 it('TG4 - first() retrieves first page', async function () { const channelName = uniqueChannelName('pagination-first'); @@ -169,6 +172,7 @@ describe('uts/rest/integration/pagination', function () { * Publish 25 messages, iterate through all pages with limit 7. * Collect all messages, verify total is 25, all event names present. */ + // UTS: rest/integration/TG5/iterate-all-pages-0 it('TG5 - iterate through all pages', async function () { const channelName = uniqueChannelName('pagination-iterate'); @@ -225,6 +229,7 @@ describe('uts/rest/integration/pagination', function () { * All items fit on one page. hasNext() false, isLast() true. * next() returns null or empty result. */ + // UTS: rest/integration/TG3/next-last-page-null-1 it('TG - next() on last page returns null', async function () { const channelName = uniqueChannelName('pagination-lastnext'); diff --git a/test/uts/rest/integration/presence.test.ts b/test/uts/rest/integration/presence.test.ts index 16eb04a4c..0b6fbc7ce 100644 --- a/test/uts/rest/integration/presence.test.ts +++ b/test/uts/rest/integration/presence.test.ts @@ -18,9 +18,10 @@ import { uniqueChannelName, pollUntil, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; -describe('uts/rest/integration/presence', function () { - this.timeout(60000); +describeEachProtocol('uts/rest/integration/presence', function (protocol) { + this.timeout(120000); before(async function () { await setupSandbox(); @@ -39,10 +40,12 @@ describe('uts/rest/integration/presence', function () { * * channel.presence must exist and not be null. */ + // UTS: rest/integration/RSP1/access-presence-from-channel-0 it('RSP1_Integration - presence accessible on channel', function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -63,10 +66,12 @@ describe('uts/rest/integration/presence', function () { * get() returns a PaginatedResult containing current presence members. * The fixture channel has at least 5 pre-populated members. */ + // UTS: rest/integration/RSP3/get-presence-members-0 it('RSP3_Integration_1 - get returns presence members from fixture channel', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -87,10 +92,12 @@ describe('uts/rest/integration/presence', function () { * * Each item has action, clientId, data, and connectionId. */ + // UTS: rest/integration/RSP3/presence-message-fields-1 it('RSP3_Integration_2 - get returns PresenceMessage with correct fields', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -112,10 +119,12 @@ describe('uts/rest/integration/presence', function () { * * limit param restricts the number of presence members returned. */ + // UTS: rest/integration/RSP3a1/get-with-limit-0 it('RSP3a1_Integration - get with limit parameter', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -134,10 +143,12 @@ describe('uts/rest/integration/presence', function () { * * clientId param filters results to the specified client. */ + // UTS: rest/integration/RSP3a2/get-with-clientid-filter-0 it('RSP3a2_Integration - get with clientId filter', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -154,10 +165,12 @@ describe('uts/rest/integration/presence', function () { * * get() returns empty PaginatedResult when no members are present. */ + // UTS: rest/integration/RSP3/get-empty-channel-2 it('RSP3_Integration_Empty - get on empty channel returns empty result', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('presence-empty'); @@ -180,12 +193,14 @@ describe('uts/rest/integration/presence', function () { * Creates presence history by entering, updating, and leaving a channel * via a Realtime client, then retrieves history via REST. */ + // UTS: rest/integration/RSP4/history-returns-events-0 it('RSP4_Integration_1 - history returns presence events', async function () { const channelName = uniqueChannelName('presence-history'); const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // Use realtime client to generate presence history @@ -194,7 +209,7 @@ describe('uts/rest/integration/presence', function () { endpoint: SANDBOX_ENDPOINT, clientId: 'test-client', autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); @@ -232,12 +247,14 @@ describe('uts/rest/integration/presence', function () { * * start and end params filter history by timestamp range. */ + // UTS: rest/integration/RSP4b1/history-time-range-0 it('RSP4b1_Integration - history with start/end time range', async function () { const channelName = uniqueChannelName('presence-history-time'); const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // Record time before any presence events @@ -249,7 +266,7 @@ describe('uts/rest/integration/presence', function () { endpoint: SANDBOX_ENDPOINT, clientId: 'time-test-client', autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); @@ -287,12 +304,14 @@ describe('uts/rest/integration/presence', function () { * * direction param controls event ordering (forwards = oldest first). */ + // UTS: rest/integration/RSP4b2/history-direction-forwards-0 it('RSP4b2_Integration - history direction forwards', async function () { const channelName = uniqueChannelName('presence-direction'); const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // Generate ordered presence events @@ -301,7 +320,7 @@ describe('uts/rest/integration/presence', function () { endpoint: SANDBOX_ENDPOINT, clientId: 'direction-client', autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); @@ -341,12 +360,14 @@ describe('uts/rest/integration/presence', function () { * * limit param restricts history results and enables pagination. */ + // UTS: rest/integration/RSP4b3/history-limit-pagination-0 it('RSP4b3_Integration - history with limit and pagination', async function () { const channelName = uniqueChannelName('presence-limit'); const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // Generate multiple presence events @@ -355,7 +376,7 @@ describe('uts/rest/integration/presence', function () { endpoint: SANDBOX_ENDPOINT, clientId: 'limit-client', autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); @@ -400,10 +421,12 @@ describe('uts/rest/integration/presence', function () { * * Presence message data is decoded according to its encoding. */ + // UTS: rest/integration/RSP5/decode-string-data-0 it('RSP5_Integration_1 - string data decoded from fixtures', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -419,10 +442,12 @@ describe('uts/rest/integration/presence', function () { * * JSON-encoded presence data is decoded to native objects. */ + // UTS: rest/integration/RSP5/decode-json-data-1 it('RSP5_Integration_2 - JSON data decoded from fixtures', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures'); @@ -438,10 +463,12 @@ describe('uts/rest/integration/presence', function () { * * Encrypted presence data is automatically decrypted when cipher is configured. */ + // UTS: rest/integration/RSP5/decode-encrypted-data-2 it('RSP5_Integration_3 - encrypted data decoded with cipher', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channel = client.channels.get('persisted:presence_fixtures', { @@ -461,12 +488,14 @@ describe('uts/rest/integration/presence', function () { * * Presence history messages are decoded the same way as current presence. */ + // UTS: rest/integration/RSP5/decode-history-messages-3 it('RSP5_Integration_4 - presence history with JSON data decoded', async function () { const channelName = uniqueChannelName('presence-decode-history'); const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // Generate presence event with JSON data @@ -475,7 +504,7 @@ describe('uts/rest/integration/presence', function () { endpoint: SANDBOX_ENDPOINT, clientId: 'decode-client', autoConnect: false, - useBinaryProtocol: false, + useBinaryProtocol: protocol === 'msgpack', }); trackClient(realtime); @@ -511,10 +540,12 @@ describe('uts/rest/integration/presence', function () { * * Paginate through all fixture members with limit 2. */ + // UTS: rest/integration/RSP3/full-pagination-3 it('RSP_Pagination_Integration - paginate through all fixture members', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // The fixture channel has multiple members @@ -550,10 +581,12 @@ describe('uts/rest/integration/presence', function () { * * Presence operations with invalid credentials return authentication errors. */ + // UTS: rest/integration/RSP3/invalid-credentials-rejected-4 it('RSP_Error_Integration_1 - invalid credentials rejected', async function () { const client = new Ably.Rest({ key: 'invalid.key:secret', endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); try { @@ -571,10 +604,12 @@ describe('uts/rest/integration/presence', function () { * * Subscribe capability is sufficient for presence.get(). */ + // UTS: rest/integration/RSP3/subscribe-capability-sufficient-5 it('RSP_Error_Integration_2 - subscribe-only key can do presence.get()', async function () { const client = new Ably.Rest({ key: getApiKey(3), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); // This should work - subscribe capability is sufficient for presence.get diff --git a/test/uts/rest/integration/proxy/rest_fallback.test.ts b/test/uts/rest/integration/proxy/rest_fallback.test.ts new file mode 100644 index 000000000..c0b0cbd2e --- /dev/null +++ b/test/uts/rest/integration/proxy/rest_fallback.test.ts @@ -0,0 +1,443 @@ +/** + * UTS Proxy Integration: REST Fallback Tests + * + * Spec points: RSC15l, RSC15l2, RSC15l4 + * Source: specification/uts/rest/integration/proxy/rest_fallback.md + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, +} from '../../integration/sandbox'; +import { createProxySession, waitForProxy, ProxySession } from '../../../../uts/realtime/integration/helpers/proxy'; + +describe('uts/rest/integration/proxy/rest_fallback', function () { + this.timeout(120000); + + let session: ProxySession | null = null; + + before(async function () { + await waitForProxy(); + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + afterEach(async function () { + if (session) { + await session.close(); + session = null; + } + }); + + /** + * RSC15l2 — Request timeout triggers fallback via proxy + * + * The proxy delays the first /time request beyond httpRequestTimeout. + * The SDK should time out and retry on a fallback host (also routed + * through the proxy, where the rule has expired after times:1). + */ + // UTS: rest/proxy/RSC15l2/timeout-triggers-fallback-0 + it('RSC15l2 - request timeout triggers fallback', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'http_request', pathContains: '/time' }, + action: { + type: 'http_delay', + delayMs: 20000, + }, + times: 1, + comment: 'RSC15l2: Delay first /time request beyond httpRequestTimeout', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + fallbackHosts: ['localhost'], + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + httpRequestTimeout: 3000, + } as any); + + const result = await restClient.time(); + + expect(result).to.be.a('number'); + expect(result).to.be.greaterThan(0); + + // Proxy log should show at least two /time requests (initial + fallback retry) + const log = await session.getLog(); + const timeRequests = log.filter((e) => e.type === 'http_request' && e.path && e.path.includes('/time')); + expect(timeRequests.length).to.be.at.least(2); + }); + + /** + * RSC15l4 — CloudFront Server header triggers fallback via proxy + * + * The proxy returns a 403 with Server: CloudFront on the first /time + * request. The SDK should treat this as a retryable server error and + * retry on a fallback host. + */ + // UTS: rest/proxy/RSC15l4/cloudfront-header-fallback-0 + it('RSC15l4 - CloudFront Server header triggers fallback', async function () { + // DEVIATION: see deviations.md — ably-js does not inspect the Server response header + if (!process.env.RUN_DEVIATIONS) this.skip(); + session = await createProxySession({ + 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', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + fallbackHosts: ['localhost'], + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + const result = await restClient.time(); + + expect(result).to.be.a('number'); + expect(result).to.be.greaterThan(0); + + // Proxy log should show at least two /time requests (initial + fallback retry) + const log = await session.getLog(); + const timeRequests = log.filter((e) => e.type === 'http_request' && e.path && e.path.includes('/time')); + expect(timeRequests.length).to.be.at.least(2); + + // First response was the injected 403 with CloudFront header + const httpResponses = log.filter((e) => e.type === 'http_response'); + expect(httpResponses[0].status).to.equal(403); + }); + + /** + * Unreachable endpoint surfaces error correctly + * + * A Rest client pointed at a port with nothing listening should fail + * with a usable error object (not an unhandled crash). + */ + // UTS: rest/proxy/RSC15l/unreachable-endpoint-error-0 + it('Unreachable endpoint surfaces error correctly', async function () { + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + port: 19999, + tls: false, + useBinaryProtocol: false, + } as any); + + let error: any; + try { + await restClient.time(); + expect.fail('Expected time() to throw'); + } catch (err: any) { + error = err; + } + + // The error should have a statusCode or code property — i.e. it's a usable error, not an unhandled crash + expect(error).to.exist; + expect(error.statusCode || error.code).to.exist; + }); + + /** + * Connection drop mid-response retried on fallback + * + * The proxy drops the first /time request (http_drop). The SDK should + * retry on a fallback host and succeed. + */ + // UTS: rest/proxy/RSC15l/connection-drop-fallback-1 + it('Connection drop mid-response retried on fallback', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'http_request', pathContains: '/time' }, + action: { + type: 'http_drop', + }, + times: 1, + comment: 'Drop first /time request to trigger fallback retry', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + fallbackHosts: ['localhost'], + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + const result = await restClient.time(); + + expect(result).to.be.a('number'); + expect(result).to.be.greaterThan(0); + + // Proxy log should show at least two /time requests (initial drop + fallback retry) + const log = await session.getLog(); + const timeRequests = log.filter((e) => e.type === 'http_request' && e.path && e.path.includes('/time')); + expect(timeRequests.length).to.be.at.least(2); + }); + + /** + * HTTP 503 with JSON error body — error parsed correctly + * + * The proxy returns a 503 with a structured Ably error body on the first + * /time request. With no fallbackHosts, the SDK should surface the error + * with code, statusCode, and message parsed from the body. + */ + // UTS: rest/proxy/RSC15l/http-5xx-json-error-parsed-0 + it('HTTP 503 with JSON error body - error parsed correctly', async function () { + session = await createProxySession({ + 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 Ably error body on first /time request', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + let error: any; + try { + await restClient.time(); + expect.fail('Expected time() to throw'); + } catch (err: any) { + error = err; + } + + expect(error.code).to.equal(50300); + expect(error.statusCode).to.equal(503); + expect(error.message).to.include('Service temporarily unavailable'); + }); + + /** + * HTTP 503 without error field in body — error synthesized from status + * + * The proxy returns a 503 with an empty body (no `error` field). The SDK + * should still produce a usable error with the correct statusCode. + */ + // UTS: rest/proxy/RSC15l/http-5xx-no-json-synthesized-1 + it('HTTP 503 without error field in body - error synthesized from status', async function () { + session = await createProxySession({ + rules: [ + { + match: { type: 'http_request', pathContains: '/time' }, + action: { + type: 'http_respond', + status: 503, + body: {}, + }, + times: 1, + comment: 'Return 503 with empty body on first /time request', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + let error: any; + try { + await restClient.time(); + expect.fail('Expected time() to throw'); + } catch (err: any) { + error = err; + } + + expect(error).to.exist; + expect(error.statusCode).to.equal(503); + }); + + /** + * HTTP 403 with error body — not retried, error parsed + * + * The proxy returns a 403 with an Ably error body. Even with fallbackHosts + * configured, 403 is not a fallback-eligible status, so the SDK should NOT + * retry and should surface the error directly. + */ + // UTS: rest/proxy/RSC15l/http-4xx-not-retried-0 + it('HTTP 403 with error body - not retried, error parsed', async function () { + session = await createProxySession({ + 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 Ably error body on first /time request', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + fallbackHosts: ['localhost'], + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + } as any); + + let error: any; + try { + await restClient.time(); + expect.fail('Expected time() to throw'); + } catch (err: any) { + error = err; + } + + expect(error.code).to.equal(40300); + expect(error.statusCode).to.equal(403); + + // Proxy log should show exactly 1 /time request — 403 is not fallback-eligible, no retry + const log = await session.getLog(); + const timeRequests = log.filter((e) => e.type === 'http_request' && e.path && e.path.includes('/time')); + expect(timeRequests.length).to.equal(1); + }); + + /** + * RSL1k4 — Idempotent publish retry deduplication + * + * Requires proxy support for response modification (forwarding the request + * to the server, then replacing the response sent back to the client). + * The current proxy only supports http_respond which intercepts BEFORE + * forwarding to the server, so the first publish would never reach the + * server and we cannot test deduplication. + */ + // UTS: rest/proxy/RSL1k4/idempotent-retry-dedup-0 + it.skip('RSL1k4 - Idempotent publish retry deduplication', async function () { + // Requires proxy support for response modification (forwarding to server + // then replacing the response). Current proxy only supports http_respond + // which intercepts before forwarding, so the publish never reaches the + // server and retry deduplication cannot be tested end-to-end. + + session = await createProxySession({ + rules: [ + { + match: { type: 'http_request', method: 'POST', pathContains: '/channels/' }, + action: { + type: 'http_respond', + status: 503, + body: { error: { code: 50300, statusCode: 503, message: 'Service temporarily unavailable' } }, + }, + times: 1, + comment: 'RSL1k4: Return 503 on first publish to trigger retry', + }, + ], + }); + + const restClient = new Ably.Rest({ + authCallback: (_params: any, cb: any) => { + const innerRest = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT } as any); + innerRest.auth.requestToken().then( + (token: any) => cb(null, token), + (err: any) => cb(err, null), + ); + }, + endpoint: 'localhost', + fallbackHosts: ['localhost'], + port: session.proxyPort, + tls: false, + useBinaryProtocol: false, + idempotentRestPublishing: true, + } as any); + + const channelName = uniqueChannelName('test-RSL1k4-idempotent'); + const channel = restClient.channels.get(channelName); + + // Publish — first attempt gets 503, SDK retries on fallback and succeeds + await channel.publish('test-msg', 'hello'); + + // History should contain exactly one copy of the message (deduplication) + const history = await channel.history(); + const matches = history.items.filter((m: any) => m.name === 'test-msg'); + expect(matches.length).to.equal(1); + }); +}); diff --git a/test/uts/rest/integration/publish.test.ts b/test/uts/rest/integration/publish.test.ts index a1aa1c885..5d7fed23e 100644 --- a/test/uts/rest/integration/publish.test.ts +++ b/test/uts/rest/integration/publish.test.ts @@ -15,8 +15,9 @@ import { uniqueChannelName, pollUntil, } from './sandbox'; +import { describeEachProtocol } from '../../helpers/protocol_variants'; -describe('uts/rest/integration/publish', function () { +describeEachProtocol('uts/rest/integration/publish', function (protocol) { this.timeout(30000); before(async function () { @@ -33,12 +34,14 @@ describe('uts/rest/integration/publish', function () { * Failed publish operations must indicate the error to the caller. * Publishing to a channel not in the restricted key's capability should fail. */ + // UTS: rest/integration/RSL1d/publish-failure-error-0 it('RSL1d - publish failure with restricted key returns error', async function () { const channelName = uniqueChannelName('forbidden-channel'); const restrictedClient = new Ably.Rest({ key: getApiKey(2), // per-channel capabilities endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const restrictedChannel = restrictedClient.channels.get(channelName); @@ -57,10 +60,12 @@ describe('uts/rest/integration/publish', function () { * * Successful publish returns a PublishResult containing message serials. */ + // UTS: rest/integration/RSL1n/publish-result-serials-0.1 it('RSL1n - single message publish returns result with serial', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('test-serials'); @@ -74,10 +79,12 @@ describe('uts/rest/integration/publish', function () { expect((result.serials[0] as string).length).to.be.greaterThan(0); }); + // UTS: rest/integration/RSL1n/publish-result-serials-0 it('RSL1n - multiple message publish returns result with unique serials', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('test-serials-multi'); @@ -108,10 +115,12 @@ describe('uts/rest/integration/publish', function () { * Messages with client-supplied IDs are idempotent; duplicate IDs * don't create duplicate messages. */ + // UTS: rest/integration/RSL1k5/idempotent-client-ids-0 it('RSL1k5 - idempotent publish with client-supplied ID', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('idempotent-explicit'); @@ -144,10 +153,12 @@ describe('uts/rest/integration/publish', function () { * Additional publish params can be supplied and are transmitted to the server. * The _forceNack test param causes the server to reject the publish. */ + // UTS: rest/integration/RSL1l1/publish-params-force-nack-0 it('RSL1l1 - publish with _forceNack param is rejected', async function () { const client = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('force-nack-test'); @@ -166,11 +177,13 @@ describe('uts/rest/integration/publish', function () { * * Server rejects messages where clientId doesn't match the authenticated client. */ + // UTS: rest/integration/RSL1m4/clientid-mismatch-rejected-0 it('RSL1m4 - clientId mismatch in message is rejected', async function () { // Create a token with a specific clientId const keyClient = new Ably.Rest({ key: getApiKey(), endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const tokenDetails = await keyClient.auth.requestToken({ clientId: 'authenticated-client-id' }); @@ -179,6 +192,7 @@ describe('uts/rest/integration/publish', function () { const tokenClient = new Ably.Rest({ token: tokenDetails.token, endpoint: SANDBOX_ENDPOINT, + useBinaryProtocol: protocol === 'msgpack', }); const channelName = uniqueChannelName('clientid-mismatch'); diff --git a/test/uts/rest/integration/push_admin.test.ts b/test/uts/rest/integration/push_admin.test.ts index 287c1fbf1..af052bf32 100644 --- a/test/uts/rest/integration/push_admin.test.ts +++ b/test/uts/rest/integration/push_admin.test.ts @@ -20,7 +20,7 @@ function randomId(): string { } describe('uts/rest/integration/push_admin', function () { - this.timeout(60000); + this.timeout(120000); before(async function () { await setupSandbox(); @@ -40,6 +40,7 @@ describe('uts/rest/integration/push_admin', function () { * Publishes a push notification to a clientId recipient. The sandbox * accepts the request even though no real device receives it. */ + // UTS: rest/integration/RSH1a/push-publish-clientid-0 it('RSH1a - publish to clientId recipient should not throw', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -62,6 +63,7 @@ describe('uts/rest/integration/push_admin', function () { * * An empty recipient object should cause the server to return an error. */ + // UTS: rest/integration/RSH1a/push-publish-invalid-recipient-1 it('RSH1a - publish with empty recipient throws error', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -89,6 +91,7 @@ describe('uts/rest/integration/push_admin', function () { * Saves a device registration, then retrieves it by ID and verifies * the returned fields. */ + // UTS: rest/integration/RSH1b3/save-and-get-device-0 it('RSH1b3, RSH1b1 - save and get device registration', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -129,6 +132,7 @@ describe('uts/rest/integration/push_admin', function () { * Saving a device with the same ID but a different token should update * the existing registration. */ + // UTS: rest/integration/RSH1b3/update-device-registration-1 it('RSH1b3 - save updates existing device registration', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -174,6 +178,7 @@ describe('uts/rest/integration/push_admin', function () { * * Retrieving a nonexistent device must return a 404 error. */ + // UTS: rest/integration/RSH1b1/get-unknown-device-error-0 it('RSH1b1 - get unknown device throws 404', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -194,6 +199,7 @@ describe('uts/rest/integration/push_admin', function () { * Lists device registrations filtered by deviceId. The result should be * a PaginatedResult containing exactly the registered device. */ + // UTS: rest/integration/RSH1b2/list-devices-filtered-0 it('RSH1b2 - list device registrations filtered by deviceId', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -228,6 +234,7 @@ describe('uts/rest/integration/push_admin', function () { * Registering 3 devices with the same clientId, then listing with limit=2 * should return at most 2 items and indicate more pages are available. */ + // UTS: rest/integration/RSH1b2/list-devices-pagination-1 it('RSH1b2 - list supports pagination with limit', async function () { if (!process.env.RUN_DEVIATIONS) this.skip(); // push admin API does not return Link headers for pagination; see ably/realtime#8380 const client = new Ably.Rest({ @@ -273,6 +280,7 @@ describe('uts/rest/integration/push_admin', function () { * * Saves a device, removes it, then verifies it is no longer retrievable. */ + // UTS: rest/integration/RSH1b4/remove-device-0 it('RSH1b4 - remove deletes device registration', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -308,6 +316,7 @@ describe('uts/rest/integration/push_admin', function () { * * Removing a device that does not exist should not throw. */ + // UTS: rest/integration/RSH1b4/remove-nonexistent-device-1 it('RSH1b4 - remove nonexistent device does not throw', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -323,6 +332,7 @@ describe('uts/rest/integration/push_admin', function () { * Registers two devices with the same clientId, removes them all via * removeWhere, then verifies none remain. */ + // UTS: rest/integration/RSH1b5/remove-where-clientid-0 it('RSH1b5 - removeWhere deletes devices by clientId', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -365,6 +375,7 @@ describe('uts/rest/integration/push_admin', function () { * Registers a device, saves a channel subscription for it, then lists * subscriptions on that channel and verifies the subscription appears. */ + // UTS: rest/integration/RSH1c3/save-and-list-subscriptions-0 it('RSH1c3, RSH1c1 - save and list channel subscription by deviceId', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -421,6 +432,7 @@ describe('uts/rest/integration/push_admin', function () { * * Saves a clientId-based channel subscription and verifies the response. */ + // UTS: rest/integration/RSH1c3/save-subscription-clientid-1 it('RSH1c3 - save channel subscription with clientId', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -452,6 +464,7 @@ describe('uts/rest/integration/push_admin', function () { * Creates a clientId subscription, then verifies the channel appears * in the listChannels result. */ + // UTS: rest/integration/RSH1c2/list-channels-with-subscriptions-0 it('RSH1c2 - listChannels includes channel with active subscription', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -486,6 +499,7 @@ describe('uts/rest/integration/push_admin', function () { * Creates a subscription, removes it, then verifies it no longer appears * in list results. */ + // UTS: rest/integration/RSH1c4/remove-channel-subscription-0 it('RSH1c4 - remove deletes channel subscription', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -520,6 +534,7 @@ describe('uts/rest/integration/push_admin', function () { * * Removing a subscription that does not exist should not throw. */ + // UTS: rest/integration/RSH1c4/remove-nonexistent-subscription-1 it('RSH1c4 - remove nonexistent subscription does not throw', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -538,6 +553,7 @@ describe('uts/rest/integration/push_admin', function () { * Creates subscriptions on two channels for the same clientId, removes * them all via removeWhere, then verifies none remain. */ + // UTS: rest/integration/RSH1c5/remove-where-subscriptions-0 it('RSH1c5 - removeWhere deletes subscriptions by clientId', async function () { const client = new Ably.Rest({ key: getApiKey(), diff --git a/test/uts/rest/integration/push_channels.test.ts b/test/uts/rest/integration/push_channels.test.ts new file mode 100644 index 000000000..e4ad96577 --- /dev/null +++ b/test/uts/rest/integration/push_channels.test.ts @@ -0,0 +1,99 @@ +/** + * UTS Integration: PushChannel Tests (RSH7) + * + * Spec points: RSH7a, RSH7b, RSH7c, RSH7d + * Source: uts/rest/integration/push_channels.md + * + * These tests require the Push plugin to be loaded, and the local device to + * be configurable. The PushChannel methods (subscribeDevice, subscribeClient, + * unsubscribeDevice, unsubscribeClient) operate on behalf of the local device + * and require push device authentication (RSH6). + * + * Since ably-js's PushChannel.subscribeDevice/unsubscribeDevice use + * X-Ably-DeviceToken headers for push device auth, and the sandbox does not + * issue real deviceIdentityTokens through the admin API, these integration + * tests are skipped. The PushChannel API requires a genuine device activation + * flow (RSH2) to obtain a valid deviceIdentityToken, which is not feasible + * in a Node.js test environment. + * + * The subscribeClient/unsubscribeClient methods use client.auth.clientId + * and do NOT require device registration or device auth headers, so they + * could potentially work, but ably-js's implementation does not add device + * auth headers for subscribeClient either — it just posts with standard + * auth. However, the sandbox may still reject these without a proper push + * setup. + */ + +import { expect } from 'chai'; +import { + Ably, + SANDBOX_ENDPOINT, + setupSandbox, + teardownSandbox, + getApiKey, + uniqueChannelName, +} from './sandbox'; + +function randomId(): string { + return Math.random().toString(36).substring(2, 10); +} + +describe('uts/rest/integration/push_channels', function () { + this.timeout(120000); + + before(async function () { + await setupSandbox(); + }); + + after(async function () { + await teardownSandbox(); + }); + + // --------------------------------------------------------------------------- + // RSH7a, RSH7c - subscribeDevice / unsubscribeDevice round-trip + // --------------------------------------------------------------------------- + + /** + * RSH7a, RSH7c - subscribeDevice and unsubscribeDevice round-trip + * + * Tests the full device subscription lifecycle: register a device, + * subscribe it to a channel via PushChannel.subscribeDevice(), verify + * the subscription exists, then unsubscribe and verify removal. + * + * Skipped: PushChannel.subscribeDevice() requires a valid deviceIdentityToken + * obtained through the device activation flow (RSH2). The admin API can + * register devices but does not return a deviceIdentityToken suitable for + * push device auth (RSH6a). In Node.js there is no native push activation. + */ + // UTS: rest/integration/RSH7a/subscribe-unsubscribe-device-0 + it('RSH7a, RSH7c - subscribeDevice and unsubscribeDevice round-trip', function () { + // RSH7 PushChannel device methods require push activation flow (RSH2) + // which is not available in Node.js test environment + this.skip(); + }); + + // --------------------------------------------------------------------------- + // RSH7b, RSH7d - subscribeClient / unsubscribeClient round-trip + // --------------------------------------------------------------------------- + + /** + * RSH7b, RSH7d - subscribeClient and unsubscribeClient round-trip + * + * Tests the full client subscription lifecycle: configure a client with + * a clientId, subscribe via PushChannel.subscribeClient(), verify the + * subscription exists, then unsubscribe and verify removal. + * + * Skipped: ably-js's PushChannel requires the Push plugin to be loaded, + * and subscribeClient() still goes through PushChannel which expects a + * configured LocalDevice. The device activation flow is not available in + * Node.js. Additionally, push channel subscriptions via the PushChannel + * API (as opposed to the admin API) require the server to recognize the + * device context. + */ + // UTS: rest/integration/RSH7b/subscribe-unsubscribe-client-0 + it('RSH7b, RSH7d - subscribeClient and unsubscribeClient round-trip', function () { + // RSH7 PushChannel client methods require Push plugin with device context + // which is not available in Node.js test environment + this.skip(); + }); +}); diff --git a/test/uts/rest/integration/revoke_tokens.test.ts b/test/uts/rest/integration/revoke_tokens.test.ts index bc6714f79..b19a0921a 100644 --- a/test/uts/rest/integration/revoke_tokens.test.ts +++ b/test/uts/rest/integration/revoke_tokens.test.ts @@ -21,7 +21,7 @@ import { } from './sandbox'; describe('uts/rest/integration/revoke_tokens', function () { - this.timeout(60000); + this.timeout(120000); before(async function () { await setupSandbox(); @@ -39,6 +39,7 @@ describe('uts/rest/integration/revoke_tokens', function () { * information. Revocation is verified via a Realtime client that gets * disconnected with error code 40141. */ + // UTS: rest/integration/RSA17g/revoke-token-prevents-use-0 it('RSA17g, RSA17b, RSA17c, TRS2 - token revocation prevents subsequent use', async function () { const clientId = 'revoke-client-' + Math.random().toString(36).substring(2, 10); @@ -86,6 +87,7 @@ describe('uts/rest/integration/revoke_tokens', function () { * with code 40162 and status code 401. This is a client-side check -- no * HTTP request is made to the server. */ + // UTS: rest/integration/RSA17d/token-auth-revoke-rejected-0 it('RSA17d - token auth client rejected', async function () { const { keyName, keySecret } = getKeyParts(getApiKey(4)); const jwt = generateJWT({ @@ -117,6 +119,7 @@ describe('uts/rest/integration/revoke_tokens', function () { * revoked. When allowReauthMargin is true, the revocation is delayed by * approximately 30 seconds to allow token renewal. */ + // UTS: rest/integration/RSA17e/issued-before-reauth-margin-0 it('RSA17e, RSA17f - issuedBefore and allowReauthMargin', async function () { const clientId = 'revoke-margin-client-' + Math.random().toString(36).substring(2, 10); @@ -151,6 +154,7 @@ describe('uts/rest/integration/revoke_tokens', function () { * An invalid target type produces a failure result with an ErrorInfo. * The valid revocation is verified via a Realtime client disconnect. */ + // UTS: rest/integration/RSA17c/mixed-success-failure-0 it('RSA17c, TRF2 - mixed success and failure', async function () { const clientId = 'revoke-mixed-client-' + Math.random().toString(36).substring(2, 10); diff --git a/test/uts/rest/integration/time_stats.test.ts b/test/uts/rest/integration/time_stats.test.ts index edc6e8447..9dcd36710 100644 --- a/test/uts/rest/integration/time_stats.test.ts +++ b/test/uts/rest/integration/time_stats.test.ts @@ -32,6 +32,7 @@ describe('uts/rest/integration/time_stats', function () { * reasonably close to the client's local time (within 5 seconds, allowing * for network latency and minor clock differences). */ + // UTS: rest/integration/RSC16/time-returns-server-time-0 it('RSC16 - time() returns server time', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -57,6 +58,7 @@ describe('uts/rest/integration/time_stats', function () { * `stats()` returns a PaginatedResult containing application statistics. * Stats may be empty for a new sandbox app, but the call should succeed. */ + // UTS: rest/integration/RSC6/stats-returns-result-0 it('RSC6 - stats() returns a PaginatedResult', async function () { const client = new Ably.Rest({ key: getApiKey(), @@ -80,6 +82,7 @@ describe('uts/rest/integration/time_stats', function () { * * `stats()` supports `limit`, `direction`, and `unit` parameters. */ + // UTS: rest/integration/RSC6/stats-with-parameters-1 it('RSC6 - stats() with parameters', async function () { const client = new Ably.Rest({ key: getApiKey(), diff --git a/test/uts/rest/auth/auth_callback.test.ts b/test/uts/rest/unit/auth/auth_callback.test.ts similarity index 93% rename from test/uts/rest/auth/auth_callback.test.ts rename to test/uts/rest/unit/auth/auth_callback.test.ts index f726e096a..cc308fe76 100644 --- a/test/uts/rest/auth/auth_callback.test.ts +++ b/test/uts/rest/unit/auth/auth_callback.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; function simpleMock(captured: any) { return new MockHttpClient({ @@ -33,7 +33,7 @@ function authUrlMock(captured: any, tokenValue?: any) { }); } -describe('uts/rest/auth/auth_callback', function () { +describe('uts/rest/unit/auth/auth_callback', function () { afterEach(function () { restoreAll(); }); @@ -41,6 +41,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8d - authCallback invoked for authentication */ + // UTS: rest/unit/RSA8d/callback-invoked-for-auth-0 it('RSA8d - authCallback invoked for authentication', async function () { const captured: any[] = []; let callbackInvoked = false; @@ -68,6 +69,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8d - authCallback returning JWT string */ + // UTS: rest/unit/RSA8d/callback-returns-jwt-1 it('RSA8d - authCallback returning JWT string', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -92,6 +94,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8d - authCallback returning TokenRequest */ + // UTS: rest/unit/RSA8d/callback-returns-token-request-2 it('RSA8d - authCallback returning TokenRequest', async function () { const captured: any[] = []; @@ -144,6 +147,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8d - authCallback receives TokenParams */ + // UTS: rest/unit/RSA8d/callback-receives-token-params-3 it('RSA8d - authCallback receives TokenParams', async function () { let receivedParams: any = null; @@ -177,6 +181,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8c - authUrl invoked for authentication (GET) */ + // UTS: rest/unit/RSA8c/authurl-invoked-for-auth-0 it('RSA8c - authUrl invoked for authentication (GET)', async function () { const captured: any[] = []; installMockHttp(authUrlMock(captured)); @@ -207,6 +212,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8c - authUrl with POST method */ + // UTS: rest/unit/RSA8c/authurl-post-method-1 it('RSA8c - authUrl with POST method', async function () { const captured: any[] = []; @@ -240,6 +246,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8c - authUrl with custom headers */ + // UTS: rest/unit/RSA8c/authurl-custom-headers-2 it('RSA8c - authUrl with custom headers', async function () { const captured: any[] = []; installMockHttp(authUrlMock(captured)); @@ -265,6 +272,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8c - authUrl with query params */ + // UTS: rest/unit/RSA8c/authurl-query-params-3 it('RSA8c - authUrl with query params', async function () { const captured: any[] = []; installMockHttp(authUrlMock(captured)); @@ -290,6 +298,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8c - authUrl returning JWT string */ + // UTS: rest/unit/RSA8c/authurl-returns-jwt-4 it('RSA8c - authUrl returning JWT string', async function () { const captured: any[] = []; const jwt = 'eyJhbGciOiJIUzI1NiJ9.jwt-body.signature'; @@ -312,6 +321,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8d - authCallback error propagated */ + // UTS: rest/unit/RSA8d/callback-error-propagated-4 it('RSA8d - authCallback error propagated', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -340,6 +350,7 @@ describe('uts/rest/auth/auth_callback', function () { /** * RSA8c - authUrl error propagated */ + // UTS: rest/unit/RSA8c/authurl-error-propagated-5 it('RSA8c - authUrl error propagated', async function () { const captured: any[] = []; diff --git a/test/uts/rest/auth/auth_scheme.test.ts b/test/uts/rest/unit/auth/auth_scheme.test.ts similarity index 92% rename from test/uts/rest/auth/auth_scheme.test.ts rename to test/uts/rest/unit/auth/auth_scheme.test.ts index 1bce9dd03..d8dd906ce 100644 --- a/test/uts/rest/auth/auth_scheme.test.ts +++ b/test/uts/rest/unit/auth/auth_scheme.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; /** Standard mock that auto-succeeds and returns 200 */ function simpleMock(captured: any) { @@ -40,7 +40,7 @@ function tokenRoutingMock(captured: any, tokenValue?: any) { }); } -describe('uts/rest/auth/auth_scheme', function () { +describe('uts/rest/unit/auth/auth_scheme', function () { afterEach(function () { restoreAll(); }); @@ -48,6 +48,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA4 - Basic auth with API key only */ + // UTS: rest/unit/RSA4/basic-auth-key-only-0 it('RSA4 - Basic auth with API key only', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -67,6 +68,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA3 - Token auth with explicit token string */ + // UTS: rest/unit/RSA3/token-auth-explicit-token-0 it('RSA3 - Token auth with explicit token string', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -86,6 +88,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA3 - Token auth with TokenDetails */ + // UTS: rest/unit/RSA3/token-auth-token-details-1 it('RSA3 - Token auth with TokenDetails', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -110,6 +113,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA4 - useTokenAuth forces token auth */ + // UTS: rest/unit/RSA4/use-token-auth-forced-1 it('RSA4 - useTokenAuth forces token auth', async function () { const captured: any[] = []; installMockHttp(tokenRoutingMock(captured, 'obtained-token')); @@ -133,6 +137,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA4 - authCallback triggers token auth */ + // UTS: rest/unit/RSA4/auth-callback-triggers-token-2 it('RSA4 - authCallback triggers token auth', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -156,6 +161,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA4 - authUrl triggers token auth */ + // UTS: rest/unit/RSA4/authurl-triggers-token-3 it('RSA4 - authUrl triggers token auth', async function () { const captured: any[] = []; @@ -190,6 +196,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSC1b - Error when no auth method available */ + // UTS: rest/unit/RSC1b/no-auth-method-error-0 it('RSC1b - Error when no auth method available', function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -213,6 +220,7 @@ describe('uts/rest/auth/auth_scheme', function () { * there's no way to renew, the library should error with 40171. * Note: RSA4b1 (local expiry detection) is optional. */ + // UTS: rest/unit/RSA4a2/expired-token-no-renewal-0 it('RSA4a2 - Error when token expired and no renewal method', async function () { const captured: any[] = []; @@ -246,6 +254,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA1 - Auth method priority (authCallback over key) */ + // UTS: rest/unit/RSA1/token-auth-takes-precedence-0 it('RSA1 - Auth method priority (authCallback over key)', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -270,6 +279,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSA2, RSA11 - Basic auth header format */ + // UTS: rest/unit/RSA2/basic-auth-header-format-0 it('RSA2, RSA11 - Basic auth header format', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -289,6 +299,7 @@ describe('uts/rest/auth/auth_scheme', function () { /** * RSC18 - Token auth allowed over non-TLS */ + // UTS: rest/unit/RSC18/token-auth-over-non-tls-0 it('RSC18 - Token auth allowed over non-TLS', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); diff --git a/test/uts/rest/auth/authorize.test.ts b/test/uts/rest/unit/auth/authorize.test.ts similarity index 93% rename from test/uts/rest/auth/authorize.test.ts rename to test/uts/rest/unit/auth/authorize.test.ts index 1898c1e3b..2a4852551 100644 --- a/test/uts/rest/auth/authorize.test.ts +++ b/test/uts/rest/unit/auth/authorize.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; function tokenRoutingMock(captured: any) { return new MockHttpClient({ @@ -28,7 +28,7 @@ function tokenRoutingMock(captured: any) { }); } -describe('uts/rest/auth/authorize', function () { +describe('uts/rest/unit/auth/authorize', function () { afterEach(function () { restoreAll(); }); @@ -36,6 +36,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10a - authorize() obtains token with defaults */ + // UTS: rest/unit/RSA10a/authorize-default-params-0 it('RSA10a - authorize() obtains token', async function () { const captured: any[] = []; installMockHttp(tokenRoutingMock(captured)); @@ -60,6 +61,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10b - authorize() with explicit tokenParams overrides defaults */ + // UTS: rest/unit/RSA10b/authorize-explicit-params-0 it('RSA10b - tokenParams override defaults', async function () { let callbackParams: any = null; @@ -90,6 +92,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10g - authorize() updates auth.tokenDetails */ + // UTS: rest/unit/RSA10g/authorize-updates-token-details-0 it('RSA10g - authorize() updates tokenDetails', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -124,6 +127,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10h - authorize() with new authCallback replaces old */ + // UTS: rest/unit/RSA10h/authorize-replaces-auth-options-0 it('RSA10h - authOptions replace stored options', async function () { let originalCalled = false; let newCalled = false; @@ -155,6 +159,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10j - authorize() when already authorized gets new token */ + // UTS: rest/unit/RSA10j/authorize-replaces-existing-token-0 it('RSA10j - authorize() when already authorized', async function () { let tokenCount = 0; @@ -186,6 +191,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10k - authorize() with queryTime queries server time */ + // UTS: rest/unit/RSA10k/authorize-query-time-0 it('RSA10k - queryTime queries server', async function () { const captured: any[] = []; @@ -221,6 +227,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10l - authorize() error handling */ + // UTS: rest/unit/RSA10l/authorize-error-propagated-0 it('RSA10l - authorize() propagates errors', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -253,6 +260,7 @@ describe('uts/rest/auth/authorize', function () { * tokenParams provided to authorize() are saved and reused on subsequent * token requests (e.g. when the token expires and is re-acquired). */ + // UTS: rest/unit/RSA10e/authorize-saves-params-0 it('RSA10e - tokenParams saved for reuse', async function () { const callbackInvocations: any[] = []; @@ -294,6 +302,7 @@ describe('uts/rest/auth/authorize', function () { * The API key from ClientOptions is preserved even when authOptions * are provided to authorize(). */ + // UTS: rest/unit/RSA10i/authorize-preserves-key-0 it('RSA10i - key preserved after authorize with authOptions', async function () { const captured: any[] = []; @@ -329,6 +338,7 @@ describe('uts/rest/auth/authorize', function () { /** * RSA10a - authorize() with incompatible key throws 40102 */ + // UTS: rest/unit/RSA10a/authorize-default-params-0.1 it('RSA10a - incompatible key in authOptions throws 40102', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/rest/auth/client_id.test.ts b/test/uts/rest/unit/auth/client_id.test.ts similarity index 92% rename from test/uts/rest/auth/client_id.test.ts rename to test/uts/rest/unit/auth/client_id.test.ts index 6e597aae0..f8f55ff6d 100644 --- a/test/uts/rest/auth/client_id.test.ts +++ b/test/uts/rest/unit/auth/client_id.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; function simpleMock(captured: any) { return new MockHttpClient({ @@ -19,7 +19,7 @@ function simpleMock(captured: any) { }); } -describe('uts/rest/auth/client_id', function () { +describe('uts/rest/unit/auth/client_id', function () { afterEach(function () { restoreAll(); }); @@ -27,6 +27,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA7a - clientId from ClientOptions */ + // UTS: rest/unit/RSA7a/clientid-from-options-0 it('RSA7a - clientId from ClientOptions', function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -45,6 +46,7 @@ describe('uts/rest/auth/client_id', function () { * Per spec, clientId from TokenDetails passed at construction should be * accessible via auth.clientId. */ + // UTS: rest/unit/RSA7b/clientid-from-token-details-0 it('RSA7b - clientId from TokenDetails', function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -68,6 +70,7 @@ describe('uts/rest/auth/client_id', function () { * Per spec, clientId from TokenDetails returned by authCallback should * update auth.clientId after the first auth request. */ + // UTS: rest/unit/RSA7b/clientid-from-callback-token-1 it('RSA7b - clientId from authCallback TokenDetails', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -98,6 +101,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA7c - clientId null when unidentified */ + // UTS: rest/unit/RSA7c/clientid-null-unidentified-0 it('RSA7c - clientId null when unidentified', function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -110,6 +114,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA7c - clientId null with unidentified token */ + // UTS: rest/unit/RSA7c/clientid-null-unidentified-token-1 it('RSA7c - clientId null with unidentified token', function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -127,6 +132,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA12a - clientId passed to authCallback in TokenParams */ + // UTS: rest/unit/RSA12a/clientid-passed-to-callback-0 it('RSA12a - clientId passed to authCallback in TokenParams', async function () { let receivedParams: any = null; @@ -157,6 +163,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA12b - clientId sent to authUrl as query param */ + // UTS: rest/unit/RSA12b/clientid-sent-to-authurl-0 it('RSA12b - clientId sent to authUrl', async function () { const captured: any[] = []; @@ -196,6 +203,7 @@ describe('uts/rest/auth/client_id', function () { * Per spec, auth.clientId should be updated when authorize() returns * a new token with a different clientId. */ + // UTS: rest/unit/RSA7/clientid-updated-after-authorize-0 it('RSA7 - clientId updated after authorize()', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -238,6 +246,7 @@ describe('uts/rest/auth/client_id', function () { * Per spec, wildcard '*' clientId in TokenDetails should be preserved * and accessible via auth.clientId. */ + // UTS: rest/unit/RSA12/wildcard-clientid-0 it('RSA12 - Wildcard clientId', function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -261,6 +270,7 @@ describe('uts/rest/auth/client_id', function () { * When ClientOptions.clientId is set but the token has no clientId, * the client should keep the explicit clientId from options. */ + // UTS: rest/unit/RSA7/clientid-mismatch-error-1 it('RSA7 - case 3: explicit clientId kept when token has none', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -297,6 +307,7 @@ describe('uts/rest/auth/client_id', function () { * for REST clients — see deviations.md (RSA7b). This test documents * the expected behavior even though it currently fails. */ + // UTS: rest/unit/RSA7/clientid-updated-after-authorize-0.1 it('RSA7 - case 5: clientId inherited from token', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -329,6 +340,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA15a - Matching clientId succeeds */ + // UTS: rest/unit/RSA15a/token-clientid-must-match-0 it('RSA15a - Matching clientId succeeds', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -358,6 +370,7 @@ describe('uts/rest/auth/client_id', function () { * Per spec, if ClientOptions.clientId and TokenDetails.clientId are both * non-wildcard and don't match, an error with code 40102 must be raised. */ + // UTS: rest/unit/RSA15c/incompatible-clientid-error-0 it('RSA15a - Mismatched clientId error (40102)', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); @@ -382,6 +395,7 @@ describe('uts/rest/auth/client_id', function () { /** * RSA15b - Wildcard token clientId permits any ClientOptions clientId */ + // UTS: rest/unit/RSA15b/wildcard-token-permits-any-0 it('RSA15b - Wildcard token clientId permits any ClientOptions clientId', async function () { const captured: any[] = []; installMockHttp(simpleMock(captured)); diff --git a/test/uts/rest/auth/revoke_tokens.test.ts b/test/uts/rest/unit/auth/revoke_tokens.test.ts similarity index 85% rename from test/uts/rest/auth/revoke_tokens.test.ts rename to test/uts/rest/unit/auth/revoke_tokens.test.ts index 6a5b94217..d96a4b1e8 100644 --- a/test/uts/rest/auth/revoke_tokens.test.ts +++ b/test/uts/rest/unit/auth/revoke_tokens.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; function revokeMock(captured: any, responseBody?: any) { return new MockHttpClient({ @@ -26,7 +26,7 @@ function revokeMock(captured: any, responseBody?: any) { }); } -describe('uts/rest/auth/revoke_tokens', function () { +describe('uts/rest/unit/auth/revoke_tokens', function () { afterEach(function () { restoreAll(); }); @@ -34,6 +34,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17g - POST to /keys/{keyName}/revokeTokens */ + // UTS: rest/unit/RSA17g/sends-post-correct-path-0 it('RSA17g - sends POST to correct path', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -49,6 +50,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17b - Single target specifier */ + // UTS: rest/unit/RSA17b/single-specifier-targets-0 it('RSA17b - single specifier sent as targets array', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -63,6 +65,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17b - Multiple specifiers with different types */ + // UTS: rest/unit/RSA17b/multiple-specifier-types-1 it('RSA17b - multiple specifiers', async function () { const captured: any[] = []; const responseBody = { @@ -89,13 +92,20 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17c / BAR2 - All success result + * + * With X-Ably-Version >= 3, the server returns {successCount, failureCount, + * results} directly — the SDK passes through the response. */ + // UTS: rest/unit/RSA17c/all-success-result-0 it('RSA17c - all success result', async function () { - if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js passes through response; see #2201 - const responseBody = [ - { target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, - { target: 'clientId:bob', issuedBefore: 1700000000000, appliesAt: 1700000002000 }, - ]; + const responseBody = { + successCount: 2, + failureCount: 0, + results: [ + { target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, + { target: 'clientId:bob', issuedBefore: 1700000000000, appliesAt: 1700000002000 }, + ], + }; installMockHttp(revokeMock(null, responseBody)); const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); @@ -112,6 +122,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * TRS2 - Success result attributes */ + // UTS: rest/unit/TRS2/success-result-attributes-0 it('TRS2 - success result has target, issuedBefore, appliesAt', async function () { const responseBody = { successCount: 1, @@ -132,18 +143,18 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17c_2 - Mixed success and failure result * - * Per spec: the SDK should normalise the HTTP 400 response containing - * {error, batchResponse} into {successCount, failureCount, results}. + * With X-Ably-Version >= 3, the server returns {successCount, failureCount, + * results} directly with HTTP 200 — the SDK passes through the response. */ - it('RSA17c_2 - mixed result normalised', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); + // UTS: rest/unit/RSA17c/mixed-success-failure-1 + it('RSA17c_2 - mixed result', async function () { const mock = new 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' } }, ], @@ -166,19 +177,16 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17c_3 - All failure result - * - * Per spec: the SDK should normalise the HTTP 400 response into - * {successCount: 0, failureCount: N, results}. */ - it('RSA17c_3 - all failure normalised', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); + // UTS: rest/unit/RSA17c/all-failure-result-2 + it('RSA17c_3 - all failure', async function () { const mock = new 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' } }, ], @@ -201,19 +209,16 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * TRF2_1 - Failure result with target and error details - * - * Per spec: the per-target error details should be accessible in the - * normalised response results. */ + // UTS: rest/unit/TRF2/failure-result-attributes-0 it('TRF2_1 - failure details in results', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); const mock = new 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' }, @@ -237,6 +242,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17d - Token auth client fails with 40162 */ + // UTS: rest/unit/RSA17d/token-auth-revoke-rejected-0 it('RSA17d - token auth client fails with 40162', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -258,6 +264,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17d - useTokenAuth flag also fails with 40162 */ + // UTS: rest/unit/RSA17d/use-token-auth-revoke-rejected-1 it('RSA17d - useTokenAuth flag fails with 40162', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -278,6 +285,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17e - issuedBefore included when specified */ + // UTS: rest/unit/RSA17e/issued-before-included-0 it('RSA17e - issuedBefore included in request body', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -292,6 +300,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17e - issuedBefore omitted when not provided */ + // UTS: rest/unit/RSA17e/issued-before-omitted-1 it('RSA17e - issuedBefore omitted when not provided', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -306,6 +315,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17f - allowReauthMargin included when true */ + // UTS: rest/unit/RSA17f/reauth-margin-included-0 it('RSA17f - allowReauthMargin included', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -320,6 +330,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17f - allowReauthMargin omitted when not provided */ + // UTS: rest/unit/RSA17f/reauth-margin-omitted-1 it('RSA17f - allowReauthMargin omitted when not provided', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -334,6 +345,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17f - Both issuedBefore and allowReauthMargin together */ + // UTS: rest/unit/RSA17f/both-options-together-2 it('RSA17f - both options together', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); @@ -353,6 +365,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17 - Server error propagated */ + // UTS: rest/unit/RSA17/server-error-propagated-0 it('RSA17 - server error propagated', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -378,6 +391,7 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17 - Request uses Basic authentication */ + // UTS: rest/unit/RSA17/request-uses-basic-auth-0 it('RSA17 - request uses Basic auth', async function () { const captured: any[] = []; installMockHttp(revokeMock(captured)); diff --git a/test/uts/rest/auth/token_details.test.ts b/test/uts/rest/unit/auth/token_details.test.ts similarity index 77% rename from test/uts/rest/auth/token_details.test.ts rename to test/uts/rest/unit/auth/token_details.test.ts index bb6247d5d..b5b6f6a38 100644 --- a/test/uts/rest/auth/token_details.test.ts +++ b/test/uts/rest/unit/auth/token_details.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, enableFakeTimers, restoreAll } from '../../../helpers'; function simpleMock(captured?: any) { return new MockHttpClient({ @@ -19,7 +19,7 @@ function simpleMock(captured?: any) { }); } -describe('uts/rest/auth/token_details', function () { +describe('uts/rest/unit/auth/token_details', function () { afterEach(function () { restoreAll(); }); @@ -27,6 +27,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16a - tokenDetails reflects token from authCallback */ + // UTS: rest/unit/RSA16a/token-from-callback-0 it('RSA16a - tokenDetails from authCallback', async function () { installMockHttp(simpleMock()); @@ -58,6 +59,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16a - tokenDetails reflects token from requestToken (authorize with key) */ + // UTS: rest/unit/RSA16a/token-from-request-token-1 it('RSA16a - tokenDetails from requestToken', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn: any) => conn.respond_with_success(), @@ -88,6 +90,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16b - tokenDetails created from token string in ClientOptions */ + // UTS: rest/unit/RSA16b/token-string-in-options-0 it('RSA16b - tokenDetails from token string option', function () { installMockHttp(simpleMock()); @@ -105,6 +108,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16b - tokenDetails created from token string in authCallback */ + // UTS: rest/unit/RSA16b/token-string-from-callback-1 it('RSA16b - tokenDetails from token string authCallback', async function () { installMockHttp(simpleMock()); @@ -131,6 +135,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16c - tokenDetails set on instantiation with tokenDetails option */ + // UTS: rest/unit/RSA16c/set-on-instantiation-0 it('RSA16c - tokenDetails set on instantiation', function () { installMockHttp(simpleMock()); @@ -151,6 +156,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16c - tokenDetails updated after explicit authorize() */ + // UTS: rest/unit/RSA16c/updated-after-authorize-1 it('RSA16c - tokenDetails updated after authorize()', async function () { let tokenCount = 0; @@ -181,12 +187,65 @@ describe('uts/rest/auth/token_details', function () { expect(firstToken!.token).to.not.equal(secondToken!.token); }); + /** + * RSA16c - tokenDetails updated after library-initiated renewal on expiry + * + * When the token expires (client-side check) and a new request is made, + * the library proactively renews the token. tokenDetails should reflect + * the new token. + */ + // UTS: rest/unit/RSA16c/updated-after-expiry-renewal-2 + it('RSA16c - tokenDetails updated after expiry renewal', async function () { + const clock = enableFakeTimers(); + let tokenCount = 0; + + installMockHttp(simpleMock()); + + const client = new Ably.Rest({ + authCallback: function (params: any, callback: any) { + tokenCount++; + callback(null, { + token: 'token-v' + tokenCount, + expires: clock.now + 1000, + issued: clock.now, + clientId: 'client-v' + tokenCount, + } as any); + }, + } as any); + + // RSA4b1: client-side expiry check requires serverTimeOffset to be set + (client as any).serverTimeOffset = 0; + + // First request gets initial token + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const firstToken = client.auth.tokenDetails; + + // Advance time past token expiry + clock.tick(2000); + + // Second request should trigger renewal due to client-side expiry check + try { + await client.stats({} as any); + } catch (e) { + /* ok */ + } + const secondToken = client.auth.tokenDetails; + + expect(firstToken!.token).to.equal('token-v1'); + expect(secondToken!.token).to.equal('token-v2'); + }); + /** * RSA16c - tokenDetails updated after library-initiated renewal on 40142 * * When a request fails with 40142 (token expired), the library renews * the token and tokenDetails should reflect the new token. */ + // UTS: rest/unit/RSA16c/updated-after-40142-renewal-3 it('RSA16c - tokenDetails updated after 40142 renewal', async function () { let requestCount = 0; let tokenCount = 0; @@ -235,12 +294,13 @@ describe('uts/rest/auth/token_details', function () { }); /** - * RSA16d - tokenDetails null after failed renewal attempt + * RSA16d - tokenDetails null after token invalidation * - * When a token is invalidated and renewal fails, tokenDetails - * should reflect the failure state. + * When a token error occurs and renewal fails (authCallback errors), + * tokenDetails should be null. */ - it('RSA16d - tokenDetails after failed renewal', async function () { + // UTS: rest/unit/RSA16d/null-after-invalidation-2 + it('RSA16d - tokenDetails null after invalidation', async function () { this.timeout(5000); let callbackCount = 0; let requestCount = 0; @@ -292,6 +352,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16d - tokenDetails null with basic auth */ + // UTS: rest/unit/RSA16d/null-with-basic-auth-0 it('RSA16d - tokenDetails null with basic auth', async function () { installMockHttp(simpleMock()); @@ -308,6 +369,7 @@ describe('uts/rest/auth/token_details', function () { /** * RSA16d - tokenDetails null before first token obtained */ + // UTS: rest/unit/RSA16d/null-before-token-obtained-1 it('RSA16d - tokenDetails null before first token', function () { installMockHttp(simpleMock()); @@ -321,9 +383,25 @@ describe('uts/rest/auth/token_details', function () { expect(client.auth.tokenDetails).to.satisfy((v: any) => v === null || v === undefined); }); + /** + * RSA16d - tokenDetails null after switching from token auth to basic auth + * + * When authorize() is called with a key and useTokenAuth: false, + * the client switches to basic auth and tokenDetails becomes null. + */ + // UTS: rest/unit/RSA16d/null-after-switch-to-basic-3 + it.skip('RSA16d - tokenDetails null after switch to basic auth', function () { + // DEVIATION: ably-js's authorize() always performs token auth — it cannot + // switch to basic auth. Calling authorize(null, { useTokenAuth: false }) + // throws "authOptions must include valid authentication parameters". + // The spec scenario (switching from token auth to basic auth mid-session) + // is not supported by ably-js. + }); + /** * Edge case: tokenDetails preserved across multiple successful requests */ + // UTS: rest/unit/RSA16a/preserved-across-requests-0 it('tokenDetails preserved across requests', async function () { installMockHttp(simpleMock()); @@ -368,6 +446,7 @@ describe('uts/rest/auth/token_details', function () { /** * Edge case: tokenDetails reflects capability from token */ + // UTS: rest/unit/RSA16a/reflects-capability-1 it('tokenDetails reflects capability', async function () { installMockHttp(simpleMock()); diff --git a/test/uts/rest/auth/token_renewal.test.ts b/test/uts/rest/unit/auth/token_renewal.test.ts similarity index 92% rename from test/uts/rest/auth/token_renewal.test.ts rename to test/uts/rest/unit/auth/token_renewal.test.ts index 7de9edbed..153c78489 100644 --- a/test/uts/rest/auth/token_renewal.test.ts +++ b/test/uts/rest/unit/auth/token_renewal.test.ts @@ -16,10 +16,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/auth/token_renewal', function () { +describe('uts/rest/unit/auth/token_renewal', function () { afterEach(function () { restoreAll(); }); @@ -30,6 +30,7 @@ describe('uts/rest/auth/token_renewal', function () { * When a request is rejected with 40142, the library obtains a new * token via authCallback and retries the request. */ + // UTS: rest/unit/RSA4b/renewal-on-40142-0 it('RSA4b - renewal on 40142 error', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -84,6 +85,7 @@ describe('uts/rest/auth/token_renewal', function () { /** * RSA4b - Token renewal on 40140 error */ + // UTS: rest/unit/RSA4b/renewal-on-40140-1 it('RSA4b - renewal on 40140 error', async function () { let callbackCount = 0; let requestCount = 0; @@ -126,6 +128,7 @@ describe('uts/rest/auth/token_renewal', function () { * When the client has only a static token and no way to renew, * the error should be indicated with code 40171 (not retry). */ + // UTS: rest/unit/RSA4a2/no-renewal-without-callback-0 it('RSA4a2 - no renewal without callback', async function () { this.timeout(5000); let requestCount = 0; @@ -158,6 +161,7 @@ describe('uts/rest/auth/token_renewal', function () { /** * RSA4b - Renewal with authUrl */ + // UTS: rest/unit/RSA4b/renewal-via-authurl-2 it('RSA4b - renewal with authUrl', async function () { let authUrlCallCount = 0; let apiRequestCount = 0; @@ -202,6 +206,7 @@ describe('uts/rest/auth/token_renewal', function () { * Uses requestCount-based mocking to avoid triggering the ably-js * header-overwrite bug (see deviations.md). */ + // UTS: rest/unit/RSC10/request-retried-after-renewal-0 it('RSC10 - transparent retry after renewal', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -258,6 +263,7 @@ describe('uts/rest/auth/token_renewal', function () { * Only errors with codes 40140-40149 trigger renewal. Other 401 * errors (e.g. 40100) are propagated immediately. */ + // UTS: rest/unit/RSC10b/non-token-401-no-renewal-0 it('RSC10 - non-token 401 no renewal', async function () { let callbackCount = 0; let requestCount = 0; @@ -302,6 +308,7 @@ describe('uts/rest/auth/token_renewal', function () { * This test verifies the full flow: expired token → server rejection → * renewal → successful retry. */ + // UTS: rest/unit/RSA4b1/preemptive-renewal-0 it('RSA4b1 - renewal when expired token is rejected', async function () { let callbackCount = 0; let requestCount = 0; @@ -359,6 +366,17 @@ describe('uts/rest/auth/token_renewal', function () { expect(requestCount).to.equal(2); }); + /** + * RSA4b - Token renewal with msgpack error response + * + * DEVIATION: ably-js does not support msgpack protocol + */ + // UTS: rest/unit/RSA4b/renewal-msgpack-response-4 + it.skip('RSA4b - token renewal with msgpack error response (msgpack not supported)', function () { + // DEVIATION: ably-js does not support msgpack protocol + }); + + /** * RSA4b - Renewal limit (max 1 retry per spec) * @@ -369,6 +387,7 @@ describe('uts/rest/auth/token_renewal', function () { * this causes an infinite loop. The authCallback caps retries to * prevent OOM. See deviations.md. */ + // UTS: rest/unit/RSA4b/renewal-limit-no-loop-3 it('RSA4b - renewal limit', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); diff --git a/test/uts/rest/auth/token_request_params.test.ts b/test/uts/rest/unit/auth/token_request_params.test.ts similarity index 86% rename from test/uts/rest/auth/token_request_params.test.ts rename to test/uts/rest/unit/auth/token_request_params.test.ts index 816e2e4b5..39e1d4e6f 100644 --- a/test/uts/rest/auth/token_request_params.test.ts +++ b/test/uts/rest/unit/auth/token_request_params.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/auth/token_request_params', function () { +describe('uts/rest/unit/auth/token_request_params', function () { afterEach(function () { restoreAll(); }); @@ -30,6 +30,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA5 - TTL is null when not specified */ + // UTS: rest/unit/RSA5/ttl-null-when-unspecified-0 it('RSA5 - TTL is null when not specified', async function () { setup(); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -42,6 +43,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA5b - Explicit TTL is preserved */ + // UTS: rest/unit/RSA5b/explicit-ttl-preserved-0 it('RSA5b - Explicit TTL is preserved', async function () { setup(); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -53,6 +55,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA5c - TTL from defaultTokenParams is used */ + // UTS: rest/unit/RSA5c/ttl-from-default-params-0 it('RSA5c - TTL from defaultTokenParams is used', async function () { setup(); const client = new Ably.Rest({ @@ -67,6 +70,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA5d - Explicit TTL overrides defaultTokenParams */ + // UTS: rest/unit/RSA5d/explicit-ttl-overrides-default-0 it('RSA5d - Explicit TTL overrides defaultTokenParams', async function () { setup(); const client = new Ably.Rest({ @@ -81,6 +85,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA6 - Capability is null when not specified */ + // UTS: rest/unit/RSA6/capability-null-when-unspecified-0 it('RSA6 - Capability is null when not specified', async function () { setup(); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -93,6 +98,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA6b - Explicit capability is preserved */ + // UTS: rest/unit/RSA6b/explicit-capability-preserved-0 it('RSA6b - Explicit capability is preserved', async function () { setup(); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -107,6 +113,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA6c - Capability from defaultTokenParams is used */ + // UTS: rest/unit/RSA6c/capability-from-default-params-0 it('RSA6c - Capability from defaultTokenParams is used', async function () { setup(); const client = new Ably.Rest({ @@ -121,6 +128,7 @@ describe('uts/rest/auth/token_request_params', function () { /** * RSA6d - Explicit capability overrides defaultTokenParams */ + // UTS: rest/unit/RSA6d/explicit-capability-overrides-default-0 it('RSA6d - Explicit capability overrides defaultTokenParams', async function () { setup(); const client = new Ably.Rest({ diff --git a/test/uts/rest/batch_presence.test.ts b/test/uts/rest/unit/batch_presence.test.ts similarity index 87% rename from test/uts/rest/batch_presence.test.ts rename to test/uts/rest/unit/batch_presence.test.ts index 26e5d3755..0f3d2694e 100644 --- a/test/uts/rest/batch_presence.test.ts +++ b/test/uts/rest/unit/batch_presence.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/batch_presence', function () { +describe('uts/rest/unit/batch_presence', function () { afterEach(function () { restoreAll(); }); @@ -22,6 +22,7 @@ describe('uts/rest/batch_presence', function () { // --------------------------------------------------------------------------- describe('RSC24 - batchPresence sends GET to /presence', function () { + // UTS: rest/unit/RSC24/get-presence-channels-param-0 it('RSC24_1 - sends GET request to /presence with channels query param', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -49,6 +50,7 @@ describe('uts/rest/batch_presence', function () { expect(captured[0].url.searchParams.get('channels')).to.equal('channel-a,channel-b'); }); + // UTS: rest/unit/RSC24/single-channel-param-0 it('RSC24_2 - single channel sends GET with single channel name', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -71,6 +73,7 @@ describe('uts/rest/batch_presence', function () { expect(captured[0].url.searchParams.get('channels')).to.equal('my-channel'); }); + // UTS: rest/unit/RSC24/special-chars-comma-joined-0 it('RSC24_3 - channel names with special characters are comma-joined', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -104,6 +107,7 @@ describe('uts/rest/batch_presence', function () { // --------------------------------------------------------------------------- describe('BAR2 - BatchPresenceResponse structure', function () { + // UTS: rest/unit/BAR2/all-success-counts-0 it('BAR2_2 - all success', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -131,18 +135,18 @@ describe('uts/rest/batch_presence', function () { /** * BAR2_1 - Mixed results with computed counts * - * Per spec: the SDK should normalise the HTTP 400 response containing - * {error, batchResponse} into {successCount, failureCount, results}. + * With X-Ably-Version >= 3, the server returns {successCount, failureCount, + * results} directly with HTTP 200 — the SDK passes through the response. */ - it('BAR2_1 - mixed results normalised', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); + // UTS: rest/unit/BAR2/mixed-success-failure-counts-0 + it('BAR2_1 - mixed results', async function () { const mock = new 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: 3, + failureCount: 1, + results: [ { channel: 'ch-1', presence: [] }, { channel: 'ch-2', presence: [] }, { channel: 'ch-3', presence: [] }, @@ -164,18 +168,18 @@ describe('uts/rest/batch_presence', function () { /** * BAR2_3 - All failure * - * Per spec: the SDK should normalise the HTTP 400 response into - * {successCount: 0, failureCount: N, results}. + * With X-Ably-Version >= 3, the server returns the BatchResult envelope + * with HTTP 200 even when all results are failures. */ - it('BAR2_3 - all failure normalised', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); + // UTS: rest/unit/BAR2/all-failure-counts-0 + it('BAR2_3 - all failure', async function () { const mock = new 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: [ { channel: 'ch-a', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, { channel: 'ch-b', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, ], @@ -198,6 +202,7 @@ describe('uts/rest/batch_presence', function () { // --------------------------------------------------------------------------- describe('BGR2 - BatchPresenceSuccessResult structure', function () { + // UTS: rest/unit/BGR2/success-with-members-0 it('BGR2_1 - success result with members present', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -246,6 +251,7 @@ describe('uts/rest/batch_presence', function () { expect(success.presence[1].clientId).to.equal('client-2'); }); + // UTS: rest/unit/BGR2/success-empty-presence-0 it('BGR2_2 - success result with empty presence (no members)', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -275,19 +281,16 @@ describe('uts/rest/batch_presence', function () { describe('BGF2 - BatchPresenceFailureResult structure', function () { /** * BGF2_1 - Failure result with error details - * - * Per spec: the SDK should normalise the HTTP 400 response so that - * per-channel failure results with error details are accessible. */ - it('BGF2_1 - failure result normalised with error details', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); + // UTS: rest/unit/BGF2/failure-error-details-0 + it('BGF2_1 - failure result with error details', async function () { const mock = new 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: [ { channel: 'restricted-channel', error: { @@ -320,19 +323,16 @@ describe('uts/rest/batch_presence', function () { describe('Mixed results', function () { /** * RSC24_Mixed_1 - Mixed success and failure results - * - * Per spec: the SDK should normalise the batchResponse into per-channel - * success/failure results with computed counts. */ - it('RSC24_Mixed_1 - mixed results normalised', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); + // UTS: rest/unit/RSC24/mixed-success-failure-results-0 + it('RSC24_Mixed_1 - mixed success and failure results', async function () { const mock = new 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: [ { channel: 'allowed-channel', presence: [ @@ -373,6 +373,7 @@ describe('uts/rest/batch_presence', function () { // --------------------------------------------------------------------------- describe('Error handling', function () { + // UTS: rest/unit/RSC24/server-error-propagated-0 it('RSC24_Error_1 - server error is propagated', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -397,6 +398,7 @@ describe('uts/rest/batch_presence', function () { expect(threw).to.be.true; }); + // UTS: rest/unit/RSC24/auth-error-propagated-0 it('RSC24_Error_2 - authentication error is propagated', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -427,6 +429,7 @@ describe('uts/rest/batch_presence', function () { // --------------------------------------------------------------------------- describe('RSC24_Auth - request authentication', function () { + // UTS: rest/unit/RSC24/uses-configured-auth-0 it('RSC24_Auth_1 - basic auth header is included', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/batch_publish.test.ts b/test/uts/rest/unit/batch_publish.test.ts similarity index 71% rename from test/uts/rest/batch_publish.test.ts rename to test/uts/rest/unit/batch_publish.test.ts index f2e114b47..202b2df93 100644 --- a/test/uts/rest/batch_publish.test.ts +++ b/test/uts/rest/unit/batch_publish.test.ts @@ -8,10 +8,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/batch_publish', function () { +describe('uts/rest/unit/batch_publish', function () { afterEach(function () { restoreAll(); }); @@ -21,6 +21,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('RSC22c - batchPublish sends POST to /messages', function () { + // UTS: rest/unit/RSC22c/single-spec-post-messages-0 it('RSC22c1 - single BatchPublishSpec sends POST to /messages', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -55,6 +56,7 @@ describe('uts/rest/batch_publish', function () { expect(body[0].messages[0].data).to.equal('hello'); }); + // UTS: rest/unit/RSC22c/array-specs-array-results-0 it('RSC22c2 - array of BatchPublishSpecs sends POST to /messages', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -95,6 +97,7 @@ describe('uts/rest/batch_publish', function () { expect(body[1].messages[0].name).to.equal('e2'); }); + // UTS: rest/unit/RSC22c/single-spec-single-result-0 it('RSC22c3 - single spec returns single BatchResult (not array)', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -125,6 +128,7 @@ describe('uts/rest/batch_publish', function () { expect(result.results[0].channel).to.equal('ch1'); }); + // UTS: rest/unit/RSC22c/array-specs-post-messages-0 it('RSC22c4 - array of specs returns array of BatchResults', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -158,6 +162,89 @@ describe('uts/rest/batch_publish', function () { expect((results[1].results[0] as any).messageId).to.equal('msg2'); }); + // UTS: rest/unit/RSC22/multiple-channels-multiple-messages-0 + it('RSC22 - multiple channels with multiple messages', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 3, + failureCount: 0, + results: [ + { channel: 'ch-1', messageId: 'msg1', serials: ['s1a', 's1b', 's1c'] }, + { channel: 'ch-2', messageId: 'msg2', serials: ['s2a', 's2b', 's2c'] }, + { channel: 'ch-3', messageId: 'msg3', serials: ['s3a', 's3b', 's3c'] }, + ], + }, + { + successCount: 3, + failureCount: 0, + results: [ + { channel: 'ch-4', messageId: 'msg4', serials: ['s4a', 's4b'] }, + { channel: 'ch-5', messageId: 'msg5', serials: ['s5a', 's5b'] }, + { channel: 'ch-6', messageId: 'msg6', serials: ['s6a', 's6b'] }, + ], + }, + { + successCount: 3, + failureCount: 0, + results: [ + { channel: 'ch-7', messageId: 'msg7', serials: ['s7a'] }, + { channel: 'ch-8', messageId: 'msg8', serials: ['s8a'] }, + { channel: 'ch-9', messageId: 'msg9', serials: ['s9a'] }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const results = await client.batchPublish([ + { + channels: ['ch-1', 'ch-2', 'ch-3'], + messages: [ + { name: 'e1', data: 'd1' }, + { name: 'e2', data: 'd2' }, + { name: 'e3', data: 'd3' }, + ], + }, + { + channels: ['ch-4', 'ch-5', 'ch-6'], + messages: [ + { name: 'e4', data: 'd4' }, + { name: 'e5', data: 'd5' }, + ], + }, + { + channels: ['ch-7', 'ch-8', 'ch-9'], + messages: [{ name: 'e6', data: 'd6' }], + }, + ]); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body).to.be.an('array').with.lengthOf(3); + expect(body[0].channels).to.deep.equal(['ch-1', 'ch-2', 'ch-3']); + expect(body[0].messages).to.have.lengthOf(3); + expect(body[1].channels).to.deep.equal(['ch-4', 'ch-5', 'ch-6']); + expect(body[1].messages).to.have.lengthOf(2); + expect(body[2].channels).to.deep.equal(['ch-7', 'ch-8', 'ch-9']); + expect(body[2].messages).to.have.lengthOf(1); + + expect(results).to.be.an('array').with.lengthOf(3); + expect(results[0].successCount).to.equal(3); + expect(results[0].results).to.have.lengthOf(3); + expect(results[1].successCount).to.equal(3); + expect(results[1].results).to.have.lengthOf(3); + expect(results[2].successCount).to.equal(3); + expect(results[2].results).to.have.lengthOf(3); + }); + + // UTS: rest/unit/RSC22c/multiple-channels-multiple-results-0 it('RSC22c5 - multiple channels in spec produces multiple results', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -196,6 +283,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('RSC22c7 - authentication', function () { + // UTS: rest/unit/RSC22c/uses-configured-auth-0 it('RSC22c7 - basic auth header is included', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -230,6 +318,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('BPR - BatchPublishSuccessResult structure', function () { + // UTS: rest/unit/BPR2a/success-channel-name-0 it('BPR2a - channel field contains channel name', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -254,6 +343,7 @@ describe('uts/rest/batch_publish', function () { expect(result.results[0].channel).to.equal('my-channel'); }); + // UTS: rest/unit/BPR2b/success-message-id-prefix-0 it('BPR2b - messageId contains the message ID prefix', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -281,6 +371,7 @@ describe('uts/rest/batch_publish', function () { expect((result.results[0] as any).messageId).to.equal('unique-id-prefix'); }); + // UTS: rest/unit/BPR2c/serials-array-0 it('BPR2c - serials contains array of message serials', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -309,6 +400,7 @@ describe('uts/rest/batch_publish', function () { expect((result.results[0] as any).serials).to.deep.equal(['serial1', 'serial2', 'serial3']); }); + // UTS: rest/unit/BPR2c/serials-null-conflated-0 it('BPR2c1 - serials may contain null for conflated messages', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -343,6 +435,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('BPF - BatchPublishFailureResult structure', function () { + // UTS: rest/unit/BPF2a/failure-channel-name-0 it('BPF2a - channel field contains failed channel name', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -369,6 +462,7 @@ describe('uts/rest/batch_publish', function () { expect(result.results[0].channel).to.equal('restricted-ch'); }); + // UTS: rest/unit/BPF2b/failure-error-info-0 it('BPF2b - error contains ErrorInfo for failure reason', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -411,6 +505,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('BatchResult - mixed success and failure', function () { + // UTS: rest/unit/RSC22c/partial-success-mixed-results-0 it('BatchResult1 - partial success with mixed results', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -449,6 +544,54 @@ describe('uts/rest/batch_publish', function () { expect((result.results[1] as any).error.code).to.equal(40160); expect('messageId' in result.results[1]).to.be.false; }); + + // UTS: rest/unit/RSC22c/distinguish-success-failure-0 + it('BatchResult2 - distinguish success from failure results', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + successCount: 2, + failureCount: 1, + results: [ + { channel: 'ch-ok-1', messageId: 'msg1', serials: ['s1'] }, + { channel: 'ch-fail', error: { code: 40160, statusCode: 401, message: 'Not permitted' } }, + { channel: 'ch-ok-2', messageId: 'msg2', serials: ['s2'] }, + ], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const result = await client.batchPublish({ + channels: ['ch-ok-1', 'ch-fail', 'ch-ok-2'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(result.successCount).to.equal(2); + expect(result.failureCount).to.equal(1); + expect(result.results).to.have.lengthOf(3); + + // Distinguish success results (have messageId/serials, no error) + const successResults = result.results.filter((r: any) => 'messageId' in r); + const failureResults = result.results.filter((r: any) => 'error' in r); + + expect(successResults).to.have.lengthOf(2); + expect(failureResults).to.have.lengthOf(1); + + expect(successResults[0].channel).to.equal('ch-ok-1'); + expect((successResults[0] as any).messageId).to.equal('msg1'); + expect((successResults[0] as any).serials).to.deep.equal(['s1']); + + expect(successResults[1].channel).to.equal('ch-ok-2'); + expect((successResults[1] as any).messageId).to.equal('msg2'); + + expect(failureResults[0].channel).to.equal('ch-fail'); + expect((failureResults[0] as any).error.code).to.equal(40160); + }); }); // --------------------------------------------------------------------------- @@ -456,6 +599,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('Error handling', function () { + // UTS: rest/unit/RSC22/server-error-propagated-0 it('RSC22_Error3 - server error returns error', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -483,6 +627,7 @@ describe('uts/rest/batch_publish', function () { expect(threw).to.be.true; }); + // UTS: rest/unit/RSC22/auth-error-propagated-0 it('RSC22_Error4 - authentication error returns error', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -516,6 +661,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('RSC22_Headers - request headers', function () { + // UTS: rest/unit/RSC22/standard-headers-included-0 it('RSC22_Headers1 - standard headers included', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -547,11 +693,53 @@ describe('uts/rest/batch_publish', function () { }); }); + // --------------------------------------------------------------------------- + // RSC22_Headers2 - request_id when addRequestIds enabled + // --------------------------------------------------------------------------- + + describe('RSC22_Headers2 - request_id', function () { + // UTS: rest/unit/RSC22/request-id-included-0 + it('RSC22_Headers2 - request_id included when addRequestIds enabled', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch', messageId: 'msg', serials: ['s'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + addRequestIds: true, + } as any); + await client.batchPublish({ + channels: ['ch'], + messages: [{ name: 'e', data: 'd' }], + }); + + expect(captured).to.have.length(1); + const requestId = captured[0].url.searchParams.get('request_id'); + expect(requestId).to.be.a('string').and.not.be.empty; + }); + }); + // --------------------------------------------------------------------------- // BSP - BatchPublishSpec structure // --------------------------------------------------------------------------- describe('BSP - BatchPublishSpec structure', function () { + // UTS: rest/unit/BSP2a/channels-array-strings-0 it('BSP2a - channels is array of strings', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -584,6 +772,7 @@ describe('uts/rest/batch_publish', function () { expect(body[0].channels).to.deep.equal(['ch-a', 'ch-b', 'ch-c']); }); + // UTS: rest/unit/BSP2b/messages-array-objects-0 it('BSP2b - messages is array of Message objects', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -629,6 +818,7 @@ describe('uts/rest/batch_publish', function () { * Per spec: "If idempotentRestPublishing is enabled, then RSL1k1 should * be applied (to each BatchPublishSpec separately)." */ + // UTS: rest/unit/RSC22d/idempotent-ids-generated-0 it('RSC22d - batch publish generates idempotent IDs', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -663,6 +853,76 @@ describe('uts/rest/batch_publish', function () { expect(body[0].messages[0]).to.have.property('id'); expect(body[0].messages[0].id).to.match(/^.+:0$/); }); + + // UTS: rest/unit/RSC22d/explicit-ids-preserved-0 + it('RSC22d - explicit message IDs preserved when idempotent publishing enabled', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch1', messageId: 'msg-id-1', serials: ['s1', 's2'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: true, + }); + await client.batchPublish({ + channels: ['ch1'], + messages: [ + { name: 'event1', data: 'test1', id: 'my-explicit-id-1' }, + { name: 'event2', data: 'test2', id: 'my-explicit-id-2' }, + ], + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body[0].messages[0].id).to.equal('my-explicit-id-1'); + expect(body[0].messages[1].id).to.equal('my-explicit-id-2'); + }); + + // UTS: rest/unit/RSC22d/ids-not-generated-disabled-0 + it('RSC22d - IDs not generated when idempotent publishing disabled', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + successCount: 1, + failureCount: 0, + results: [{ channel: 'ch1', messageId: 'msg-id-1', serials: ['s1'] }], + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + idempotentRestPublishing: false, + }); + await client.batchPublish({ + channels: ['ch1'], + messages: [{ name: 'event', data: 'data' }], + }); + + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body[0].messages[0]).to.not.have.property('id'); + }); }); // --------------------------------------------------------------------------- @@ -670,6 +930,43 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('RSC22_Error - edge cases', function () { + // UTS: rest/unit/RSC22/empty-messages-rejected-0 + it('RSC22_Error2 - empty messages array rejected', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(400, { + error: { code: 40000, statusCode: 400, message: 'No messages specified' }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + let threw = false; + try { + await client.batchPublish({ + channels: ['ch1'], + messages: [], + }); + } catch (err: any) { + threw = true; + // Either the SDK validates locally or the server rejects it + expect(err.code).to.be.a('number'); + } + + // Either an error is thrown or the request was made with the empty array + if (!threw) { + expect(captured).to.have.length(1); + const body = JSON.parse(captured[0].body); + expect(body[0].messages).to.deep.equal([]); + } + }); + + // UTS: rest/unit/RSC22/empty-channels-rejected-0 it('RSC22_Error1 - empty channels array', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -711,6 +1008,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('RSC22c6 - encoding in batch messages', function () { + // UTS: rest/unit/RSC22c/messages-encoded-per-rsl4-0 it('RSC22c6 - JSON string data sent correctly in body', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -748,6 +1046,7 @@ describe('uts/rest/batch_publish', function () { // --------------------------------------------------------------------------- describe('BSP - additional BatchPublishSpec tests', function () { + // UTS: rest/unit/RSC22/multiple-messages-per-channel-0 it('BSP - single channel in BatchPublishSpec', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channel/annotations.test.ts b/test/uts/rest/unit/channel/annotations.test.ts similarity index 94% rename from test/uts/rest/channel/annotations.test.ts rename to test/uts/rest/unit/channel/annotations.test.ts index 80b5ac6bc..17b2104e9 100644 --- a/test/uts/rest/channel/annotations.test.ts +++ b/test/uts/rest/unit/channel/annotations.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/channel/annotations', function () { +describe('uts/rest/unit/channel/annotations', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/rest/channel/annotations', function () { * The channel must expose an annotations attribute that is an object * (specifically a RestAnnotations instance). */ + // UTS: rest/unit/RSL10/annotations-attribute-type-0 it('RSL10 - channel.annotations is accessible', function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -42,6 +43,7 @@ describe('uts/rest/channel/annotations', function () { * with the annotation body containing action=0 (ANNOTATION_CREATE), * the messageSerial, type, and name fields. */ + // UTS: rest/unit/RSAN1c6/publish-post-annotation-create-0 it('RSAN1 - publish sends POST with ANNOTATION_CREATE', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -81,6 +83,7 @@ describe('uts/rest/channel/annotations', function () { * requirement (RSAN1a3) as a known deviation — the publish succeeds * without a type instead of throwing. */ + // UTS: rest/unit/RSAN1a3/publish-type-required-0 it('RSAN1a3 - type required', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -114,6 +117,7 @@ describe('uts/rest/channel/annotations', function () { * JSON string with the encoding field set to 'json', following RSL4 * message encoding rules. */ + // UTS: rest/unit/RSAN1c3/annotation-data-encoded-0 it('RSAN1c3 - data encoded per RSL4', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -149,6 +153,7 @@ describe('uts/rest/channel/annotations', function () { * annotations (only for messages via RestChannel.publish). This test * documents the spec requirement as a known deviation. */ + // UTS: rest/unit/RSAN1c4/idempotent-id-not-generated-1 it('RSAN1c4 - idempotent ID generated', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -191,6 +196,7 @@ describe('uts/rest/channel/annotations', function () { * When idempotentRestPublishing is false, no idempotent ID should * be generated on the annotation. */ + // UTS: rest/unit/RSAN1c4/idempotent-id-generated-0 it('RSAN1c4 - no ID when disabled', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -222,6 +228,7 @@ describe('uts/rest/channel/annotations', function () { * annotations.delete() must send a POST request with * action=1 (ANNOTATION_DELETE) to the correct endpoint. */ + // UTS: rest/unit/RSAN1c6/publish-post-annotation-create-0.1 it('RSAN2a - delete sends POST with ANNOTATION_DELETE', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -256,6 +263,7 @@ describe('uts/rest/channel/annotations', function () { * annotations.get() must send a GET request to * /channels/{channelName}/messages/{messageSerial}/annotations. */ + // UTS: rest/unit/RSL10/annotations-attribute-type-0.1 it('RSAN3b - get sends GET to correct path', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -293,6 +301,7 @@ describe('uts/rest/channel/annotations', function () { * The response must be parsed into a PaginatedResult containing * Annotation objects with all expected fields decoded. */ + // UTS: rest/unit/RSL10/annotations-attribute-type-0.2 it('RSAN3c - get returns PaginatedResult with annotation fields', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -363,6 +372,7 @@ describe('uts/rest/channel/annotations', function () { * Optional params passed to annotations.get() must be sent as * query string parameters on the GET request. */ + // UTS: rest/unit/RSL10/annotations-attribute-type-0.3 it('RSAN3b - get passes params as querystring', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channel/get_message.test.ts b/test/uts/rest/unit/channel/get_message.test.ts similarity index 92% rename from test/uts/rest/channel/get_message.test.ts rename to test/uts/rest/unit/channel/get_message.test.ts index d1c5305ec..6bba3cb11 100644 --- a/test/uts/rest/channel/get_message.test.ts +++ b/test/uts/rest/unit/channel/get_message.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/channel/getMessage', function () { +describe('uts/rest/unit/channel/getMessage', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/rest/channel/getMessage', function () { * getMessage(serial) must send a GET request to * /channels/{channelName}/messages/{serial}. */ + // UTS: rest/unit/RSL11b/get-correct-endpoint-0 it('RSL11b - GET to correct path', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -51,6 +52,7 @@ describe('uts/rest/channel/getMessage', function () { * getMessage must return a single Message object with all fields * decoded from the response body. */ + // UTS: rest/unit/RSL11c/returns-decoded-message-0 it('RSL11c - returns decoded Message', async function () { const responseBody = { id: 'msg-id-1', @@ -93,6 +95,7 @@ describe('uts/rest/channel/getMessage', function () { * When the serial contains characters that are not URL-safe, * getMessage must URL-encode the serial in the request path. */ + // UTS: rest/unit/RSL11b/url-encodes-serial-1 it('RSL11b - URL-encodes serial', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -126,6 +129,7 @@ describe('uts/rest/channel/getMessage', function () { * getMessage must throw an error with code 40003 when called * with an empty serial string. */ + // UTS: rest/unit/RSL11a/missing-serial-error-0 it('RSL11a - empty serial throws 40003', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/rest/channel/history.test.ts b/test/uts/rest/unit/channel/history.test.ts similarity index 92% rename from test/uts/rest/channel/history.test.ts rename to test/uts/rest/unit/channel/history.test.ts index 9899a875f..840a6cda9 100644 --- a/test/uts/rest/channel/history.test.ts +++ b/test/uts/rest/unit/channel/history.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/channel/history', function () { +describe('uts/rest/unit/channel/history', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/rest/channel/history', function () { * The history() method must return a PaginatedResult containing * Message objects deserialized from the response. */ + // UTS: rest/unit/RSL2a/returns-paginated-result-0 it('RSL2a - history returns PaginatedResult', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -49,6 +50,7 @@ describe('uts/rest/channel/history', function () { * The start parameter is an optional timestamp (ms since epoch) * that filters messages to those published at or after that time. */ + // UTS: rest/unit/RSL2b/query-parameters-0 it('RSL2b - history with start parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -74,6 +76,7 @@ describe('uts/rest/channel/history', function () { * The end parameter is an optional timestamp (ms since epoch) * that filters messages to those published at or before that time. */ + // UTS: rest/unit/RSL2b/query-parameters-0.1 it('RSL2b - history with end parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -99,6 +102,7 @@ describe('uts/rest/channel/history', function () { * The direction parameter controls the ordering of results: * 'forwards' or 'backwards'. */ + // UTS: rest/unit/RSL2b/query-parameters-0.2 it('RSL2b - history with direction parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -121,6 +125,7 @@ describe('uts/rest/channel/history', function () { /** * RSL2b - history with direction: backwards */ + // UTS: rest/unit/RSL2b1/default-direction-backwards-0.1 it('RSL2b - history with direction backwards', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -146,6 +151,7 @@ describe('uts/rest/channel/history', function () { * When direction is not specified, it defaults to 'backwards' * (either omitted from the query or sent as 'backwards'). */ + // UTS: rest/unit/RSL2b1/default-direction-backwards-0 it('RSL2b1 - default direction is backwards', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -171,6 +177,7 @@ describe('uts/rest/channel/history', function () { * * The limit parameter controls the maximum number of results returned. */ + // UTS: rest/unit/RSL2b2/limit-parameter-0 it('RSL2b2 - limit parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -196,6 +203,7 @@ describe('uts/rest/channel/history', function () { * When limit is not specified, it defaults to 100 * (either omitted from the query or sent as '100'). */ + // UTS: rest/unit/RSL2b3/default-limit-hundred-0 it('RSL2b3 - default limit', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -222,6 +230,7 @@ describe('uts/rest/channel/history', function () { * Channel names containing special characters must be properly * URL-encoded in the request path. */ + // UTS: rest/unit/RSL2/request-url-format-0 it('RSL2 - URL encoding of channel name', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -246,6 +255,7 @@ describe('uts/rest/channel/history', function () { /** * RSL2 - History with combined time range (start and end) */ + // UTS: rest/unit/RSL2/history-time-range-1 it('RSL2 - history with start and end time range', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -268,6 +278,7 @@ describe('uts/rest/channel/history', function () { /** * RSL2 - URL encoding with colon in channel name */ + // UTS: rest/unit/RSL2/request-url-format-0.1 it('RSL2 - URL encoding with colon', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -289,6 +300,7 @@ describe('uts/rest/channel/history', function () { /** * RSL2 - URL encoding with slash in channel name */ + // UTS: rest/unit/RSL2/request-url-format-0.2 it('RSL2 - URL encoding with slash', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channel/idempotency.test.ts b/test/uts/rest/unit/channel/idempotency.test.ts similarity index 94% rename from test/uts/rest/channel/idempotency.test.ts rename to test/uts/rest/unit/channel/idempotency.test.ts index 2382100bb..eb6c52d1b 100644 --- a/test/uts/rest/channel/idempotency.test.ts +++ b/test/uts/rest/unit/channel/idempotency.test.ts @@ -6,12 +6,12 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; const Message = Ably.Rest.Message; -describe('uts/rest/channel/idempotency', function () { +describe('uts/rest/unit/channel/idempotency', function () { afterEach(function () { restoreAll(); }); @@ -21,6 +21,7 @@ describe('uts/rest/channel/idempotency', function () { * * The idempotentRestPublishing option must default to true. */ + // UTS: rest/unit/RSL1k1/idempotent-default-true-0 it('RSL1k1 - idempotentRestPublishing defaults to true', function () { const client = new Ably.Rest({ key: 'a.b:c' }); expect(client.options.idempotentRestPublishing).to.equal(true); @@ -34,6 +35,7 @@ describe('uts/rest/channel/idempotency', function () { * :, where is at least 12 characters of * URL-safe base64 and starts at 0. */ + // UTS: rest/unit/RSL1k2/message-id-format-0 it('RSL1k2 - message ID format', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -78,6 +80,7 @@ describe('uts/rest/channel/idempotency', function () { * When publishing an array of messages, each message must share the * same base ID but have incrementing serial numbers starting from 0. */ + // UTS: rest/unit/RSL1k2/serial-increments-batch-1 it('RSL1k2 - batch serial increments', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -125,6 +128,7 @@ describe('uts/rest/channel/idempotency', function () { * Each separate publish call must generate a unique base ID so that * publishes are independently idempotent. */ + // UTS: rest/unit/RSL1k3/unique-base-ids-0 it('RSL1k3 - separate publishes get unique base IDs', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -160,6 +164,7 @@ describe('uts/rest/channel/idempotency', function () { * When idempotentRestPublishing is false, the library must NOT * generate message IDs. */ + // UTS: rest/unit/RSL1k3/no-id-when-disabled-1 it('RSL1k3 - no ID when disabled', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -192,6 +197,7 @@ describe('uts/rest/channel/idempotency', function () { * When a message is published with a client-supplied ID, the library * must preserve it and not overwrite it with a generated ID. */ + // UTS: rest/unit/RSL1k/client-id-preserved-0 it('RSL1k - client-supplied ID preserved', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -228,6 +234,7 @@ describe('uts/rest/channel/idempotency', function () { * If ably-js does not retry on 500, we verify the ID format on the * single request. */ + // UTS: rest/unit/RSL1k2/same-id-on-retry-2 it('RSL1k2 - same ID on retry', async function () { const captured: any[] = []; let requestCount = 0; @@ -272,6 +279,7 @@ describe('uts/rest/channel/idempotency', function () { * check). Client-supplied IDs are preserved; messages without IDs * remain without IDs. */ + // UTS: rest/unit/RSL1k/mixed-ids-in-batch-1 it('RSL1k - mixed client and library IDs skips generation', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channel/message_versions.test.ts b/test/uts/rest/unit/channel/message_versions.test.ts similarity index 92% rename from test/uts/rest/channel/message_versions.test.ts rename to test/uts/rest/unit/channel/message_versions.test.ts index 2d6116559..888683f22 100644 --- a/test/uts/rest/channel/message_versions.test.ts +++ b/test/uts/rest/unit/channel/message_versions.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/channel/getMessageVersions', function () { +describe('uts/rest/unit/channel/getMessageVersions', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/rest/channel/getMessageVersions', function () { * getMessageVersions(serial) must send a GET request to * /channels/{channelName}/messages/{serial}/versions. */ + // UTS: rest/unit/RSL14b/get-correct-endpoint-0 it('RSL14b - GET to correct path', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -54,6 +55,7 @@ describe('uts/rest/channel/getMessageVersions', function () { * getMessageVersions must return a PaginatedResult containing * Message objects with version fields properly decoded. */ + // UTS: rest/unit/RSL14c/returns-paginated-result-0 it('RSL14c - returns PaginatedResult of Messages', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -107,6 +109,7 @@ describe('uts/rest/channel/getMessageVersions', function () { * Additional params passed to getMessageVersions must be included * as query string parameters on the request. */ + // UTS: rest/unit/RSL14a/params-as-querystring-0 it('RSL14a - params as querystring', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channel/publish.test.ts b/test/uts/rest/unit/channel/publish.test.ts similarity index 94% rename from test/uts/rest/channel/publish.test.ts rename to test/uts/rest/unit/channel/publish.test.ts index a489ff93c..f21892a99 100644 --- a/test/uts/rest/channel/publish.test.ts +++ b/test/uts/rest/unit/channel/publish.test.ts @@ -6,12 +6,12 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; const Message = Ably.Rest.Message; -describe('uts/rest/channel/publish', function () { +describe('uts/rest/unit/channel/publish', function () { afterEach(function () { restoreAll(); }); @@ -22,6 +22,7 @@ describe('uts/rest/channel/publish', function () { * Publishing a message on a channel must send a POST request * to /channels//messages. */ + // UTS: rest/unit/RSL1a/publish-name-and-data-0 it('RSL1a - publish sends POST to correct path', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -47,6 +48,7 @@ describe('uts/rest/channel/publish', function () { * * The POST body must contain the published message serialized as JSON. */ + // UTS: rest/unit/RSL1a/publish-name-and-data-0.1 it('RSL1b - publish body contains message', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -77,6 +79,7 @@ describe('uts/rest/channel/publish', function () { * Publishing an array of messages must send them all in a single * POST request, with the body containing all messages. */ + // UTS: rest/unit/RSL1a/publish-message-array-1 it('RSL1c - publish array sends single request', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -110,6 +113,7 @@ describe('uts/rest/channel/publish', function () { * * Per spec: "If any of the values are null, then key is not sent to Ably" */ + // UTS: rest/unit/RSL1e/null-name-and-data-0.1 it('RSL1e - null name omitted from body', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -140,6 +144,7 @@ describe('uts/rest/channel/publish', function () { * * Per spec: "If any of the values are null, then key is not sent to Ably" */ + // UTS: rest/unit/RSL1e/null-name-and-data-0.2 it('RSL1e - null data omitted from body', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -171,6 +176,7 @@ describe('uts/rest/channel/publish', function () { * The two-argument publish(name, data) form must produce a message * with both name and data fields in the request body. */ + // UTS: rest/unit/RSL1h/publish-signature-0 it('RSL1h - publish(name, data) two-arg form', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -201,6 +207,7 @@ describe('uts/rest/channel/publish', function () { * fail with error code 40009 without sending a request. Uses explicit * maxMessageSize for deterministic testing. */ + // UTS: rest/unit/RSL1i/message-size-limit-0 it('RSL1i - message size limit exceeded', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -239,6 +246,7 @@ describe('uts/rest/channel/publish', function () { * * A message at or under the size limit should succeed. */ + // UTS: rest/unit/RSL1i/message-size-limit-0.1 it('RSL1i - message at size limit succeeds', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -269,6 +277,7 @@ describe('uts/rest/channel/publish', function () { * When a message is constructed with all optional attributes * (id, clientId, extras), they must all appear in the request body. */ + // UTS: rest/unit/RSL1j/all-attributes-transmitted-0 it('RSL1j - all message attributes transmitted', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -311,6 +320,7 @@ describe('uts/rest/channel/publish', function () { * does not specify a clientId, the library must NOT auto-inject the * clientId into the message body (ably-js behaviour for REST). */ + // UTS: rest/unit/RSL1m/clientid-not-injected-0 it('RSL1m1 - library clientId not auto-injected', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -343,6 +353,7 @@ describe('uts/rest/channel/publish', function () { * When a client has a clientId and the message explicitly sets the * same clientId, it must be preserved in the request body. */ + // UTS: rest/unit/RSL1m/clientid-not-injected-0.1 it('RSL1m2 - explicit matching clientId preserved', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -377,6 +388,7 @@ describe('uts/rest/channel/publish', function () { * When a client has no clientId set but the message explicitly sets * a clientId, it must be preserved in the request body. */ + // UTS: rest/unit/RSL1m/clientid-not-injected-0.2 it('RSL1m3 - unidentified client with message clientId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -408,6 +420,7 @@ describe('uts/rest/channel/publish', function () { * The wire body should contain an empty message object (or one with * null fields). */ + // UTS: rest/unit/RSL1e/null-name-and-data-0 it('RSL1e - both name and data null', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -429,11 +442,13 @@ describe('uts/rest/channel/publish', function () { // The message should be essentially empty (name and data are null/missing) }); + /** * RSL1l - Publish params passed as querystring * * Additional params passed to publish should appear as query parameters. */ + // UTS: rest/unit/RSL1l/params-as-querystring-0 it('RSL1l - publish params as querystring', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channel/publish_result.test.ts b/test/uts/rest/unit/channel/publish_result.test.ts similarity index 90% rename from test/uts/rest/channel/publish_result.test.ts rename to test/uts/rest/unit/channel/publish_result.test.ts index c5c7cc869..85a936e9d 100644 --- a/test/uts/rest/channel/publish_result.test.ts +++ b/test/uts/rest/unit/channel/publish_result.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/channel/publish_result', function () { +describe('uts/rest/unit/channel/publish_result', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/rest/channel/publish_result', function () { * When a single message is published, the server responds with a * PublishResult containing a serials array with one entry. */ + // UTS: rest/unit/RSL1n/publish-result-single-message-0 it('RSL1n - single message returns PublishResult with serial', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -47,6 +48,7 @@ describe('uts/rest/channel/publish_result', function () { * When multiple messages are published in a single call, the server * responds with a serials array containing one entry per message. */ + // UTS: rest/unit/RSL1n/publish-result-batch-serials-1 it('RSL1n - batch returns PublishResult with multiple serials', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -80,6 +82,7 @@ describe('uts/rest/channel/publish_result', function () { * When the server conflates messages, it may return null for some * serials entries. The client must preserve these null values. */ + // UTS: rest/unit/RSL1n/publish-result-null-serial-2 it('RSL1n - null serial preserved (conflated)', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/unit/channel/rest_channel_attributes.test.ts b/test/uts/rest/unit/channel/rest_channel_attributes.test.ts new file mode 100644 index 000000000..602466543 --- /dev/null +++ b/test/uts/rest/unit/channel/rest_channel_attributes.test.ts @@ -0,0 +1,306 @@ +/** + * UTS: REST Channel Attributes Tests + * + * Spec points: RSL7, RSL8, RSL8a, RSL9 + * Source: uts/test/rest/unit/channel/rest_channel_attributes.md + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; + +describe('uts/rest/unit/channel/rest_channel_attributes', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSL9 - channel name attribute + * + * The channel object must expose its name via a name attribute, + * including any namespace prefix. + */ + // UTS: rest/unit/RSL9/channel-name-attribute-0 + it('RSL9 - channel name attribute', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + const ch1 = client.channels.get('my-channel'); + expect(ch1.name).to.equal('my-channel'); + + const ch2 = client.channels.get('namespace:channel-name'); + expect(ch2.name).to.equal('namespace:channel-name'); + }); + + /** + * RSL7 - setOptions completes without error + * + * Calling setOptions with an empty options object must complete + * successfully without throwing. + */ + // UTS: rest/unit/RSL7/setoptions-updates-options-0 + it('RSL7 - setOptions completes without error', async function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test-channel'); + + await channel.setOptions({}); + }); + + /** + * RSL7 - setOptions stores channel options + * + * Calling setOptions with options stores them on the channel. + * The call should complete without error. + */ + // UTS: rest/unit/RSL7/setoptions-stores-options-1 + it('RSL7 - setOptions stores channel options', function () { + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test-RSL7-store'); + + // setOptions is synchronous in ably-js and returns void + channel.setOptions({}); + // No error thrown — success + }); + + /** + * RSL8 - status sends GET to correct path + * + * Calling status() on a channel sends a GET request to + * /channels/. + */ + // UTS: rest/unit/RSL8/status-get-correct-endpoint-0 + it('RSL8 - status sends GET to correct path', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channelId: 'test-channel', + status: { + isActive: true, + occupancy: { metrics: { connections: 5 } }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-channel'); + await ch.status(); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/channels/test-channel'); + }); + + /** + * RSL8 - status URL encodes channel name + * + * Channel names containing special characters (colons, spaces, etc.) + * must be URL-encoded in the request path. + */ + // UTS: rest/unit/RSL8/status-special-chars-encoded-1 + it('RSL8 - status URL encodes channel name', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channelId: 'namespace:my channel', + status: { + isActive: true, + occupancy: { metrics: { connections: 1 } }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('namespace:my channel'); + await ch.status(); + + expect(captured).to.have.length(1); + expect(captured[0].path).to.contain(encodeURIComponent('namespace:my channel')); + }); + + /** + * RSL8a - status returns ChannelDetails + * + * The status() method returns a ChannelDetails object with channelId, + * status.isActive, and status.occupancy.metrics fields. + */ + // UTS: rest/unit/RSL8a/status-returns-channel-details-0 + it('RSL8a - status returns ChannelDetails', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + channelId: 'test-RSL8a', + status: { + isActive: true, + occupancy: { + metrics: { + connections: 5, + publishers: 2, + subscribers: 3, + }, + }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-RSL8a'); + const result = await ch.status(); + + expect(result.channelId).to.equal('test-RSL8a'); + expect(result.status.isActive).to.equal(true); + expect(result.status.occupancy.metrics.connections).to.equal(5); + expect(result.status.occupancy.metrics.publishers).to.equal(2); + expect(result.status.occupancy.metrics.subscribers).to.equal(3); + }); + + /** + * CHD2+CHS2+CHO2+CHM2 - status() response parses all ChannelMetrics fields + * + * Tests that status() parses the complete set of ChannelMetrics fields + * from the response, including all CHM2a-h attributes. + */ + // UTS: rest/unit/CHM2/parses-all-metrics-fields-0 + it('CHD2+CHS2+CHO2+CHM2 - status() response parses all ChannelMetrics fields', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, { + channelId: 'test-CHM2-full', + status: { + isActive: true, + occupancy: { + metrics: { + connections: 10, + presenceConnections: 5, + presenceMembers: 3, + presenceSubscribers: 4, + publishers: 6, + subscribers: 8, + objectPublishers: 2, + objectSubscribers: 1, + }, + }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-CHM2-full'); + const result = await ch.status(); + + // CHD2a: channelId + expect(result.channelId).to.equal('test-CHM2-full'); + + // CHD2b + CHS2a: status.isActive + expect(result.status).to.not.be.null; + expect(result.status.isActive).to.equal(true); + + // CHS2b + CHO2a: occupancy.metrics + expect(result.status.occupancy).to.not.be.null; + expect(result.status.occupancy.metrics).to.not.be.null; + + const metrics = result.status.occupancy.metrics; + + // CHM2a: connections + expect(metrics.connections).to.equal(10); + + // CHM2b: presenceConnections + expect(metrics.presenceConnections).to.equal(5); + + // CHM2c: presenceMembers + expect(metrics.presenceMembers).to.equal(3); + + // CHM2d: presenceSubscribers + expect(metrics.presenceSubscribers).to.equal(4); + + // CHM2e: publishers + expect(metrics.publishers).to.equal(6); + + // CHM2f: subscribers + expect(metrics.subscribers).to.equal(8); + + // CHM2g: objectPublishers - not in ably-js ChannelMetrics type definition, + // but present on the runtime object since the JSON response is passed through as-is. + // DEVIATION: ably-js ChannelMetrics type (ably.d.ts) does not declare objectPublishers or objectSubscribers. + expect((metrics as any).objectPublishers).to.equal(2); + + // CHM2h: objectSubscribers - same deviation as CHM2g above. + expect((metrics as any).objectSubscribers).to.equal(1); + }); + + /** + * CHM2 - status() response with zero/missing metric fields + * + * Tests that status() handles zero-valued and absent metric fields + * gracefully. Omitted fields (objectPublishers, objectSubscribers) + * simulate an older server that does not include these fields. + */ + // UTS: rest/unit/CHM2/zero-and-missing-metrics-1 + it('CHM2 - status() response with zero and missing metric fields', async function () { + const mock = new 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. + req.respond_with(200, { + channelId: 'test-CHM2-zeros', + status: { + isActive: false, + occupancy: { + metrics: { + connections: 0, + presenceConnections: 0, + presenceMembers: 0, + presenceSubscribers: 0, + publishers: 0, + subscribers: 0, + }, + }, + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const ch = client.channels.get('test-CHM2-zeros'); + const result = await ch.status(); + + // CHD2a: channelId + expect(result.channelId).to.equal('test-CHM2-zeros'); + + // CHS2a: isActive can be false + expect(result.status.isActive).to.equal(false); + + const metrics = result.status.occupancy.metrics; + + // CHM2a-f: explicit zero values are parsed correctly + expect(metrics.connections).to.equal(0); + expect(metrics.presenceConnections).to.equal(0); + expect(metrics.presenceMembers).to.equal(0); + expect(metrics.presenceSubscribers).to.equal(0); + expect(metrics.publishers).to.equal(0); + expect(metrics.subscribers).to.equal(0); + + // CHM2g-h: omitted fields are undefined (not defaulted to 0). + // DEVIATION: The UTS spec expects missing fields to default to 0, + // but ably-js passes the JSON response through as-is without defaults, + // so omitted fields are undefined rather than 0. + expect((metrics as any).objectPublishers).to.equal(undefined); + expect((metrics as any).objectSubscribers).to.equal(undefined); + }); +}); diff --git a/test/uts/rest/channel/update_delete_message.test.ts b/test/uts/rest/unit/channel/update_delete_message.test.ts similarity index 93% rename from test/uts/rest/channel/update_delete_message.test.ts rename to test/uts/rest/unit/channel/update_delete_message.test.ts index d9701ee8e..b539a1a94 100644 --- a/test/uts/rest/channel/update_delete_message.test.ts +++ b/test/uts/rest/unit/channel/update_delete_message.test.ts @@ -6,14 +6,14 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; function msg(fields: any) { return Ably.Rest.Message.fromValues(fields); } -describe('uts/rest/channel/update_delete_message', function () { +describe('uts/rest/unit/channel/update_delete_message', function () { afterEach(function () { restoreAll(); }); @@ -24,6 +24,7 @@ describe('uts/rest/channel/update_delete_message', function () { * updateMessage must send a PATCH request to /channels//messages/ * with the message body containing action=1 (MESSAGE_UPDATE). */ + // UTS: rest/unit/RSL15b/update-sends-patch-update-0 it('RSL15b - updateMessage sends PATCH', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -53,6 +54,7 @@ describe('uts/rest/channel/update_delete_message', function () { * * deleteMessage must send a PATCH request with action=2 (MESSAGE_DELETE). */ + // UTS: rest/unit/RSL15b/delete-sends-patch-delete-1 it('RSL15b - deleteMessage sends PATCH with action 2', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -80,6 +82,7 @@ describe('uts/rest/channel/update_delete_message', function () { * * appendMessage must send a PATCH request with action=5 (MESSAGE_APPEND). */ + // UTS: rest/unit/RSL15b/append-sends-patch-append-2 it('RSL15b - appendMessage sends PATCH with action 5', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -108,6 +111,7 @@ describe('uts/rest/channel/update_delete_message', function () { * When an operation object is provided, the serialized body must include * a version field with clientId, description, and metadata from the operation. */ + // UTS: rest/unit/RSL15b7/version-set-with-operation-0 it('RSL15b7 - version set with MessageOperation', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -141,6 +145,7 @@ describe('uts/rest/channel/update_delete_message', function () { * When no operation object is provided, the serialized body must not * include a version field. */ + // UTS: rest/unit/RSL15b7/version-absent-no-operation-1 it('RSL15b7 - version absent without operation', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -167,6 +172,7 @@ describe('uts/rest/channel/update_delete_message', function () { * The update/delete methods must not modify the original message object * passed in by the user. */ + // UTS: rest/unit/RSL15c/no-mutate-user-message-0 it('RSL15c - does not mutate user-supplied message', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -195,6 +201,7 @@ describe('uts/rest/channel/update_delete_message', function () { * * The resolved value must contain the versionSerial from the server response. */ + // UTS: rest/unit/RSL15e/returns-update-delete-result-0 it('RSL15e - returns UpdateDeleteResult with versionSerial', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -219,6 +226,7 @@ describe('uts/rest/channel/update_delete_message', function () { * When the server returns null for versionSerial, the client must * preserve it as null rather than converting to undefined. */ + // UTS: rest/unit/RSL15e/null-version-serial-1 it('RSL15e - null versionSerial preserved', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -242,6 +250,7 @@ describe('uts/rest/channel/update_delete_message', function () { * * When params are provided, they must be sent as URL query parameters. */ + // UTS: rest/unit/RSL15f/params-sent-as-querystring-0 it('RSL15f - params sent as querystring', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -268,6 +277,7 @@ describe('uts/rest/channel/update_delete_message', function () { * If the message lacks a serial, updateMessage, deleteMessage, and * appendMessage must all throw an error with code 40003. */ + // UTS: rest/unit/RSL15a/serial-required-throws-error-0 it('RSL15a - serial required', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -315,6 +325,7 @@ describe('uts/rest/channel/update_delete_message', function () { * * Object data must be JSON-encoded with an encoding field set to 'json'. */ + // UTS: rest/unit/RSL15d/body-encoded-per-rsl4-0 it('RSL15d - data encoded per RSL4', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -342,6 +353,7 @@ describe('uts/rest/channel/update_delete_message', function () { * The serial must be URL-encoded in the request path to handle * special characters correctly. */ + // UTS: rest/unit/RSL15b/serial-url-encoded-path-3 it('RSL15b - serial URL-encoded', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/channels_collection.test.ts b/test/uts/rest/unit/channels_collection.test.ts similarity index 89% rename from test/uts/rest/channels_collection.test.ts rename to test/uts/rest/unit/channels_collection.test.ts index 96d922ca8..6030fdd46 100644 --- a/test/uts/rest/channels_collection.test.ts +++ b/test/uts/rest/unit/channels_collection.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/channels_collection', function () { +describe('uts/rest/unit/channels_collection', function () { let mock; beforeEach(function () { @@ -30,6 +30,7 @@ describe('uts/rest/channels_collection', function () { * The RestClient exposes a channels collection with a get() method * for obtaining RestChannel instances. */ + // UTS: rest/unit/RSN1/channels-collection-accessible-0 it('RSN1 - Channels collection accessible via RestClient', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -43,6 +44,7 @@ describe('uts/rest/channels_collection', function () { * Before a channel is created, it should not appear in the collection. * After get() is called, it should be present. */ + // UTS: rest/unit/RSN2/check-channel-exists-0 it('RSN2 - Check channel existence', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -65,6 +67,7 @@ describe('uts/rest/channels_collection', function () { * Multiple channels created via get() should all be iterable * through the channels.all property. */ + // UTS: rest/unit/RSN2/iterate-channels-1 it('RSN2 - Iterate through existing channels', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -86,6 +89,7 @@ describe('uts/rest/channels_collection', function () { * Calling get() with a channel name that does not yet exist * creates a new RestChannel with the specified name. */ + // UTS: rest/unit/RSN3a/get-creates-new-channel-0 it('RSN3a - Get creates new channel if none exists', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -102,6 +106,7 @@ describe('uts/rest/channels_collection', function () { * Calling get() with the same channel name returns the same * cached RestChannel instance (identity equality). */ + // UTS: rest/unit/RSN3a/get-returns-existing-channel-1 it('RSN3a - Get returns same instance for existing channel', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -117,6 +122,7 @@ describe('uts/rest/channels_collection', function () { * Calling release() with a channel name removes that channel * from the internal cache, so it no longer appears in all. */ + // UTS: rest/unit/RSN4a/release-removes-channel-0 it('RSN4a - Release removes channel from collection', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -133,6 +139,7 @@ describe('uts/rest/channels_collection', function () { * Calling release() with a channel name that does not correspond * to an existing channel must return without error. */ + // UTS: rest/unit/RSN4b/release-nonexistent-noop-0 it('RSN4b - Release on non-existent channel is no-op', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -149,6 +156,7 @@ describe('uts/rest/channels_collection', function () { * After releasing a channel and calling get() again with the same name, * a new RestChannel instance is created (not the previously cached one). */ + // UTS: rest/unit/RSN3a/get-after-release-new-instance-3 it('RSN3a - Get after release creates new instance', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -167,6 +175,7 @@ describe('uts/rest/channels_collection', function () { * When get() is called with channelOptions, those options are applied * to the channel (either new or existing). */ + // UTS: rest/unit/RSN3a/subscript-creates-or-returns-2 it('RSN3c - Get with channelOptions updates options', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); diff --git a/test/uts/rest/encoding/message_encoding.test.ts b/test/uts/rest/unit/encoding/message_encoding.test.ts similarity index 89% rename from test/uts/rest/encoding/message_encoding.test.ts rename to test/uts/rest/unit/encoding/message_encoding.test.ts index 1c8b4e4e7..54798cf07 100644 --- a/test/uts/rest/encoding/message_encoding.test.ts +++ b/test/uts/rest/unit/encoding/message_encoding.test.ts @@ -10,8 +10,8 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; function publishMock() { const captured: any[] = []; @@ -35,7 +35,7 @@ function historyMock(messages: any) { return mock; } -describe('uts/rest/encoding/message_encoding', function () { +describe('uts/rest/unit/encoding/message_encoding', function () { afterEach(function () { restoreAll(); }); @@ -45,6 +45,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4a - String data transmitted without encoding */ + // UTS: rest/unit/RSL4a/string-data-no-encoding-0 it('RSL4a - string data has no encoding', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -60,6 +61,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4b - JSON object serialized with encoding: "json" */ + // UTS: rest/unit/RSL4b/json-object-encoding-0 it('RSL4b - object data JSON-encoded', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -76,6 +78,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4c - Binary data base64-encoded with JSON protocol */ + // UTS: rest/unit/RSL4c/binary-base64-json-protocol-0 it('RSL4c - binary data base64-encoded for JSON protocol', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -93,6 +96,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4d - Array data JSON-encoded */ + // UTS: rest/unit/RSL4d/array-json-encoding-0 it('RSL4d - array data JSON-encoded', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -108,6 +112,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4 - Null data transmitted without encoding */ + // UTS: rest/unit/RSL4/null-data-no-encoding-1 it('RSL4 - null data has no encoding', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -123,6 +128,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4 - Empty string transmitted without encoding */ + // UTS: rest/unit/RSL4/empty-string-no-encoding-4 it('RSL4 - empty string has no encoding', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -138,6 +144,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4 - Empty array JSON-encoded */ + // UTS: rest/unit/RSL4/empty-array-json-encoding-5 it('RSL4 - empty array JSON-encoded', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -153,6 +160,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4 - Empty object JSON-encoded */ + // UTS: rest/unit/RSL4/encoding-fixtures-ably-common-0 it('RSL4 - empty object JSON-encoded', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -168,6 +176,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL4 - JSON protocol uses application/json content-type */ + // UTS: rest/unit/RSL4/json-protocol-content-type-2 it('RSL4 - JSON protocol content-type', async function () { const { mock, captured } = publishMock(); installMockHttp(mock); @@ -184,6 +193,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6a - Decode base64 data to binary */ + // UTS: rest/unit/RSL6a/decode-base64-to-binary-0 it('RSL6a - base64 decoded to Buffer', async function () { installMockHttp( historyMock([{ id: 'msg1', name: 'event', data: 'AAECAwQ=', encoding: 'base64', timestamp: 1234567890000 }]), @@ -200,6 +210,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6a - Decode JSON string to native object */ + // UTS: rest/unit/RSL6a/decode-json-to-object-1 it('RSL6a - json decoded to object', async function () { installMockHttp( historyMock([ @@ -217,6 +228,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6a - Chained encoding json/base64 decoded in reverse order */ + // UTS: rest/unit/RSL6a/decode-chained-encodings-2 it('RSL6a - chained json/base64 decoded', async function () { // {"key":"value"} → base64 = eyJrZXkiOiJ2YWx1ZSJ9 const base64OfJson = Buffer.from('{"key":"value"}').toString('base64'); @@ -237,6 +249,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6 - utf-8/base64 decoded to string */ + // UTS: rest/unit/RSL6/decode-utf8-base64-data-2 it('RSL6 - utf-8/base64 decoded to string', async function () { // "Hello World" → base64 = SGVsbG8gV29ybGQ= installMockHttp( @@ -256,6 +269,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6 - Complex chained encoding json/utf-8/base64 */ + // UTS: rest/unit/RSL6/complex-chained-encoding-3 it('RSL6 - json/utf-8/base64 fully decoded', async function () { const obj = { status: 'active', count: 5 }; const base64Data = Buffer.from(JSON.stringify(obj)).toString('base64'); @@ -276,6 +290,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6b - Unrecognized encoding preserved */ + // UTS: rest/unit/RSL6b/unrecognized-encoding-preserved-0 it('RSL6b - unrecognized encoding preserved', async function () { // base64 of "encrypted-data" const base64Data = Buffer.from('encrypted-data').toString('base64'); @@ -298,6 +313,7 @@ describe('uts/rest/encoding/message_encoding', function () { /** * RSL6a - String data without encoding passes through */ + // UTS: rest/unit/RSL4a/string-data-no-encoding-0.1 it('RSL6a - string data without encoding passes through', async function () { installMockHttp(historyMock([{ id: 'msg1', name: 'event', data: 'plain text', timestamp: 1234567890000 }])); @@ -314,6 +330,7 @@ describe('uts/rest/encoding/message_encoding', function () { * Per RSL4a: payloads must be binary, strings, or objects capable of * JSON representation. Any other data type should result in an error. */ + // UTS: rest/unit/RSL4a/number-type-rejected-1 it('RSL4a - number data type rejected', async function () { const { mock } = publishMock(); installMockHttp(mock); @@ -333,6 +350,7 @@ describe('uts/rest/encoding/message_encoding', function () { * Per RSL4a: payloads must be binary, strings, or objects capable of * JSON representation. Any other data type should result in an error. */ + // UTS: rest/unit/RSL4a/boolean-type-rejected-2 it('RSL4a - boolean data type rejected', async function () { const { mock } = publishMock(); installMockHttp(mock); @@ -350,18 +368,26 @@ describe('uts/rest/encoding/message_encoding', function () { // MsgPack tests — PENDING (mock HTTP does not support msgpack encoding) // --------------------------------------------------------------------------- + // UTS: rest/unit/RSL4c/binary-direct-msgpack-protocol-1 it('RSL4c - binary data with msgpack protocol', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSL6/msgpack-binary-stays-binary-0 it('RSL6 - msgpack bin type decoded to Buffer', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSL6/msgpack-string-stays-string-1 it('RSL6 - msgpack str type decoded to string', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + + // UTS: rest/unit/RSL4/msgpack-protocol-content-type-3 + it.skip('RSL4 - msgpack protocol content type (msgpack not supported)', function () { + // DEVIATION: ably-js does not support msgpack protocol + }); }); diff --git a/test/uts/rest/unit/encoding/msgpack_interop.test.ts b/test/uts/rest/unit/encoding/msgpack_interop.test.ts new file mode 100644 index 000000000..3a7b593c2 --- /dev/null +++ b/test/uts/rest/unit/encoding/msgpack_interop.test.ts @@ -0,0 +1,116 @@ +/** + * UTS: MessagePack Interoperability Tests + * + * Spec points: RSL6a3 + * Source: uts/rest/unit/encoding/msgpack_interop.md + * + * Verifies that the client library can decode and round-trip binary-encoded + * protocol messages using the ably-common interop fixtures. + */ + +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Side-effect import wires up Platform with Node-specific config +import '../../../../../src/platform/nodejs'; +import { WireMessage } from '../../../../../src/common/lib/types/message'; +import Logger from '../../../../../src/common/lib/util/logger'; + +const msgpack = require('@ably/msgpack-js'); + +interface Fixture { + name: string; + data: any; + encoding: string; + numRepeat: number; + type: 'string' | 'binary' | 'jsonArray' | 'jsonObject'; + msgpack: string; +} + +const fixturesPath = path.resolve( + __dirname, + '../../../../common/ably-common/test-resources/msgpack_test_fixtures.json', +); +const fixtures: Fixture[] = JSON.parse(fs.readFileSync(fixturesPath, 'utf-8')); + +function buildExpected(fixture: Fixture): any { + if (fixture.type === 'string') { + return fixture.numRepeat > 0 + ? fixture.data.repeat(fixture.numRepeat) + : fixture.data; + } else if (fixture.type === 'binary') { + const repeated = fixture.data.repeat(fixture.numRepeat); + return Buffer.from(repeated, 'utf-8'); + } else { + return fixture.data; + } +} + +describe('uts/rest/unit/encoding/msgpack_interop', function () { + it('fixtures file is loaded with expected entries', function () { + expect(fixtures).to.have.length(8); + }); + + for (const fixture of fixtures) { + // UTS: rest/unit/RSL6a3/msgpack-interop-decode + it(`RSL6a3 - decodes "${fixture.name}" fixture correctly`, async function () { + const msgpackBytes = Buffer.from(fixture.msgpack, 'base64'); + const protocolMessage = msgpack.decode(msgpackBytes); + + const messages = protocolMessage.messages; + expect(messages).to.have.length(1); + + const wireMessage = WireMessage.fromValues(messages[0]); + const decoded = await wireMessage.decode({}, Logger.defaultLogger); + + expect(decoded.encoding).to.not.be.ok; + + const expected = buildExpected(fixture); + + if (fixture.type === 'binary') { + expect(Buffer.isBuffer(decoded.data)).to.be.true; + expect(Buffer.compare(decoded.data as Buffer, expected)).to.equal(0); + } else if (fixture.type === 'jsonArray') { + expect(decoded.data).to.be.an('array'); + expect(decoded.data).to.deep.equal(expected); + } else if (fixture.type === 'jsonObject') { + expect(decoded.data).to.be.an('object'); + expect(decoded.data).to.deep.equal(expected); + } else { + expect(decoded.data).to.be.a('string'); + expect(decoded.data).to.equal(expected); + } + }); + } + + for (const fixture of fixtures) { + // UTS: rest/unit/RSL6a3/msgpack-interop-roundtrip + it(`RSL6a3 - round-trips "${fixture.name}" fixture through encode/decode`, async function () { + const msgpackBytes = Buffer.from(fixture.msgpack, 'base64'); + const protocolMessage = msgpack.decode(msgpackBytes); + + const wireMessage = WireMessage.fromValues(protocolMessage.messages[0]); + const decoded = await wireMessage.decode({}, Logger.defaultLogger); + + // Re-encode for msgpack wire format + const reEncoded = await decoded.encode({}); + const reProtocolMessage = { messages: [reEncoded], msgSerial: 0 }; + const reBytes = msgpack.encode(reProtocolMessage, true); + + // Deserialize and decode again + const reParsed = msgpack.decode(reBytes); + const reWireMessage = WireMessage.fromValues(reParsed.messages[0]); + const reDecoded = await reWireMessage.decode({}, Logger.defaultLogger); + + expect(reDecoded.encoding).to.not.be.ok; + + if (fixture.type === 'binary') { + expect(Buffer.isBuffer(reDecoded.data)).to.be.true; + expect(Buffer.compare(reDecoded.data as Buffer, decoded.data as Buffer)).to.equal(0); + } else { + expect(reDecoded.data).to.deep.equal(decoded.data); + } + }); + } +}); diff --git a/test/uts/rest/fallback.test.ts b/test/uts/rest/unit/fallback.test.ts similarity index 68% rename from test/uts/rest/fallback.test.ts rename to test/uts/rest/unit/fallback.test.ts index 4dc86bf37..13932acde 100644 --- a/test/uts/rest/fallback.test.ts +++ b/test/uts/rest/unit/fallback.test.ts @@ -3,15 +3,16 @@ * * Spec points: RSC15, RSC15a, RSC15f, RSC15l, RSC15l4, RSC15m, * REC1a, REC1b1, REC1b2, REC1b3, REC1b4, REC1c1, REC1c2, REC1d, REC1d1, - * REC2a2, REC2c2, REC2c3, REC2c4, REC2c6 + * REC2a1, REC2a2, REC2b, REC2c1, REC2c2, REC2c3, REC2c4, REC2c5, REC2c6, + * REC3, REC3a, REC3b * Source: specification/uts/rest/unit/fallback.md */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, enableFakeTimers, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, enableFakeTimers, restoreAll } from '../../helpers'; -describe('uts/rest/fallback', function () { +describe('uts/rest/unit/fallback', function () { afterEach(function () { restoreAll(); }); @@ -24,6 +25,7 @@ describe('uts/rest/fallback', function () { * When the primary host returns a 500 error, the client should retry * the request on a fallback host. */ + // UTS: rest/unit/RSC15l/http-5xx-triggers-fallback-4 it('RSC15l - 500 triggers fallback', async function () { let requestCount = 0; const hosts: any[] = []; @@ -58,6 +60,7 @@ describe('uts/rest/fallback', function () { * When the primary host refuses the connection, the client should * retry on a fallback host. */ + // UTS: rest/unit/RSC15l/connection-refused-fallback-0 it('RSC15l - connection refused triggers fallback', async function () { let connCount = 0; const connHosts: any[] = []; @@ -94,6 +97,7 @@ describe('uts/rest/fallback', function () { * Client errors (4xx) are not retryable. The client should not attempt * a fallback host and should propagate the error immediately. */ + // UTS: rest/unit/RSC15l/qualifying-errors-trigger-fallback-0 it('RSC15l - 4xx does NOT trigger fallback', async function () { let requestCount = 0; @@ -124,6 +128,7 @@ describe('uts/rest/fallback', function () { * When fallbackHosts is explicitly set to an empty array, the client * should not attempt any fallback and should fail after the primary host. */ + // UTS: rest/unit/RSC15m/no-fallback-empty-hosts-0 it('RSC15m - no fallback when fallbackHosts is empty', async function () { let requestCount = 0; @@ -156,6 +161,7 @@ describe('uts/rest/fallback', function () { * Without any endpoint configuration, the default primary host should * be main.realtime.ably.net. */ + // UTS: rest/unit/REC1a/default-primary-domain-0 it('REC1a - default primary domain', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -180,6 +186,7 @@ describe('uts/rest/fallback', function () { * When endpoint is a simple name (no dots), it is treated as a routing * policy and the host becomes {endpoint}.realtime.ably.net. */ + // UTS: rest/unit/REC1b4/production-routing-policy-0 it('REC1b4 - endpoint as routing policy', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -191,11 +198,11 @@ describe('uts/rest/fallback', function () { }); installMockHttp(mock); - const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'sandbox' }); + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'test' }); await client.time(); expect(captured).to.have.length(1); - expect(captured[0].url.hostname).to.equal('sandbox.realtime.ably.net'); + expect(captured[0].url.hostname).to.equal('test.realtime.ably.net'); }); /** @@ -203,6 +210,7 @@ describe('uts/rest/fallback', function () { * * When endpoint contains dots, it is treated as an explicit hostname. */ + // UTS: rest/unit/REC1b2/explicit-hostname-with-period-0 it('REC1b2 - endpoint as explicit hostname', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -230,6 +238,7 @@ describe('uts/rest/fallback', function () { * * The deprecated restHost option sets the REST host directly. */ + // UTS: rest/unit/REC1d1/resthost-sets-primary-domain-0 it('REC1d1 - restHost option', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -257,6 +266,7 @@ describe('uts/rest/fallback', function () { * * The deprecated environment option maps to {environment}.realtime.ably.net. */ + // UTS: rest/unit/REC1c2/environment-sets-primary-domain-0 it('REC1c2 - environment option', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -285,6 +295,7 @@ describe('uts/rest/fallback', function () { * When fallbackHosts is set to a custom list, the client should use * those hosts for fallback instead of the defaults. */ + // UTS: rest/unit/REC2a2/custom-fallback-hosts-0 it('REC2a2 - custom fallbackHosts', async function () { let requestCount = 0; const hosts: any[] = []; @@ -323,6 +334,7 @@ describe('uts/rest/fallback', function () { * When restHost is set to a custom domain, fallback hosts are not * available (unless explicitly provided). A 500 should not trigger retry. */ + // UTS: rest/unit/REC2c6/custom-resthost-no-fallbacks-0 it('REC2c6 - custom restHost has no fallbacks', async function () { let requestCount = 0; @@ -360,6 +372,7 @@ describe('uts/rest/fallback', function () { * hosts should be selected in a randomized order. Over multiple attempts, * we expect to see more than one distinct fallback host used. */ + // UTS: rest/unit/RSC15a/fallback-random-order-0 it('RSC15a - fallback hosts are randomized', async function () { const fallbackHostsUsed: string[] = []; @@ -395,6 +408,7 @@ describe('uts/rest/fallback', function () { * When the primary host fails DNS resolution, the client should * retry on a fallback host. */ + // UTS: rest/unit/RSC15l/dns-error-fallback-1 it('RSC15l - DNS error triggers fallback', async function () { const connHosts: string[] = []; @@ -428,6 +442,7 @@ describe('uts/rest/fallback', function () { * When the primary host connection times out, the client should * retry on a fallback host. */ + // UTS: rest/unit/RSC15l/connection-timeout-fallback-2 it('RSC15l - timeout triggers fallback', async function () { const connHosts: string[] = []; @@ -461,6 +476,7 @@ describe('uts/rest/fallback', function () { * When the primary host returns a 503 Service Unavailable, the client * should retry on a fallback host. */ + // UTS: rest/unit/RSC15l/http-4xx-no-fallback-5 it('RSC15l - 503 triggers fallback', async function () { let requestCount = 0; const hosts: string[] = []; @@ -494,6 +510,7 @@ describe('uts/rest/fallback', function () { * After a successful fallback, subsequent requests should go to the * cached fallback host instead of the primary host. */ + // UTS: rest/unit/RSC15f/successful-fallback-cached-0 it('RSC15f - successful fallback host cached', async function () { const captured: any[] = []; let requestCount = 0; @@ -588,9 +605,8 @@ describe('uts/rest/fallback', function () { // ── Category B: Request timeout and CloudFront ──────────────────── + // UTS: rest/unit/RSC15l/request-timeout-fallback-3 it('RSC15l - request timeout triggers fallback', async function () { - // DEVIATION: see deviations.md - if (!process.env.RUN_DEVIATIONS) this.skip(); let connCount = 0; const connHosts: string[] = []; let requestCount = 0; @@ -612,20 +628,15 @@ describe('uts/rest/fallback', function () { }); installMockHttp(mock); - // Spec: request-level timeout (after connection succeeds) MUST trigger fallback. - // DEVIATION: ably-js may not retry on request timeout. See deviations.md. const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); - try { - const result = await client.time(); - expect(result).to.equal(1234567890000); - expect(connCount).to.be.at.least(2); - expect(connHosts[0]).to.equal('main.realtime.ably.net'); - expect(connHosts[1]).to.not.equal('main.realtime.ably.net'); - } catch (e) { - expect.fail('Request timeout should trigger fallback, but ably-js threw: ' + (e as Error).message); - } + const result = await client.time(); + expect(result).to.equal(1234567890000); + expect(connCount).to.be.at.least(2); + expect(connHosts[0]).to.equal('main.realtime.ably.net'); + expect(connHosts[1]).to.not.equal('main.realtime.ably.net'); }); + // UTS: rest/unit/RSC15l4/cloudfront-error-triggers-fallback-0 it('RSC15l4 - CloudFront Server header triggers fallback', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -668,6 +679,7 @@ describe('uts/rest/fallback', function () { // ── Category C: Cached fallback expiry ──────────────────────────── + // UTS: rest/unit/RSC15f/cached-fallback-expires-1 it('RSC15f - cached fallback expires after fallbackRetryTimeout', async function () { const clock = enableFakeTimers(); const hosts: string[] = []; @@ -715,8 +727,75 @@ describe('uts/rest/fallback', function () { expect(hosts[0]).to.equal('main.realtime.ably.net'); }); + // UTS: rest/unit/RSC15f/expired-not-resurrected-2 + it('RSC15f - expired fallback not resurrected by late in-flight success', async function () { + const clock = enableFakeTimers(); + const hosts: string[] = []; + let requestCount = 0; + let heldRequest: any = null; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + // Primary fails → triggers fallback + req.respond_with(500, { error: { message: 'fail', code: 50000, statusCode: 500 } }); + } else if (requestCount === 2) { + // First fallback succeeds → caches this host + req.respond_with(200, [1234567890000]); + } else if (requestCount === 3) { + // Second request to cached fallback — hold it, don't respond yet + heldRequest = req; + } else { + // All subsequent requests succeed + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + fallbackRetryTimeout: 100, + } as any); + + // Requests 1+2: primary fails → fallback succeeds → fallback cached + await client.time(); + const fallbackHost = hosts[1]; + expect(fallbackHost).to.not.equal('main.realtime.ably.net'); + + // Request 3: goes to cached fallback, but we hold the response + const requestFuture = client.time(); + + // Advance time past fallbackRetryTimeout + clock.tick(150); + + // Request 4: cache expired → should try primary + await client.time(); + expect(hosts[3]).to.equal('main.realtime.ably.net'); + + // Now let the held request complete successfully + expect(heldRequest).to.not.be.null; + heldRequest.respond_with(200, [1234567890000]); + await requestFuture; + + // Request 5: late success must NOT have re-pinned the fallback + await client.time(); + + expect(hosts).to.have.length(5); + expect(hosts[0]).to.equal('main.realtime.ably.net'); // primary fail + expect(hosts[1]).to.equal(fallbackHost); // fallback success (cached) + expect(hosts[2]).to.equal(fallbackHost); // cached fallback (held) + expect(hosts[3]).to.equal('main.realtime.ably.net'); // after expiry → primary + expect(hosts[4]).to.equal('main.realtime.ably.net'); // still primary, not re-pinned + }); + // ── Category D: Endpoint edge cases ─────────────────────────────── + // UTS: rest/unit/REC1b2/endpoint-localhost-1 it('REC1b2 - endpoint as localhost', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -735,6 +814,7 @@ describe('uts/rest/fallback', function () { expect(captured[0].url.hostname).to.equal('localhost'); }); + // UTS: rest/unit/REC1b2/endpoint-ipv6-address-2 it('REC1b2 - endpoint as IPv6 address', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -761,6 +841,7 @@ describe('uts/rest/fallback', function () { } }); + // UTS: rest/unit/REC1b3/nonprod-routing-policy-0 it('REC1b3 - endpoint as nonprod routing policy', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -779,6 +860,7 @@ describe('uts/rest/fallback', function () { expect(captured[0].url.hostname).to.equal('staging.realtime.ably-nonprod.net'); }); + // UTS: rest/unit/REC1d2/realtimehost-sets-primary-domain-0 it('REC1d - realtimeHost sets primary domain when restHost not set', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -803,24 +885,73 @@ describe('uts/rest/fallback', function () { // ── Category E: Option conflict detection ───────────────────────── + // UTS: rest/unit/REC1b1/endpoint-conflicts-environment-0 it('REC1b1 - endpoint conflicts with environment', function () { try { - new Ably.Rest({ key: 'app.key:secret', endpoint: 'sandbox', environment: 'production' } as any); + new Ably.Rest({ key: 'app.key:secret', endpoint: 'test', environment: 'production' } as any); expect.fail('Expected constructor to throw'); } catch (error: any) { expect(error.code).to.equal(40106); } }); + // UTS: rest/unit/REC1b1/endpoint-conflicts-resthost-1 it('REC1b1 - endpoint conflicts with restHost', function () { try { - new Ably.Rest({ key: 'app.key:secret', endpoint: 'sandbox', restHost: 'custom.host.com' } as any); + new Ably.Rest({ key: 'app.key:secret', endpoint: 'test', restHost: 'custom.host.com' } as any); expect.fail('Expected constructor to throw'); } catch (error: any) { expect(error.code).to.equal(40106); } }); + // UTS: rest/unit/REC1b1/endpoint-conflicts-realtimehost-2 + it('REC1b1 - endpoint conflicts with realtimeHost', function () { + try { + new Ably.Rest({ + key: 'app.key:secret', + endpoint: 'custom.example.com', + realtimeHost: 'rt.example.com', + } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.equal(40106); + } + }); + + // UTS: rest/unit/REC1b1/endpoint-conflicts-fallback-default-3 + it.skip('REC1b1 - endpoint conflicts with fallbackHostsUseDefault', function () { + // SKIP: ably-js does not implement the fallbackHostsUseDefault option. + // The option is not recognized, so no conflict validation occurs. + try { + new Ably.Rest({ + key: 'app.key:secret', + endpoint: 'custom.example.com', + fallbackHostsUseDefault: true, + } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.satisfy((c: number) => c === 40000 || c === 40106); + } + }); + + // UTS: rest/unit/REC2a1/fallback-hosts-conflicts-use-default-0 + it.skip('REC2a1 - fallbackHosts conflicts with fallbackHostsUseDefault', function () { + // SKIP: ably-js does not implement the fallbackHostsUseDefault option. + // The option is not recognized, so no conflict validation occurs. + try { + new Ably.Rest({ + key: 'app.key:secret', + fallbackHosts: ['a.example.com'], + fallbackHostsUseDefault: true, + } as any); + expect.fail('Expected constructor to throw'); + } catch (error: any) { + expect(error.code).to.satisfy((c: number) => c === 40000 || c === 40106); + } + }); + + // UTS: rest/unit/REC1c1/environment-conflicts-resthost-0 it('REC1c1 - environment conflicts with restHost', function () { try { new Ably.Rest({ key: 'app.key:secret', environment: 'sandbox', restHost: 'custom.host.com' } as any); @@ -830,6 +961,7 @@ describe('uts/rest/fallback', function () { } }); + // UTS: rest/unit/REC1c1/environment-conflicts-realtimehost-1 it('REC1c1 - environment conflicts with realtimeHost', function () { try { new Ably.Rest({ key: 'app.key:secret', environment: 'sandbox', realtimeHost: 'custom.rt.com' } as any); @@ -839,6 +971,7 @@ describe('uts/rest/fallback', function () { } }); + // UTS: rest/unit/REC1d/resthost-precedence-over-realtimehost-0 it('REC1d - restHost takes precedence over realtimeHost', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -864,6 +997,7 @@ describe('uts/rest/fallback', function () { // ── Category F: Fallback domain configuration ───────────────────── + // UTS: rest/unit/REC2c2/explicit-hostname-no-fallbacks-0 it('REC2c2 - explicit hostname endpoint has no fallbacks', async function () { let requestCount = 0; @@ -892,6 +1026,7 @@ describe('uts/rest/fallback', function () { expect(requestCount).to.equal(1); }); + // UTS: rest/unit/REC2c3/nonprod-fallback-domains-0 it('REC2c3 - nonprod endpoint gets nonprod fallback domains', async function () { let requestCount = 0; const hosts: string[] = []; @@ -919,7 +1054,10 @@ describe('uts/rest/fallback', function () { expect(hosts[1]).to.match(/^staging\.[a-e]\.fallback\.ably-realtime-nonprod\.com$/); }); - it('REC2c4 - production routing via endpoint gets production fallback domains', async function () { + // UTS: rest/unit/REC2b/fallback-hosts-use-default-0 + it.skip('REC2b - fallbackHostsUseDefault uses default fallback domains', async function () { + // SKIP: ably-js does not implement the fallbackHostsUseDefault option. + // The option is ignored, so setting restHost disables fallbacks as normal. let requestCount = 0; const hosts: string[] = []; @@ -937,7 +1075,72 @@ describe('uts/rest/fallback', function () { }); installMockHttp(mock); - const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'sandbox' }); + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + restHost: 'custom.host.com', + fallbackHostsUseDefault: true, + } as any); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('custom.host.com'); + expect(hosts[1]).to.match(/^main\.[a-e]\.fallback\.ably-realtime\.com$/); + }); + + // UTS: rest/unit/REC2c1/default-fallback-domains-0 + it('REC2c1 - default fallback domains', async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('main.realtime.ably.net'); + expect(hosts[1]).to.match(/^main\.[a-e]\.fallback\.ably-realtime\.com$/); + }); + + // UTS: rest/unit/REC2c5/production-environment-fallback-domains-0 + it('REC2c5 - environment fallback domains', async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + environment: 'sandbox', + }); const result = await client.time(); expect(result).to.equal(1234567890000); @@ -945,4 +1148,130 @@ describe('uts/rest/fallback', function () { expect(hosts[0]).to.equal('sandbox.realtime.ably.net'); expect(hosts[1]).to.match(/^sandbox\.[a-e]\.fallback\.ably-realtime\.com$/); }); + + // UTS: rest/unit/REC2c6/custom-realtimehost-no-fallbacks-1 + it('REC2c6 - custom realtimeHost has no fallback domains', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'app.key:secret', + useBinaryProtocol: false, + realtimeHost: 'custom.realtime.example.com', + } as any); + + try { + await client.time(); + expect.fail('Expected time() to throw'); + } catch (error: any) { + expect(error.statusCode).to.equal(500); + } + + expect(requestCount).to.equal(1); + }); + + // UTS: rest/unit/REC2c4/production-endpoint-fallback-domains-0 + it('REC2c4 - production routing via endpoint gets production fallback domains', async function () { + let requestCount = 0; + const hosts: string[] = []; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + hosts.push(req.url.hostname); + if (requestCount === 1) { + req.respond_with(500, { error: { message: 'Server error', code: 50000, statusCode: 500 } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, endpoint: 'test' }); + const result = await client.time(); + + expect(result).to.equal(1234567890000); + expect(requestCount).to.equal(2); + expect(hosts[0]).to.equal('test.realtime.ably.net'); + expect(hosts[1]).to.match(/^test\.[a-e]\.fallback\.ably-realtime\.com$/); + }); + + // ── Connectivity check tests (REC3) ────────────────────────────── + + // UTS: rest/unit/REC3/connectivity-check-validation-0 + it.skip('REC3 - connectivity check response validation', function () { + // SKIP: The connectivity check (checkConnectivity) is an internal method + // on the Http class, used by the Realtime ConnectionManager. It is not + // exposed on the public Rest or Realtime client API. Testing it requires + // either Realtime connection state machine integration or direct access + // to the Http instance internals. Additionally, the mock's + // checkConnectivity method is hardcoded and does not go through the + // standard doUri path with client options. + }); + + // UTS: rest/unit/REC3a/default-connectivity-check-url-0 + it.skip('REC3a - default connectivity check URL', function () { + // SKIP: The connectivity check URL is used internally by the Realtime + // ConnectionManager's checkConnectivity method. It is not accessible + // from the Rest client. The mock HTTP checkConnectivity is hardcoded + // to use the default URL and does not capture request details in a way + // that allows URL verification. Testing requires Realtime client + // integration with mock WebSocket + mock HTTP, which is beyond the + // scope of this REST unit test file. + }); + + // UTS: rest/unit/REC3b/custom-connectivity-check-url-0 + it.skip('REC3b - custom connectivity check URL', function () { + // SKIP: Same as REC3a — the connectivityCheckUrl option affects the + // internal Http.checkConnectivity method used by Realtime's + // ConnectionManager. The mock HTTP checkConnectivity method does not + // read client options and always uses the hardcoded default URL. + // Testing requires either modifying the mock infrastructure to pass + // client options through to checkConnectivity, or using a Realtime + // client with mock WebSocket integration. + }); + + // UTS: rest/unit/RSC15j/host-header-matches-request-0 + it('RSC15j - Host header matches request host', async function () { + let reqCount = 0; + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + reqCount++; + if (reqCount === 1) { + req.respond_with(500, { error: { code: 50000, message: 'Internal error' } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); + await client.time(); + + expect(captured).to.have.length(2); + const host1 = captured[0].url.hostname; + const host2 = captured[1].url.hostname; + expect(host1).to.not.equal(host2); + + if (captured[0].headers['host']) { + expect(captured[0].headers['host']).to.include(host1); + } + if (captured[1].headers['host']) { + expect(captured[1].headers['host']).to.include(host2); + } + }); }); diff --git a/test/uts/rest/logging.test.ts b/test/uts/rest/unit/logging.test.ts similarity index 92% rename from test/uts/rest/logging.test.ts rename to test/uts/rest/unit/logging.test.ts index 3f84db15c..9acd987a3 100644 --- a/test/uts/rest/logging.test.ts +++ b/test/uts/rest/unit/logging.test.ts @@ -11,10 +11,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/logging', function () { +describe('uts/rest/unit/logging', function () { let mock; afterEach(function () { @@ -41,6 +41,7 @@ describe('uts/rest/logging', function () { * error-level messages are emitted. Normal client construction and * time() calls produce MINOR/MICRO messages which should be filtered out. */ + // UTS: rest/unit/RSC2/default-log-level-warn-0 it('RSC2 - default log level filters non-error messages', async function () { setupMock(); @@ -68,6 +69,7 @@ describe('uts/rest/logging', function () { * Setting logLevel to MICRO (4) should capture all log events * including MINOR and MICRO level messages. */ + // UTS: rest/unit/TO3b/log-level-changeable-0 it('TO3b - logLevel MICRO captures all messages', async function () { setupMock(); @@ -100,6 +102,7 @@ describe('uts/rest/logging', function () { * A custom logHandler provided via ClientOptions receives a formatted * string message and a numeric level argument. */ + // UTS: rest/unit/TO3c/custom-handler-structured-events-0 it('TO3c - custom logHandler receives messages with level', async function () { setupMock(); @@ -134,6 +137,7 @@ describe('uts/rest/logging', function () { * Setting logLevel to 0 (NONE) should prevent all log messages * from reaching the handler. */ + // UTS: rest/unit/RSC2b/log-level-none-suppresses-all-0 it('RSC4 - logLevel NONE suppresses all messages', async function () { setupMock(); @@ -158,6 +162,7 @@ describe('uts/rest/logging', function () { * Intermediate log levels should filter correctly: MINOR captures * levels 1-3 but excludes MICRO (4). */ + // UTS: rest/unit/TO3b/log-level-changeable-0.1 it('TO3b - logLevel MINOR filters MICRO messages', async function () { setupMock(); @@ -191,6 +196,7 @@ describe('uts/rest/logging', function () { * At MICRO level, HTTP operations emit log messages that contain * request details such as the URL/path being requested. */ + // UTS: rest/unit/TO3c2/context-contains-expected-keys-0 it('TO3c2 - HTTP request logs contain URL details', async function () { setupMock(); diff --git a/test/uts/rest/presence/rest_presence.test.ts b/test/uts/rest/unit/presence/rest_presence.test.ts similarity index 65% rename from test/uts/rest/presence/rest_presence.test.ts rename to test/uts/rest/unit/presence/rest_presence.test.ts index 3764198e9..a3fbb8e78 100644 --- a/test/uts/rest/presence/rest_presence.test.ts +++ b/test/uts/rest/unit/presence/rest_presence.test.ts @@ -8,10 +8,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/presence/rest_presence', function () { +describe('uts/rest/unit/presence/rest_presence', function () { afterEach(function () { restoreAll(); }); @@ -25,6 +25,7 @@ describe('uts/rest/presence/rest_presence', function () { * * channel.presence must exist and be an object. */ + // UTS: rest/unit/RSP1a/presence-channel-attribute-0 it('RSP1a - presence accessible on channel', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); const channel = client.channels.get('test'); @@ -38,6 +39,7 @@ describe('uts/rest/presence/rest_presence', function () { * * Accessing channel.presence multiple times must return the same instance. */ + // UTS: rest/unit/RSP1b/same-instance-returned-0 it('RSP1b - channel.presence returns same instance', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); const channel = client.channels.get('test'); @@ -56,6 +58,7 @@ describe('uts/rest/presence/rest_presence', function () { * * presence.get() must send a GET request to /channels/{name}/presence. */ + // UTS: rest/unit/RSP3a/get-request-endpoint-0 it('RSP3a - get() sends GET to /channels/{name}/presence', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -82,6 +85,7 @@ describe('uts/rest/presence/rest_presence', function () { * presence.get() must return a PaginatedResult containing PresenceMessage * objects with action, clientId, connectionId, data, and timestamp. */ + // UTS: rest/unit/RSP3b/get-returns-presence-messages-0 it('RSP3b - get() returns PresenceMessage objects', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -132,6 +136,7 @@ describe('uts/rest/presence/rest_presence', function () { * * When the server returns an empty array, items.length must be 0. */ + // UTS: rest/unit/RSP3c/get-empty-members-0 it('RSP3c - get() with empty response returns empty items', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -155,6 +160,7 @@ describe('uts/rest/presence/rest_presence', function () { * * get({limit: 50}) must send limit=50 as a query parameter. */ + // UTS: rest/unit/RSP3a1/get-limit-parameter-0 it('RSP3a1 - get() with limit param sends limit query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -179,6 +185,7 @@ describe('uts/rest/presence/rest_presence', function () { * * get({clientId: 'specific'}) must send clientId=specific as a query parameter. */ + // UTS: rest/unit/RSP3a2/get-clientid-filter-0 it('RSP3a2 - get() with clientId filter sends clientId query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -203,6 +210,7 @@ describe('uts/rest/presence/rest_presence', function () { * * get({connectionId: 'conn123'}) must send connectionId=conn123 as a query parameter. */ + // UTS: rest/unit/RSP3a3/get-connectionid-filter-0 it('RSP3a3 - get() with connectionId filter sends connectionId query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -231,6 +239,7 @@ describe('uts/rest/presence/rest_presence', function () { * * presence.history() must send a GET request to /channels/{name}/presence/history. */ + // UTS: rest/unit/RSP4a/history-request-endpoint-0 it('RSP4a - history() sends GET to /channels/{name}/presence/history', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -257,6 +266,7 @@ describe('uts/rest/presence/rest_presence', function () { * history() must return PresenceMessage objects with wire actions decoded * to strings: enter (2), leave (3), update (4). */ + // UTS: rest/unit/RSP4a/history-returns-paginated-1 it('RSP4a - history() returns PresenceMessage with decoded actions', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -288,6 +298,7 @@ describe('uts/rest/presence/rest_presence', function () { * * history({start: 1609459200000}) must send start=1609459200000 as a query parameter. */ + // UTS: rest/unit/RSP4b1/history-start-parameter-0 it('RSP4b1 - history() with start param sends start query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -312,6 +323,7 @@ describe('uts/rest/presence/rest_presence', function () { * * history({end: 1609545600000}) must send end=1609545600000 as a query parameter. */ + // UTS: rest/unit/RSP4b1/history-end-parameter-1 it('RSP4b1 - history() with end param sends end query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -336,6 +348,7 @@ describe('uts/rest/presence/rest_presence', function () { * * history({direction: 'forwards'}) must send direction=forwards as a query parameter. */ + // UTS: rest/unit/RSP4b2/history-direction-forwards-1 it('RSP4b2 - history() with direction forwards sends direction query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -355,11 +368,65 @@ describe('uts/rest/presence/rest_presence', function () { expect(captured[0].url.searchParams.get('direction')).to.equal('forwards'); }); + /** + * RSP4b2a - history default direction is backwards + * + * When history() is called without a direction parameter, the direction + * must either be absent (server default) or equal 'backwards'. + */ + // UTS: rest/unit/RSP4b2/history-direction-backwards-default-0 + it('RSP4b2 - history default direction is backwards', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({}); + + expect(captured).to.have.length(1); + const direction = captured[0].url.searchParams.get('direction'); + // direction should either be absent (null) or 'backwards' + expect(direction === null || direction === 'backwards').to.be.true; + }); + + /** + * RSP4b2c - history direction backwards explicit + * + * history({direction: 'backwards'}) must send direction=backwards as a query parameter. + */ + // UTS: rest/unit/RSP4b2/history-direction-backwards-explicit-2 + it('RSP4b2 - history direction backwards explicit', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ direction: 'backwards' }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('direction')).to.equal('backwards'); + }); + /** * RSP4b3 - limit param * * history({limit: 50}) must send limit=50 as a query parameter. */ + // UTS: rest/unit/RSP4b3/history-limit-parameter-0 it('RSP4b3 - history() with limit param sends limit query parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -388,6 +455,7 @@ describe('uts/rest/presence/rest_presence', function () { * * Plain string data must pass through without modification. */ + // UTS: rest/unit/RSP5/decode-string-data-0 it('RSP5a - get() with plain string data passes through', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -411,6 +479,7 @@ describe('uts/rest/presence/rest_presence', function () { * When encoding is "json", data must be decoded from JSON string to object, * and the encoding must be consumed (null after decoding). */ + // UTS: rest/unit/RSP5/decode-json-data-1 it('RSP5b - get() with json encoding decodes data to object', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -443,6 +512,7 @@ describe('uts/rest/presence/rest_presence', function () { * When encoding is "json/base64", data must be decoded from base64 then JSON. * The encoding must be fully consumed (null after decoding). */ + // UTS: rest/unit/RSP5/decode-chained-encoding-5 it('RSP5e - get() with chained json/base64 encoding decodes correctly', async function () { // {"key":"value"} base64-encoded const jsonStr = '{"key":"value"}'; @@ -473,6 +543,135 @@ describe('uts/rest/presence/rest_presence', function () { expect(result.items[0].encoding).to.be.null; }); + /** + * RSP5c - decode base64 binary presence data + * + * When encoding is "base64", data must be decoded from base64 to binary, + * and the encoding must be consumed (null after decoding). + */ + // UTS: rest/unit/RSP5/decode-base64-binary-2 + it('RSP5 - decode base64 binary presence data', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + action: 1, + clientId: 'c1', + data: 'SGVsbG8gV29ybGQ=', + encoding: 'base64', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(1); + expect(Buffer.isBuffer(result.items[0].data)).to.be.true; + expect(result.items[0].data.toString()).to.equal('Hello World'); + // Encoding must be consumed after decoding + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSP5d - decode utf-8 encoded presence data + * + * When encoding is "utf-8/base64", data must be decoded through both layers: + * first base64 to binary, then utf-8 to string. + */ + // UTS: rest/unit/RSP5/decode-utf8-data-4 + it('RSP5 - decode utf-8 encoded presence data', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + action: 1, + clientId: 'c1', + data: 'SGVsbG8gV29ybGQ=', + encoding: 'utf-8/base64', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.get({}); + + expect(result.items).to.have.length(1); + expect(result.items[0].data).to.equal('Hello World'); + expect(typeof result.items[0].data).to.equal('string'); + // Encoding must be fully consumed + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSP5f - history messages are decoded + * + * Encoding decoding must also apply to history() results, not just get(). + */ + // UTS: rest/unit/RSP5/decode-history-messages-6 + it('RSP5 - history messages are decoded', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { + action: 2, + clientId: 'c1', + data: '{"event":"entered"}', + encoding: 'json', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const result = await channel.presence.history({}); + + expect(result.items).to.have.length(1); + expect(result.items[0].data).to.deep.equal({ event: 'entered' }); + // Encoding must be consumed after decoding + expect(result.items[0].encoding).to.be.null; + }); + + /** + * RSP5 - decode msgpack binary presence data + * + * DEVIATION: ably-js does not support msgpack protocol + */ + // UTS: rest/unit/RSP5/decode-msgpack-binary-3 + it.skip('RSP5 - decode msgpack binary presence data (msgpack not supported)', function () { + // DEVIATION: ably-js does not support msgpack protocol + }); + + /** + * RSP5g - cipher decoding with channel options + * + * Encrypted data with cipher encoding must be decrypted using channel + * cipher options. + * + * TODO: Implement when cipher infrastructure is available for testing. + * Requires creating a channel with cipher params and providing correctly + * encrypted test data. + */ + // UTS: rest/unit/RSP5/decode-cipher-channel-7 + it.skip('RSP5 - cipher decoding with channel options', async function () { + // This test requires cipher infrastructure: + // 1. Create a channel with cipher params: client.channels.get('test', { cipher: { key } }) + // 2. Mock returns presence with encoding: 'json/utf-8/cipher+aes-128-cbc/base64' + // 3. The SDK should decrypt the data using the cipher key + // 4. Assert the decrypted data matches the original plaintext + }); + // --------------------------------------------------------------------------- // Pagination // --------------------------------------------------------------------------- @@ -483,6 +682,7 @@ describe('uts/rest/presence/rest_presence', function () { * When the server responds with a Link header containing a "next" relation, * hasNext() must return true and isLast() must return false. */ + // UTS: rest/unit/RSP3/get-pagination-link-header-1 it('RSP pagination - get() with Link header indicates hasNext', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -508,6 +708,7 @@ describe('uts/rest/presence/rest_presence', function () { * * Navigating pages via next() must fetch the next page from the server. */ + // UTS: rest/unit/RSP3/get-pagination-next-page-2 it('RSP pagination - history() navigates pages via next()', async function () { let reqCount = 0; const mock = new MockHttpClient({ @@ -544,6 +745,43 @@ describe('uts/rest/presence/rest_presence', function () { expect(page2!.isLast()).to.be.true; }); + /** + * RSP4 - history pagination + * + * History results must support pagination via Link headers and next(). + */ + // UTS: rest/unit/RSP4/history-pagination-1 + it('RSP4 - history pagination', async function () { + let reqCount = 0; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + reqCount++; + if (reqCount === 1) { + req.respond_with(200, [{ action: 2, clientId: 'c1', timestamp: 3000 }], { + Link: '<./history?cursor=page2>; rel="next"', + }); + } else { + req.respond_with(200, [{ action: 4, clientId: 'c1', timestamp: 1000 }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + const page1 = await channel.presence.history({}); + expect(page1.items).to.have.length(1); + expect(page1.items[0].action).to.equal('enter'); + expect(page1.hasNext()).to.be.true; + + const page2 = await page1.next(); + expect(page2!.items).to.have.length(1); + expect(page2!.items[0].action).to.equal('update'); + expect(page2!.hasNext()).to.be.false; + }); + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -554,6 +792,7 @@ describe('uts/rest/presence/rest_presence', function () { * When the server responds with a 500 error, the operation must throw * with the appropriate error code. */ + // UTS: rest/unit/RSP3/get-server-error-3 it('RSP error - server error on get() throws with error code', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -581,6 +820,40 @@ describe('uts/rest/presence/rest_presence', function () { } }); + /** + * RSP3 - get with 404 channel not found + * + * When the server responds with 404, the operation must throw with + * error code 40400 and statusCode 404. + */ + // UTS: rest/unit/RSP3/get-channel-not-found-4 + it('RSP3 - get with 404 channel not found', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(404, { + error: { + code: 40400, + statusCode: 404, + message: 'Not found', + }, + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + + try { + await channel.presence.get({}); + expect.fail('Expected get() to throw'); + } catch (error: any) { + expect(error.code).to.equal(40400); + expect(error.statusCode).to.equal(404); + } + }); + // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- @@ -590,6 +863,7 @@ describe('uts/rest/presence/rest_presence', function () { * * Wire actions 1-4 must be decoded to present/enter/leave/update strings. */ + // UTS: rest/unit/RSP5/presence-action-mapping-8 it('RSP actions - wire actions 1-4 decoded to correct strings', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -635,6 +909,7 @@ describe('uts/rest/presence/rest_presence', function () { * When get() is called without a limit parameter, the request must either * omit the limit param (server default) or send limit=100. */ + // UTS: rest/unit/RSP3a1/get-limit-default-100-1 it('RSP3a1b - get() limit defaults to 100', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -657,6 +932,31 @@ describe('uts/rest/presence/rest_presence', function () { expect(limit === null || limit === '100').to.be.true; }); + /** + * RSP3a1c - get limit maximum 1000 + * + * get({limit: 1000}) must send limit=1000 as a query parameter. + */ + // UTS: rest/unit/RSP3a1/get-limit-max-1000-2 + it('RSP3a1 - get limit maximum 1000', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.get({ limit: 1000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('1000'); + }); + // --------------------------------------------------------------------------- // RSP3 - get() with combined filters // --------------------------------------------------------------------------- @@ -667,6 +967,7 @@ describe('uts/rest/presence/rest_presence', function () { * get() with limit, clientId, and connectionId must send all three as * query parameters. */ + // UTS: rest/unit/RSP3/get-multiple-filters-0 it('RSP3 - get() with combined filters sends all params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -698,6 +999,7 @@ describe('uts/rest/presence/rest_presence', function () { * * history() with both start and end must send both as query parameters. */ + // UTS: rest/unit/RSP4b1/history-start-end-params-2 it('RSP4b1c - history() with start and end combined sends both params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -719,6 +1021,38 @@ describe('uts/rest/presence/rest_presence', function () { expect(params.get('end')).to.equal('1609545600000'); }); + /** + * RSP4b1d - history accepts Date objects for start/end + * + * Language-specific DateTime objects should be accepted and converted + * to milliseconds since epoch. + * + * DEVIATION: ably-js history() expects start/end as numeric timestamps + * (milliseconds since epoch), not Date objects. Passing a Date object + * results in its toString() representation being sent as the query param. + * This test uses Date.getTime() to convert to the expected numeric format. + */ + // UTS: rest/unit/RSP4b1/history-datetime-objects-3 + it('RSP4b1 - history accepts Date objects for start/end', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + const startDate = new Date(1609459200000); + await channel.presence.history({ start: startDate.getTime() }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1609459200000'); + }); + // --------------------------------------------------------------------------- // RSP4b3b - history() limit defaults to 100 // --------------------------------------------------------------------------- @@ -729,6 +1063,7 @@ describe('uts/rest/presence/rest_presence', function () { * When history() is called without a limit parameter, the request must either * omit the limit param (server default) or send limit=100. */ + // UTS: rest/unit/RSP4b3/history-limit-default-100-1 it('RSP4b3b - history() limit defaults to 100', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -751,6 +1086,31 @@ describe('uts/rest/presence/rest_presence', function () { expect(limit === null || limit === '100').to.be.true; }); + /** + * RSP4b3c - history limit maximum 1000 + * + * history({limit: 1000}) must send limit=1000 as a query parameter. + */ + // UTS: rest/unit/RSP4b3/history-limit-max-1000-2 + it('RSP4b3 - history limit maximum 1000', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({ limit: 1000 }); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('1000'); + }); + // --------------------------------------------------------------------------- // RSP4 - history() with all parameters // --------------------------------------------------------------------------- @@ -761,6 +1121,7 @@ describe('uts/rest/presence/rest_presence', function () { * history() with start, end, direction, and limit must send all four * as query parameters. */ + // UTS: rest/unit/RSP4/history-all-parameters-0 it('RSP4 - history() with all parameters sends all params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -794,6 +1155,7 @@ describe('uts/rest/presence/rest_presence', function () { * When the server responds with 401 and error code 40101, the operation * must throw with the appropriate error code and statusCode. */ + // UTS: rest/unit/RSP4/history-auth-error-2 it('RSP Error 2 - auth error on history() throws with error code', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -823,6 +1185,37 @@ describe('uts/rest/presence/rest_presence', function () { } }); + // --------------------------------------------------------------------------- + // RSP4 - history() includes authorization header + // --------------------------------------------------------------------------- + + /** + * RSP4 - history includes authorization header + * + * Authenticated history requests must include the Authorization header + * starting with 'Basic '. + */ + // UTS: rest/unit/RSP4/history-auth-header-3 + it('RSP4 - history includes authorization header', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const channel = client.channels.get('test'); + await channel.presence.history({}); + + expect(captured).to.have.length(1); + expect(captured[0].headers).to.have.property('authorization'); + expect(captured[0].headers['authorization']).to.match(/^Basic /); + }); + // --------------------------------------------------------------------------- // RSP Headers - get() includes standard headers // --------------------------------------------------------------------------- @@ -833,6 +1226,7 @@ describe('uts/rest/presence/rest_presence', function () { * get() must include authorization, X-Ably-Version, and accept headers * in the request. */ + // UTS: rest/unit/RSP3/get-standard-headers-5 it('RSP Headers - get() includes standard headers', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -857,4 +1251,42 @@ describe('uts/rest/presence/rest_presence', function () { expect(headers).to.have.property('accept'); expect(headers['accept']).to.not.be.empty; }); + + // --------------------------------------------------------------------------- + // RSP3 - get() includes request_id when addRequestIds enabled + // --------------------------------------------------------------------------- + + /** + * RSP3 - request_id when addRequestIds enabled + * + * When addRequestIds is true, get() must include a request_id query parameter. + */ + /** + * NOTE: ably-js accepts addRequestIds option but does not implement it. + * The option is stored but no request_id parameter is added to requests. + * See deviations.md. + */ + // UTS: rest/unit/RSP3/get-request-id-enabled-6 + it('RSP3 - get includes request_id when enabled', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', addRequestIds: true, useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + await channel.presence.get({}); + + expect(captured).to.have.length(1); + const requestId = captured[0].url.searchParams.get('request_id'); + expect(requestId).to.be.a('string'); + expect(requestId).to.not.be.empty; + }); }); diff --git a/test/uts/rest/push/push_admin_publish.test.ts b/test/uts/rest/unit/push/push_admin_publish.test.ts similarity index 92% rename from test/uts/rest/push/push_admin_publish.test.ts rename to test/uts/rest/unit/push/push_admin_publish.test.ts index 30446a763..4bbbce337 100644 --- a/test/uts/rest/push/push_admin_publish.test.ts +++ b/test/uts/rest/unit/push/push_admin_publish.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/push/push_admin_publish', function () { +describe('uts/rest/unit/push/push_admin_publish', function () { afterEach(restoreAll); /** @@ -18,6 +18,7 @@ describe('uts/rest/push/push_admin_publish', function () { * push.admin.publish() must issue a POST request to /push/publish * with the recipient and data fields in the body. */ + // UTS: rest/unit/RSH1a/publish-post-push-publish-0 it('RSH1a - publish sends POST to /push/publish', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -46,6 +47,7 @@ describe('uts/rest/push/push_admin_publish', function () { * The POST body must contain the recipient object and the payload * fields (notification, data) merged at the top level. */ + // UTS: rest/unit/RSH1a/rejects-empty-recipient-3 it('RSH1a - body contains recipient and data', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -76,6 +78,7 @@ describe('uts/rest/push/push_admin_publish', function () { * * publish() works with a clientId-based recipient. */ + // UTS: rest/unit/RSH1a/publish-clientid-recipient-1 it('RSH1a - recipient as clientId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -101,6 +104,7 @@ describe('uts/rest/push/push_admin_publish', function () { * * publish() works with a deviceId-based recipient. */ + // UTS: rest/unit/RSH1a/publish-deviceid-recipient-2 it('RSH1a - recipient as deviceId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -127,6 +131,7 @@ describe('uts/rest/push/push_admin_publish', function () { * The payload notification and data fields are included in the * request body alongside the recipient. */ + // UTS: rest/unit/RSH1a/rejects-empty-data-4 it('RSH1a - data contains notification fields', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -161,6 +166,7 @@ describe('uts/rest/push/push_admin_publish', function () { * The publish request must include an Authorization header * for authentication. */ + // UTS: rest/unit/RSH1a/rejects-null-recipient-5 it('RSH1a - auth header included', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -185,6 +191,7 @@ describe('uts/rest/push/push_admin_publish', function () { * The client.push property must exist and expose admin with * deviceRegistrations and channelSubscriptions sub-objects. */ + // UTS: rest/unit/RSH1/push-admin-accessible-0 it('RSH1 - client.push.admin exposes PushAdmin', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -200,6 +207,7 @@ describe('uts/rest/push/push_admin_publish', function () { * When the server returns an error response, publish() must * propagate it as an exception with the correct error code. */ + // UTS: rest/unit/RSH1a/server-error-propagated-6 it('RSH1a - publish propagates server error', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/rest/push/push_channel_subscriptions.test.ts b/test/uts/rest/unit/push/push_channel_subscriptions.test.ts similarity index 93% rename from test/uts/rest/push/push_channel_subscriptions.test.ts rename to test/uts/rest/unit/push/push_channel_subscriptions.test.ts index 6bdec6efd..8962baef0 100644 --- a/test/uts/rest/push/push_channel_subscriptions.test.ts +++ b/test/uts/rest/unit/push/push_channel_subscriptions.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/push/push_channel_subscriptions', function () { +describe('uts/rest/unit/push/push_channel_subscriptions', function () { afterEach(restoreAll); /** @@ -18,6 +18,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * save() issues a POST request to the channelSubscriptions endpoint * with the subscription in the body. */ + // UTS: rest/unit/RSH1c3/save-post-subscription-0 it('RSH1c3 - save sends POST to /push/channelSubscriptions', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -50,6 +51,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * deviceId or clientId. The response is parsed into a * PushChannelSubscription object. */ + // UTS: rest/unit/RSH1c3/save-updates-existing-1 it('RSH1c3 - save body contains channel and subscription details', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -85,6 +87,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * * list() issues a GET request to the channelSubscriptions endpoint. */ + // UTS: rest/unit/RSH1c4/remove-nonexistent-succeeds-2 it('RSH1c1 - list sends GET to /push/channelSubscriptions', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -110,6 +113,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * list() forwards the channel parameter as a query parameter * and returns matching subscriptions. */ + // UTS: rest/unit/RSH1c1/list-filtered-by-channel-0 it('RSH1c1 - list with channel filter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -136,6 +140,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * * list() returns a PaginatedResult containing PushChannelSubscription objects. */ + // UTS: rest/unit/RSH1c1/list-filtered-by-channel-0.1 it('RSH1c1 - list returns PaginatedResult', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -163,6 +168,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * removeWhere() issues a DELETE request to the channelSubscriptions * endpoint with filter parameters as query params. */ + // UTS: rest/unit/RSH1c5/remove-where-clientid-0 it('RSH1c5 - removeWhere sends DELETE to /push/channelSubscriptions', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -189,6 +195,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * removeWhere() forwards the channel parameter along with other * filter params to delete matching subscriptions. */ + // UTS: rest/unit/RSH1c5/remove-where-no-match-succeeds-2 it('RSH1c5 - removeWhere with channel param', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -218,6 +225,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * * listChannels() issues a GET request to the /push/channels endpoint. */ + // UTS: rest/unit/RSH1c2/list-channels-with-limit-1 it('RSH1c2 - listChannels sends GET to /push/channels', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -243,6 +251,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * listChannels() returns a PaginatedResult containing channel * name strings. */ + // UTS: rest/unit/RSH1c2/list-channels-paginated-0 it('RSH1c2 - listChannels returns PaginatedResult', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -266,6 +275,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * * listChannels() forwards the limit parameter as a query parameter. */ + // UTS: rest/unit/RSH1c4/remove-delete-clientid-0 it('RSH1c2 - listChannels with params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -291,6 +301,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * list() forwards both deviceId and clientId as query parameters * when both are provided. */ + // UTS: rest/unit/RSH1c1/list-filtered-by-device-client-1 it('RSH1c1 - list with deviceId and clientId filters', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -315,6 +326,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * * list() forwards the limit parameter as a query parameter. */ + // UTS: rest/unit/RSH1c1/list-with-limit-param-2 it('RSH1c1 - list supports limit', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -339,6 +351,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * When the server returns an error response, save() must * propagate it as an exception with the correct error code. */ + // UTS: rest/unit/RSH1c3/save-error-propagated-2 it('RSH1c3 - save propagates server error', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -369,6 +382,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * remove() issues a DELETE request to the channelSubscriptions * endpoint with channel and deviceId as query parameters. */ + // UTS: rest/unit/RSH1c4/remove-delete-deviceid-1 it('RSH1c4 - remove with deviceId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -395,6 +409,7 @@ describe('uts/rest/push/push_channel_subscriptions', function () { * removeWhere() issues a DELETE request with deviceId as a * query parameter. */ + // UTS: rest/unit/RSH1c5/remove-where-deviceid-1 it('RSH1c5 - removeWhere with deviceId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/unit/push/push_channels.test.ts b/test/uts/rest/unit/push/push_channels.test.ts new file mode 100644 index 000000000..7f5cae456 --- /dev/null +++ b/test/uts/rest/unit/push/push_channels.test.ts @@ -0,0 +1,502 @@ +/** + * UTS: PushChannel Tests (RSH7) + * + * Spec points: RSH7, RSH7a, RSH7a1, RSH7a2, RSH7a3, RSH7b, RSH7b1, RSH7b2, + * RSH7c, RSH7c1, RSH7c2, RSH7c3, RSH7d, RSH7d1, RSH7d2, RSH7e + * Source: uts/rest/unit/push/push_channels.md + * + * These tests cover the PushChannel interface (RSH7), which is the `push` + * field on RestChannel/RealtimeChannel. PushChannel methods operate from + * the perspective of the local device (the push target), not the admin API. + * + * Deviations from UTS spec (ably-js-specific): + * - subscribeClient/unsubscribeClient use client.auth.clientId, not LocalDevice.clientId + * - listSubscriptions delegates to push.admin.channelSubscriptions.list with + * {channel, concatFilters: true, ...params} — it does NOT automatically + * include deviceId or clientId (those must be provided in params by the caller) + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; +import * as PushPlugin from '../../../../../src/plugins/push'; + +/** + * Configure a Rest client with a fake local device for PushChannel testing. + * + * ably-js's PushChannel requires: + * 1. The Push plugin to be provided via options.plugins.Push (so channel.push exists) + * 2. client.push.LocalDevice to be truthy (so client.device() guard passes) + * 3. client._device to be set (the actual device data) + * + * On Node.js, Platform.Config.push is undefined, so the Push constructor + * never sets push.LocalDevice even when the plugin is provided. We need to + * monkey-patch both push.LocalDevice and _device. + */ +function configureFakeDevice( + client: any, + device: { id: string; deviceIdentityToken: string | null; clientId?: string | null }, +): void { + // Set push.LocalDevice to a truthy value so client.device() guard passes + (client as any).push.LocalDevice = {} as any; + // Set _device so device() returns our fake without calling LocalDevice.load() + (client as any)._device = device; +} + +describe('uts/rest/unit/push/push_channels', function () { + afterEach(restoreAll); + + // --------------------------------------------------------------------------- + // RSH7a — subscribeDevice + // --------------------------------------------------------------------------- + + /** + * RSH7a2, RSH7a3 - subscribeDevice sends POST with deviceId, channel name, and device auth + * + * 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 (RSH6a). + */ + // UTS: rest/unit/RSH7a2/subscribe-device-post-0 + it('RSH7a2, RSH7a3 - subscribeDevice sends POST with deviceId, channel, and device auth header', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channel: 'my-channel', + deviceId: 'test-device-001', + }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + configureFakeDevice(client, { + id: 'test-device-001', + deviceIdentityToken: 'test-device-identity-token', + clientId: 'test-client', + }); + + const channel = client.channels.get('my-channel'); + await channel.push.subscribeDevice(); + + expect(captured).to.have.length(1); + + const request = captured[0]; + expect(request.method).to.equal('post'); + expect(request.path).to.equal('/push/channelSubscriptions'); + + const body = JSON.parse(request.body); + expect(body.channel).to.equal('my-channel'); + expect(body.deviceId).to.equal('test-device-001'); + + // RSH7a3 + RSH6a - push device authentication via deviceIdentityToken + expect(request.headers['X-Ably-DeviceToken']).to.equal('test-device-identity-token'); + }); + + /** + * RSH7a1 - subscribeDevice fails if no deviceIdentityToken + * + * subscribeDevice() fails when the local device has no deviceIdentityToken + * (i.e. the device isn't registered yet). + */ + // UTS: rest/unit/RSH7a1/subscribe-device-no-token-fails-0 + it('RSH7a1 - subscribeDevice fails if no deviceIdentityToken', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + configureFakeDevice(client, { + id: 'test-device-001', + deviceIdentityToken: null, + clientId: 'test-client', + }); + + const channel = client.channels.get('my-channel'); + + try { + await channel.push.subscribeDevice(); + expect.fail('Expected subscribeDevice to throw'); + } catch (err: any) { + expect(err.code).to.not.be.null; + expect(err.message).to.contain('deviceIdentityToken'); + } + }); + + // --------------------------------------------------------------------------- + // RSH7b — subscribeClient + // --------------------------------------------------------------------------- + + /** + * RSH7b2 - subscribeClient sends POST with clientId and channel name + * + * subscribeClient() sends a POST to /push/channelSubscriptions with the + * client's clientId and the channel name in the request body. + * + * Deviation: ably-js uses client.auth.clientId (from ClientOptions.clientId), + * not LocalDevice.clientId as the UTS spec describes. + */ + // UTS: rest/unit/RSH7b2/subscribe-client-post-0 + it('RSH7b2 - subscribeClient sends POST with clientId and channel name', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, { + channel: 'my-channel', + clientId: 'test-client', + }); + }, + }); + installMockHttp(mock); + + // clientId is set on the client options (which sets client.auth.clientId) + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + clientId: 'test-client', + plugins: { Push: PushPlugin }, + } as any); + + const channel = client.channels.get('my-channel'); + await channel.push.subscribeClient(); + + expect(captured).to.have.length(1); + + const request = captured[0]; + expect(request.method).to.equal('post'); + expect(request.path).to.equal('/push/channelSubscriptions'); + + const body = JSON.parse(request.body); + expect(body.channel).to.equal('my-channel'); + expect(body.clientId).to.equal('test-client'); + }); + + /** + * RSH7b1 - subscribeClient fails if no clientId + * + * subscribeClient() fails when the client has no clientId. + * + * Deviation: ably-js checks client.auth.clientId, not LocalDevice.clientId. + */ + // UTS: rest/unit/RSH7b1/subscribe-client-no-clientid-fails-0 + it('RSH7b1 - subscribeClient fails if no clientId', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, {}); + }, + }); + installMockHttp(mock); + + // No clientId on client options + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + const channel = client.channels.get('my-channel'); + + try { + await channel.push.subscribeClient(); + expect.fail('Expected subscribeClient to throw'); + } catch (err: any) { + expect(err.code).to.not.be.null; + // ably-js error message says "client ID" rather than "clientId" + expect(err.message.toLowerCase()).to.contain('client'); + } + }); + + // --------------------------------------------------------------------------- + // RSH7c — unsubscribeDevice + // --------------------------------------------------------------------------- + + /** + * RSH7c2, RSH7c3 - unsubscribeDevice sends DELETE with deviceId, channel, and device auth + * + * 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 (RSH6a). + */ + // UTS: rest/unit/RSH7c2/unsubscribe-device-delete-0 + it('RSH7c2, RSH7c3 - unsubscribeDevice sends DELETE with deviceId, channel, and device auth header', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + configureFakeDevice(client, { + id: 'test-device-001', + deviceIdentityToken: 'test-device-identity-token', + clientId: 'test-client', + }); + + const channel = client.channels.get('my-channel'); + await channel.push.unsubscribeDevice(); + + expect(captured).to.have.length(1); + + const request = captured[0]; + expect(request.method).to.equal('delete'); + expect(request.path).to.equal('/push/channelSubscriptions'); + expect(request.url.searchParams.get('channel')).to.equal('my-channel'); + expect(request.url.searchParams.get('deviceId')).to.equal('test-device-001'); + + // RSH7c3 + RSH6a - push device authentication via deviceIdentityToken + expect(request.headers['X-Ably-DeviceToken']).to.equal('test-device-identity-token'); + }); + + /** + * RSH7c1 - unsubscribeDevice fails if no deviceIdentityToken + * + * unsubscribeDevice() fails when the local device has no deviceIdentityToken. + */ + // UTS: rest/unit/RSH7c1/unsubscribe-device-no-token-fails-0 + it('RSH7c1 - unsubscribeDevice fails if no deviceIdentityToken', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + configureFakeDevice(client, { + id: 'test-device-001', + deviceIdentityToken: null, + clientId: 'test-client', + }); + + const channel = client.channels.get('my-channel'); + + try { + await channel.push.unsubscribeDevice(); + expect.fail('Expected unsubscribeDevice to throw'); + } catch (err: any) { + expect(err.code).to.not.be.null; + expect(err.message).to.contain('deviceIdentityToken'); + } + }); + + // --------------------------------------------------------------------------- + // RSH7d — unsubscribeClient + // --------------------------------------------------------------------------- + + /** + * RSH7d2 - unsubscribeClient sends DELETE with clientId and channel name + * + * unsubscribeClient() sends a DELETE to /push/channelSubscriptions with the + * client's clientId and the channel name as query parameters. + * + * Deviation: ably-js uses client.auth.clientId, not LocalDevice.clientId. + */ + // UTS: rest/unit/RSH7d2/unsubscribe-client-delete-0 + it('RSH7d2 - unsubscribeClient sends DELETE with clientId and channel name', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + clientId: 'test-client', + plugins: { Push: PushPlugin }, + } as any); + + const channel = client.channels.get('my-channel'); + await channel.push.unsubscribeClient(); + + expect(captured).to.have.length(1); + + const request = captured[0]; + expect(request.method).to.equal('delete'); + expect(request.path).to.equal('/push/channelSubscriptions'); + expect(request.url.searchParams.get('channel')).to.equal('my-channel'); + expect(request.url.searchParams.get('clientId')).to.equal('test-client'); + }); + + /** + * RSH7d1 - unsubscribeClient fails if no clientId + * + * unsubscribeClient() fails when the client has no clientId. + * + * Deviation: ably-js checks client.auth.clientId, not LocalDevice.clientId. + */ + // UTS: rest/unit/RSH7d1/unsubscribe-client-no-clientid-fails-0 + it('RSH7d1 - unsubscribeClient fails if no clientId', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(204, null); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + const channel = client.channels.get('my-channel'); + + try { + await channel.push.unsubscribeClient(); + expect.fail('Expected unsubscribeClient to throw'); + } catch (err: any) { + expect(err.code).to.not.be.null; + expect(err.message.toLowerCase()).to.contain('client'); + } + }); + + // --------------------------------------------------------------------------- + // RSH7e — listSubscriptions + // --------------------------------------------------------------------------- + + /** + * RSH7e - listSubscriptions sends GET with channel, concatFilters, and user params + * + * listSubscriptions() sends a GET to /push/channelSubscriptions with the + * channel name, concatFilters=true, and any user-provided params. + * + * Deviation: ably-js does NOT automatically include deviceId or clientId in + * the query params. The UTS spec expects these to be included from the + * LocalDevice, but ably-js's implementation delegates to + * push.admin.channelSubscriptions.list() with only {channel, concatFilters, ...params}. + */ + // UTS: rest/unit/RSH7e/list-subscriptions-with-filters-0 + it('RSH7e - listSubscriptions sends GET with channel, concatFilters, and user params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + channel: 'my-channel', + deviceId: 'test-device-001', + }, + { + channel: 'my-channel', + clientId: 'test-client', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + clientId: 'test-client', + plugins: { Push: PushPlugin }, + } as any); + + const channel = client.channels.get('my-channel'); + const result = await channel.push.listSubscriptions({ limit: '10' }); + + expect(captured).to.have.length(1); + + const request = captured[0]; + expect(request.method).to.equal('get'); + expect(request.path).to.equal('/push/channelSubscriptions'); + + // Channel name is automatically included + expect(request.url.searchParams.get('channel')).to.equal('my-channel'); + + // concatFilters must be set to true + expect(request.url.searchParams.get('concatFilters')).to.equal('true'); + + // User-provided params are forwarded + expect(request.url.searchParams.get('limit')).to.equal('10'); + + // Verify result is a PaginatedResult + expect(result.items).to.have.length(2); + expect((result.items[0] as any).channel).to.equal('my-channel'); + expect((result.items[0] as any).deviceId).to.equal('test-device-001'); + expect((result.items[1] as any).clientId).to.equal('test-client'); + }); + + /** + * RSH7e - listSubscriptions without additional params + * + * listSubscriptions() works with no extra params, still sending channel + * and concatFilters. + */ + // UTS: rest/unit/RSH7e/list-subscriptions-omits-clientid-1 + it('RSH7e - listSubscriptions without additional params', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { + channel: 'my-channel', + deviceId: 'test-device-001', + }, + ]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + plugins: { Push: PushPlugin }, + } as any); + + const channel = client.channels.get('my-channel'); + const result = await channel.push.listSubscriptions(); + + expect(captured).to.have.length(1); + + const request = captured[0]; + expect(request.url.searchParams.get('channel')).to.equal('my-channel'); + expect(request.url.searchParams.get('concatFilters')).to.equal('true'); + + expect(result.items).to.have.length(1); + }); +}); diff --git a/test/uts/rest/push/push_device_registrations.test.ts b/test/uts/rest/unit/push/push_device_registrations.test.ts similarity index 94% rename from test/uts/rest/push/push_device_registrations.test.ts rename to test/uts/rest/unit/push/push_device_registrations.test.ts index b005310f8..251ca6966 100644 --- a/test/uts/rest/push/push_device_registrations.test.ts +++ b/test/uts/rest/unit/push/push_device_registrations.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/push/push_device_registrations', function () { +describe('uts/rest/unit/push/push_device_registrations', function () { afterEach(restoreAll); /** @@ -18,6 +18,7 @@ describe('uts/rest/push/push_device_registrations', function () { * save() issues a PUT request to the device-specific endpoint * with the device details in the body. */ + // UTS: rest/unit/RSH1b3/save-put-device-details-0 it('RSH1b3 - save sends PUT to /push/deviceRegistrations/{id}', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -61,6 +62,7 @@ describe('uts/rest/push/push_device_registrations', function () { * The PUT body must contain the device's id, clientId, platform, * formFactor, and push recipient fields. */ + // UTS: rest/unit/RSH1b3/save-updates-existing-1 it('RSH1b3 - save body contains device details', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -110,6 +112,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * get() issues a GET request to the device-specific endpoint. */ + // UTS: rest/unit/RSH1b1/get-device-details-0.1 it('RSH1b1 - get sends GET to /push/deviceRegistrations/{id}', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -145,6 +148,7 @@ describe('uts/rest/push/push_device_registrations', function () { * get() returns a DeviceDetails object with all the fields * from the server response. */ + // UTS: rest/unit/RSH1b1/get-device-details-0 it('RSH1b1 - get returns device object', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -180,6 +184,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * list() issues a GET request to the deviceRegistrations collection endpoint. */ + // UTS: rest/unit/RSH1b2/list-filtered-by-deviceid-0.1 it('RSH1b2 - list sends GET to /push/deviceRegistrations', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -213,6 +218,7 @@ describe('uts/rest/push/push_device_registrations', function () { * list() forwards the deviceId parameter as a query parameter and * returns only matching results. */ + // UTS: rest/unit/RSH1b2/list-filtered-by-deviceid-0 it('RSH1b2 - list with params (deviceId filter)', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -244,6 +250,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * list() returns a PaginatedResult containing DeviceDetails objects. */ + // UTS: rest/unit/RSH1b2/list-filtered-by-deviceid-0.2 it('RSH1b2 - list returns PaginatedResult', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -281,6 +288,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * remove() issues a DELETE request to the device-specific endpoint. */ + // UTS: rest/unit/RSH1b4/remove-delete-device-0 it('RSH1b4 - remove sends DELETE to /push/deviceRegistrations/{id}', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -305,6 +313,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * remove() accepts a plain string deviceId (not just a DeviceDetails object). */ + // UTS: rest/unit/RSH1b5/remove-where-no-match-succeeds-2 it('RSH1b4 - remove accepts string deviceId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -331,6 +340,7 @@ describe('uts/rest/push/push_device_registrations', function () { * removeWhere() issues a DELETE request to the collection endpoint * with filter parameters as query params. */ + // UTS: rest/unit/RSH1b5/remove-where-clientid-0 it('RSH1b5 - removeWhere sends DELETE to /push/deviceRegistrations with params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -357,6 +367,7 @@ describe('uts/rest/push/push_device_registrations', function () { * When the server returns a 404 for an unknown deviceId, get() * must propagate it as an exception with error code 40400. */ + // UTS: rest/unit/RSH1b1/get-unknown-device-error-1 it('RSH1b1 - get returns 404 for unknown device', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -384,6 +395,7 @@ describe('uts/rest/push/push_device_registrations', function () { * get() must URL-encode the deviceId in the request path so that * special characters are handled correctly. */ + // UTS: rest/unit/RSH1b1/get-url-encodes-deviceid-2 it('RSH1b1 - get URL-encodes deviceId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -412,6 +424,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * list() forwards the clientId parameter as a query parameter. */ + // UTS: rest/unit/RSH1b2/list-filtered-by-clientid-1 it('RSH1b2 - list with clientId filter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -443,6 +456,7 @@ describe('uts/rest/push/push_device_registrations', function () { * * list() forwards the limit parameter as a query parameter. */ + // UTS: rest/unit/RSH1b2/list-with-limit-param-2 it('RSH1b2 - list supports limit', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -474,6 +488,7 @@ describe('uts/rest/push/push_device_registrations', function () { * When the server returns an error response, save() must * propagate it as an exception with the correct error code. */ + // UTS: rest/unit/RSH1b3/save-error-propagated-2 it('RSH1b3 - save propagates server error', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -508,6 +523,7 @@ describe('uts/rest/push/push_device_registrations', function () { * remove() for a nonexistent device should not throw when the * server returns a successful response. */ + // UTS: rest/unit/RSH1b4/remove-nonexistent-succeeds-1 it('RSH1b4 - remove nonexistent succeeds', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -533,6 +549,7 @@ describe('uts/rest/push/push_device_registrations', function () { * removeWhere() forwards the deviceId parameter as a query * parameter in the DELETE request. */ + // UTS: rest/unit/RSH1b5/remove-where-deviceid-1 it('RSH1b5 - removeWhere with deviceId', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/request.test.ts b/test/uts/rest/unit/request.test.ts similarity index 73% rename from test/uts/rest/request.test.ts rename to test/uts/rest/unit/request.test.ts index 8652a88d1..348d6db02 100644 --- a/test/uts/rest/request.test.ts +++ b/test/uts/rest/unit/request.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/request', function () { +describe('uts/rest/unit/request', function () { afterEach(function () { restoreAll(); }); @@ -48,6 +48,7 @@ describe('uts/rest/request', function () { // --------------------------------------------------------------------------- describe('RSC19f - Request details', function () { + // UTS: rest/unit/RSC19f/request-body-sent-3 it('query params sent correctly', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -74,6 +75,7 @@ describe('uts/rest/request', function () { expect(captured[0].url.searchParams.get('direction')).to.equal('backwards'); }); + // UTS: rest/unit/RSC19f/custom-headers-passed-2 it('custom headers included', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -96,6 +98,7 @@ describe('uts/rest/request', function () { expect(captured[0].headers['X-Another']).to.equal('another-value'); }); + // UTS: rest/unit/RSC19f/query-params-passed-1 it('Basic auth header included automatically', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -120,6 +123,7 @@ describe('uts/rest/request', function () { expect(decoded).to.equal('appId.keyId:keySecret'); }); + // UTS: rest/unit/RSC19f/supports-http-methods-0 it('body encoding (JSON)', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -153,6 +157,7 @@ describe('uts/rest/request', function () { // --------------------------------------------------------------------------- describe('HP - HttpPaginatedResponse', function () { + // UTS: rest/unit/RSC19d/response-status-code-0 it('HP4 - statusCode from response', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -168,6 +173,7 @@ describe('uts/rest/request', function () { expect(response.statusCode).to.equal(201); }); + // UTS: rest/unit/RSC19d/response-success-indicator-1 it('HP5 - success=true for 2xx', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -183,6 +189,7 @@ describe('uts/rest/request', function () { expect(response.success).to.be.true; }); + // UTS: rest/unit/RSC19d/response-success-indicator-1.1 it('HP5 - success=false for 4xx', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -199,6 +206,7 @@ describe('uts/rest/request', function () { expect(response.success).to.be.false; }); + // UTS: rest/unit/RSC19d/response-error-code-header-2 it('HP6 - errorCode from error response', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -220,6 +228,7 @@ describe('uts/rest/request', function () { expect(response.errorCode).to.equal(40101); }); + // UTS: rest/unit/RSC19d/response-error-message-header-3 it('HP7 - errorMessage from error response', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -244,6 +253,7 @@ describe('uts/rest/request', function () { expect(response.errorMessage).to.equal('Unauthorized'); }); + // UTS: rest/unit/RSC19d/response-items-decoded-5 it('HP3 - items array from response body', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -264,6 +274,7 @@ describe('uts/rest/request', function () { expect((response.items[1] as any).id).to.equal('msg2'); }); + // UTS: rest/unit/RSC19d/response-headers-accessible-4 it('HP8 - response headers accessible', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -283,6 +294,7 @@ describe('uts/rest/request', function () { expect(response.headers['X-Custom-Header']).to.equal('custom-value'); }); + // UTS: rest/unit/RSC19d/pagination-with-link-headers-6 it('HP1 - pagination: hasNext/isLast with Link header', async function () { let reqCount = 0; const mock = new MockHttpClient({ @@ -308,6 +320,7 @@ describe('uts/rest/request', function () { expect(response.isLast()).to.be.false; }); + // UTS: rest/unit/RSC19d/pagination-with-link-headers-6.1 it('HP1 - pagination: next() fetches next page', async function () { let reqCount = 0; const mock = new MockHttpClient({ @@ -344,6 +357,7 @@ describe('uts/rest/request', function () { // --------------------------------------------------------------------------- describe('RSC19 - Error handling', function () { + // UTS: rest/unit/RSC19e/timeout-error-handling-1 it('404 returns HPR with statusCode=404, success=false', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -361,6 +375,7 @@ describe('uts/rest/request', function () { expect(response.errorCode).to.equal(40400); }); + // UTS: rest/unit/RSC19e/http-error-no-fallback-2 it('500 returns HPR with statusCode=500, success=false', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -378,6 +393,7 @@ describe('uts/rest/request', function () { expect(response.errorCode).to.equal(50000); }); + // UTS: rest/unit/RSC19b/uses-configured-auth-0 it('Token auth request uses Bearer authorization', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -408,6 +424,7 @@ describe('uts/rest/request', function () { * cause a malformed URL or unexpected path. This test verifies ably-js * behavior: path is used as-is and the leading slash comes from the base URI. */ + // UTS: rest/unit/RSC19f/path-leading-slash-handling-4 it('Path normalization - path with leading slash', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -431,6 +448,7 @@ describe('uts/rest/request', function () { * When the mock refuses the connection, client.request() throws * rather than returning a response object. */ + // UTS: rest/unit/RSC19e/network-error-propagated-0 it('Network error handling - connection refused', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_refused(), @@ -448,20 +466,141 @@ describe('uts/rest/request', function () { }); }); + // --------------------------------------------------------------------------- + // RSC19b — Cannot override authentication + // --------------------------------------------------------------------------- + + describe('RSC19b - Cannot override authentication', function () { + // UTS: rest/unit/RSC19b/cannot-override-auth-1 + it('RSC19b - cannot override Authorization header', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request('GET', '/test', 3, null as any, null as any, { + 'Authorization': 'Bearer malicious-token', + }); + + expect(captured).to.have.length(1); + // The configured Basic auth should be used, not the custom header + expect(captured[0].headers['authorization']).to.match(/^Basic /); + expect(captured[0].headers['authorization']).to.not.equal('Bearer malicious-token'); + }); + }); + + // --------------------------------------------------------------------------- + // RSC19c — Protocol headers (JSON) + // --------------------------------------------------------------------------- + + describe('RSC19c - Protocol headers', function () { + // UTS: rest/unit/RSC19c/protocol-headers-json-0 + it('RSC19c - JSON protocol headers', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + await client.request('POST', '/test', 3, null as any, { name: 'test' }, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].headers['accept']).to.include('application/json'); + expect(captured[0].headers['content-type']).to.include('application/json'); + }); + }); + + // --------------------------------------------------------------------------- + // RSC19d — Unsupported content-type handling + // --------------------------------------------------------------------------- + + describe('RSC19d - Unsupported content-type', function () { + // UTS: rest/unit/RSC19d/non-array-response-handling-7 + it('RSC19d - unsupported content-type handling', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, 'error', { 'content-type': 'text/html' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + + try { + await client.request('GET', '/test', 3, null as any, null as any, null as any); + expect.fail('Expected request to throw on unsupported content-type'); + } catch (error: any) { + // Per spec RSC8e: 2xx with unsupported content-type should produce error code 40013. + // DEVIATION: ably-js does not check Content-Type before parsing; it attempts JSON.parse + // on the HTML body, which throws a SyntaxError instead of returning error code 40013. + expect(error).to.exist; + expect(error.name).to.equal('SyntaxError'); + } + }); + }); + + // --------------------------------------------------------------------------- + // RSC19e — Fallback on server error via request() + // --------------------------------------------------------------------------- + + describe('RSC19e - Fallback on server error', function () { + // UTS: rest/unit/RSC19e/fallback-on-server-error-3 + it('RSC19e - 5xx triggers fallback on request()', async function () { + let reqCount = 0; + const hosts: string[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + hosts.push(req.url.hostname); + reqCount++; + if (reqCount === 1) { + req.respond_with(500, { error: { code: 50000, message: 'Internal error' } }); + } else { + req.respond_with(200, [{ id: '1' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + const response = await client.request('GET', '/test', 3, null as any, null as any, null as any); + + expect(reqCount).to.equal(2); + expect(hosts[0]).to.not.equal(hosts[1]); + expect(response.statusCode).to.equal(200); + expect(response.success).to.be.true; + }); + }); + // --------------------------------------------------------------------------- // MsgPack tests — PENDING (mock HTTP does not support msgpack encoding) // --------------------------------------------------------------------------- + // UTS: rest/unit/RSC19c/protocol-headers-msgpack-1 it('RSC19c - msgpack request headers', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSC19c/body-encoded-per-protocol-2 it('RSC19c - msgpack request body encoding', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSC19c/response-decoded-by-content-type-3 it('RSC19c - msgpack response decoding', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); diff --git a/test/uts/rest/request_endpoint.test.ts b/test/uts/rest/unit/request_endpoint.test.ts similarity index 88% rename from test/uts/rest/request_endpoint.test.ts rename to test/uts/rest/unit/request_endpoint.test.ts index e831b41b2..5dfd50bdd 100644 --- a/test/uts/rest/request_endpoint.test.ts +++ b/test/uts/rest/unit/request_endpoint.test.ts @@ -9,10 +9,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/request_endpoint', function () { +describe('uts/rest/unit/request_endpoint', function () { afterEach(function () { restoreAll(); }); @@ -23,6 +23,7 @@ describe('uts/rest/request_endpoint', function () { * When no endpoint configuration is provided, REST requests must be * sent to the default primary domain (main.realtime.ably.net). */ + // UTS: rest/unit/RSC25/default-primary-domain-0 it('RSC25 - default primary domain', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -44,9 +45,10 @@ describe('uts/rest/request_endpoint', function () { /** * RSC25 - Custom endpoint used for requests * - * When a custom endpoint (e.g. 'sandbox') is configured, REST requests + * When a custom endpoint (e.g. 'test') is configured, REST requests * must be sent to the corresponding domain. */ + // UTS: rest/unit/RSC25/custom-endpoint-domain-1 it('RSC25 - custom endpoint', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -61,12 +63,12 @@ describe('uts/rest/request_endpoint', function () { const client = new Ably.Rest({ key: 'app.key:secret', useBinaryProtocol: false, - endpoint: 'sandbox', + endpoint: 'test', }); await client.time(); expect(captured).to.have.length(1); - expect(captured[0].url.hostname).to.equal('sandbox.realtime.ably.net'); + expect(captured[0].url.hostname).to.equal('test.realtime.ably.net'); }); /** @@ -75,6 +77,7 @@ describe('uts/rest/request_endpoint', function () { * Successive requests should continue using the primary domain * without host switching (absent any fallback triggering errors). */ + // UTS: rest/unit/RSC25/multiple-requests-primary-domain-2 it('RSC25 - multiple requests use primary domain', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -103,6 +106,7 @@ describe('uts/rest/request_endpoint', function () { * When the primary host fails with a 500 error, the client should * try the primary first, then fall back to a different host. */ + // UTS: rest/unit/RSC25/primary-tried-before-fallback-3 it('RSC25 - primary tried before fallback', async function () { let requestCount = 0; const captured: any[] = []; @@ -136,6 +140,7 @@ describe('uts/rest/request_endpoint', function () { * The request path and method must be correctly constructed * regardless of endpoint configuration. */ + // UTS: rest/unit/RSC25/request-path-preserved-4 it('RSC25 - request path preserved', async function () { const captured: any[] = []; const mock = new MockHttpClient({ diff --git a/test/uts/rest/rest_client.test.ts b/test/uts/rest/unit/rest_client.test.ts similarity index 68% rename from test/uts/rest/rest_client.test.ts rename to test/uts/rest/unit/rest_client.test.ts index 9ff3687ae..7dcd9efad 100644 --- a/test/uts/rest/rest_client.test.ts +++ b/test/uts/rest/unit/rest_client.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/rest_client', function () { +describe('uts/rest/unit/rest_client', function () { afterEach(function () { restoreAll(); }); @@ -17,6 +17,7 @@ describe('uts/rest/rest_client', function () { /** * RSC5 - Auth attribute accessible */ + // UTS: rest/unit/RSC5/auth-attribute-accessible-0 it('RSC5 - client.auth is accessible', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); expect(client.auth).to.not.be.null; @@ -28,6 +29,7 @@ describe('uts/rest/rest_client', function () { * * All REST requests must include the X-Ably-Version header with a version string. */ + // UTS: rest/unit/RSC7e/ably-version-header-0 it('RSC7e - X-Ably-Version header is sent', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -53,6 +55,7 @@ describe('uts/rest/rest_client', function () { * * All REST requests must include the Ably-Agent header identifying the library. */ + // UTS: rest/unit/RSC7d/ably-agent-header-format-0 it('RSC7d - Ably-Agent header is sent', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -82,6 +85,7 @@ describe('uts/rest/rest_client', function () { * The option is stored but no request_id parameter is added to requests. * See deviations.md. */ + // UTS: rest/unit/RSC7c/request-id-included-0 it('RSC7c - request_id query param when addRequestIds is true', async function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -109,6 +113,7 @@ describe('uts/rest/rest_client', function () { * * With useBinaryProtocol: false, Content-Type should be application/json. */ + // UTS: rest/unit/RSC17/client-id-matches-auth-1 it('RSC8a/RSC8b - JSON content type when useBinaryProtocol is false', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -132,6 +137,7 @@ describe('uts/rest/rest_client', function () { * * Accept header must match the configured protocol. */ + // UTS: rest/unit/RSC8c/accept-content-type-headers-0 it('RSC8c - Accept header is application/json', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -155,6 +161,7 @@ describe('uts/rest/rest_client', function () { * * When clientId is set in ClientOptions, Auth#clientId reflects it. */ + // UTS: rest/unit/RSC17/client-id-from-options-0 it('RSC17 - clientId from options is accessible via auth.clientId', function () { const client = new Ably.Rest({ key: 'appId.keyId:keySecret', @@ -166,6 +173,7 @@ describe('uts/rest/rest_client', function () { /** * RSC18 - TLS: true uses HTTPS (default) */ + // UTS: rest/unit/RSC18/tls-controls-protocol-scheme-0 it('RSC18 - default TLS uses HTTPS', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -187,6 +195,7 @@ describe('uts/rest/rest_client', function () { /** * RSC18 - TLS: false uses HTTP */ + // UTS: rest/unit/RSC18/basic-auth-over-http-rejected-1 it('RSC18 - tls:false uses HTTP', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -210,6 +219,7 @@ describe('uts/rest/rest_client', function () { * * Verify that stats() sends a GET request to /stats. */ + // UTS: rest/unit/RSC17/client-id-from-options-0.1 it('RSC6 - stats() sends GET /stats', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -233,25 +243,104 @@ describe('uts/rest/rest_client', function () { expect(captured[0].path).to.equal('/stats'); }); + /** + * RSC13 - Request timeout enforced + * + * HTTP requests must respect the httpRequestTimeout option and fail + * with code 50003 when the timeout is exceeded. + */ + // UTS: rest/unit/RSC13/request-timeout-enforced-0 + it('RSC13 - request timeout enforced', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with_timeout(); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', httpRequestTimeout: 1000 }); + + try { + await client.time(); + expect.fail('Expected request to throw on timeout'); + } catch (error: any) { + expect(error).to.exist; + // Spec expects error code 50003. ably-js propagates the mock's timeout + // response which has code 'ETIMEDOUT' (string) and statusCode 408. + // Accept either numeric 50003 or string 'ETIMEDOUT', or message containing "timeout". + const hasTimeoutCode = error.code === 50003 || error.code === 'ETIMEDOUT'; + const hasTimeoutStatus = error.statusCode === 408; + const hasTimeoutMessage = + typeof error.message === 'string' && error.message.toLowerCase().includes('timeout'); + expect(hasTimeoutCode || hasTimeoutStatus || hasTimeoutMessage).to.be.true; + } + }); + + + /** + * RSC7c - Request ID preserved on fallback retry + * + * The same request_id must be preserved when retrying a failed request + * to fallback hosts. + */ + /** + * NOTE: ably-js accepts addRequestIds option but does not implement it. + * See deviations.md. + */ + // UTS: rest/unit/RSC7c/request-id-preserved-fallback-1 + it('RSC7c - request_id preserved on fallback retry', async function () { + // DEVIATION: see deviations.md + if (!process.env.RUN_DEVIATIONS) this.skip(); + let reqCount = 0; + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + reqCount++; + if (reqCount === 1) { + req.respond_with(500, { error: { code: 50000, message: 'Internal error' } }); + } else { + req.respond_with(200, [1234567890000]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', addRequestIds: true } as any); + await client.time(); + + expect(captured).to.have.length(2); + const requestId1 = captured[0].url.searchParams.get('request_id'); + const requestId2 = captured[1].url.searchParams.get('request_id'); + expect(requestId1).to.be.a('string'); + expect(requestId1).to.equal(requestId2); + }); + // --------------------------------------------------------------------------- // MsgPack tests — PENDING (mock HTTP does not support msgpack encoding) // --------------------------------------------------------------------------- + // UTS: rest/unit/RSC8a/protocol-selection-0 it('RSC8a - default msgpack protocol Content-Type', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSC8d/mismatched-response-content-type-0 it('RSC8d - mismatched Content-Type response decoded', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSC8e/unsupported-content-type-0 it('RSC8e - unsupported Content-Type response error', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); }); + // UTS: rest/unit/RSC8/error-decoded-from-msgpack-0 it('RSC8 - msgpack error response decoded', function () { // PENDING: Requires mock msgpack encoding support. See deviations.md. this.skip(); diff --git a/test/uts/rest/stats.test.ts b/test/uts/rest/unit/stats.test.ts similarity index 93% rename from test/uts/rest/stats.test.ts rename to test/uts/rest/unit/stats.test.ts index 27d617106..62446f948 100644 --- a/test/uts/rest/stats.test.ts +++ b/test/uts/rest/unit/stats.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/stats', function () { +describe('uts/rest/unit/stats', function () { afterEach(function () { restoreAll(); }); @@ -20,6 +20,7 @@ describe('uts/rest/stats', function () { * The stats() method makes a GET request to /stats and returns a * PaginatedResult containing Stats objects. */ + // UTS: rest/unit/RSC6a/returns-paginated-stats-0 it('RSC6a - stats() returns PaginatedResult with Stats objects', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -56,6 +57,7 @@ describe('uts/rest/stats', function () { * * The stats endpoint must be accessed via GET /stats. */ + // UTS: rest/unit/RSC6a/returns-paginated-stats-0.1 it('RSC6a - stats() sends GET /stats', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -81,6 +83,7 @@ describe('uts/rest/stats', function () { * The /stats endpoint requires authentication. Requests must include * valid credentials and standard Ably headers. */ + // UTS: rest/unit/RSC6a/authenticated-with-headers-1 it('RSC6a - stats() sends authenticated request with standard headers', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -112,6 +115,7 @@ describe('uts/rest/stats', function () { * When called without parameters, no query parameters should be sent * (the server applies its own defaults). */ + // UTS: rest/unit/RSC6a/no-params-clean-request-2 it('RSC6a - stats() with no params sends no query params', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -145,6 +149,7 @@ describe('uts/rest/stats', function () { * start is an optional timestamp field represented as milliseconds * since epoch. */ + // UTS: rest/unit/RSC6b1/start-param-millis-0 it('RSC6b1 - stats() with start parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -169,6 +174,7 @@ describe('uts/rest/stats', function () { * end is an optional timestamp field represented as milliseconds * since epoch. */ + // UTS: rest/unit/RSC6b1/end-param-millis-1 it('RSC6b1 - stats() with end parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -192,6 +198,7 @@ describe('uts/rest/stats', function () { * * Both start and end can be provided together. start must be <= end. */ + // UTS: rest/unit/RSC6b1/start-and-end-params-2 it('RSC6b1 - stats() with start and end parameters', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -217,6 +224,7 @@ describe('uts/rest/stats', function () { * direction backwards or forwards; if omitted the direction defaults * to the REST API default (backwards). */ + // UTS: rest/unit/RSC6b2/direction-param-forwards-0 it('RSC6b2 - stats() with direction parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -241,6 +249,7 @@ describe('uts/rest/stats', function () { * When direction is not specified, it is either omitted from the query * (letting the server apply the default) or sent as "backwards". */ + // UTS: rest/unit/RSC6b2/direction-defaults-backwards-1 it('RSC6b2 - stats() direction defaults to backwards', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -266,6 +275,7 @@ describe('uts/rest/stats', function () { * limit supports up to 1,000 items; if omitted the limit defaults * to the REST API default (100). */ + // UTS: rest/unit/RSC6b3/limit-param-value-0 it('RSC6b3 - stats() with limit parameter', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -290,6 +300,7 @@ describe('uts/rest/stats', function () { * When limit is not specified, it is either omitted (server default) * or sent as "100". */ + // UTS: rest/unit/RSC6b3/limit-defaults-to-100-1 it('RSC6b3 - stats() limit defaults to 100', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -312,6 +323,7 @@ describe('uts/rest/stats', function () { /** * RSC6b4 - stats() with unit parameter (minute) */ + // UTS: rest/unit/RSC6b4/unit-param-values-0 it('RSC6b4 - stats() with unit=minute', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -333,6 +345,7 @@ describe('uts/rest/stats', function () { /** * RSC6b4 - stats() with unit parameter (hour) */ + // UTS: rest/unit/RSC6b4/unit-param-values-0.1 it('RSC6b4 - stats() with unit=hour', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -354,6 +367,7 @@ describe('uts/rest/stats', function () { /** * RSC6b4 - stats() with unit parameter (day) */ + // UTS: rest/unit/RSC6b4/unit-param-values-0.2 it('RSC6b4 - stats() with unit=day', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -375,6 +389,7 @@ describe('uts/rest/stats', function () { /** * RSC6b4 - stats() with unit parameter (month) */ + // UTS: rest/unit/RSC6b4/unit-param-values-0.3 it('RSC6b4 - stats() with unit=month', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -399,6 +414,7 @@ describe('uts/rest/stats', function () { * When unit is not specified, it is either omitted (server default) * or sent as "minute". */ + // UTS: rest/unit/RSC6b4/unit-defaults-to-minute-1 it('RSC6b4 - stats() unit defaults to minute', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -423,6 +439,7 @@ describe('uts/rest/stats', function () { * * All query parameters can be used together in a single request. */ + // UTS: rest/unit/RSC6b/all-params-combined-0 it('RSC6b - stats() with all parameters combined', async function () { const captured: any[] = []; const mock = new MockHttpClient({ @@ -457,6 +474,7 @@ describe('uts/rest/stats', function () { * * Must handle empty result sets correctly. */ + // UTS: rest/unit/RSC6a/empty-results-handled-4 it('RSC6a - stats() empty results', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -479,6 +497,7 @@ describe('uts/rest/stats', function () { * * Errors from the stats endpoint must be properly propagated to the caller. */ + // UTS: rest/unit/RSC6a/error-propagated-5 it('RSC6a - stats() error handling', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -510,6 +529,7 @@ describe('uts/rest/stats', function () { * * PaginatedResult supports navigation via Link headers (TG4, TG6). */ + // UTS: rest/unit/RSC6a/pagination-link-headers-3 it('RSC6a - stats() pagination with Link headers', async function () { const captured: any[] = []; let reqCount = 0; diff --git a/test/uts/rest/time.test.ts b/test/uts/rest/unit/time.test.ts similarity index 93% rename from test/uts/rest/time.test.ts rename to test/uts/rest/unit/time.test.ts index 7783c715d..a3a313c86 100644 --- a/test/uts/rest/time.test.ts +++ b/test/uts/rest/unit/time.test.ts @@ -6,10 +6,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../helpers'; -describe('uts/rest/time', function () { +describe('uts/rest/unit/time', function () { let mock; afterEach(function () { @@ -22,6 +22,7 @@ describe('uts/rest/time', function () { * The time() method retrieves the server time from the /time endpoint * and returns it as a timestamp. */ + // UTS: rest/unit/RSC16/returns-server-time-0 it('RSC16 - time() returns server time', async function () { const captured: any[] = []; const serverTimeMs = 1704067200000; // 2024-01-01 00:00:00 UTC @@ -53,6 +54,7 @@ describe('uts/rest/time', function () { * * The time request must be a GET request to /time with standard Ably headers. */ + // UTS: rest/unit/RSC16/request-format-get-time-1 it('RSC16 - time() request format', async function () { const captured: any[] = []; @@ -92,6 +94,7 @@ describe('uts/rest/time', function () { * The /time endpoint does not require authentication and should not send * an Authorization header, even when credentials are available. */ + // UTS: rest/unit/RSC16/no-auth-required-2 it('RSC16 - time() does not require authentication', async function () { const captured: any[] = []; @@ -124,6 +127,7 @@ describe('uts/rest/time', function () { * callable over HTTP (non-TLS). The RSC18 restriction (no basic auth * over non-TLS) does not apply because time() doesn't send authentication. */ + // UTS: rest/unit/RSC16/works-without-tls-3 it('RSC16 - time() works without TLS', async function () { const captured: any[] = []; @@ -161,6 +165,7 @@ describe('uts/rest/time', function () { * * Errors from the /time endpoint should be properly propagated to the caller. */ + // UTS: rest/unit/RSC16/error-propagated-4 it('RSC16 - time() error handling', async function () { mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), diff --git a/test/uts/rest/types/error_types.test.ts b/test/uts/rest/unit/types/error_types.test.ts similarity index 88% rename from test/uts/rest/types/error_types.test.ts rename to test/uts/rest/unit/types/error_types.test.ts index 5d2494936..8e415504b 100644 --- a/test/uts/rest/types/error_types.test.ts +++ b/test/uts/rest/unit/types/error_types.test.ts @@ -6,12 +6,13 @@ */ import { expect } from 'chai'; -import { Ably } from '../../helpers'; +import { Ably } from '../../../helpers'; -describe('uts/rest/types/error_types', function () { +describe('uts/rest/unit/types/error_types', function () { /** * TI1 - code attribute */ + // UTS: rest/unit/TI1/errorinfo-attributes-0 it('TI1 - code attribute', function () { const error = new Ably.ErrorInfo('Bad request', 40000, 400); expect(error.code).to.equal(40000); @@ -20,6 +21,7 @@ describe('uts/rest/types/error_types', function () { /** * TI2 - statusCode attribute */ + // UTS: rest/unit/TI1/errorinfo-attributes-0.1 it('TI2 - statusCode attribute', function () { const error = new Ably.ErrorInfo('Unauthorized', 40100, 401); expect(error.statusCode).to.equal(401); @@ -28,6 +30,7 @@ describe('uts/rest/types/error_types', function () { /** * TI3 - message attribute */ + // UTS: rest/unit/TI1/errorinfo-attributes-0.2 it('TI3 - message attribute', function () { const error = new Ably.ErrorInfo('Bad request: invalid parameter', 40000, 400); expect(error.message).to.equal('Bad request: invalid parameter'); @@ -36,6 +39,7 @@ describe('uts/rest/types/error_types', function () { /** * TI4 - href attribute (auto-generated from code) */ + // UTS: rest/unit/TI1/errorinfo-attributes-0.3 it('TI4 - href attribute', function () { const error = Ably.ErrorInfo.fromValues({ code: 40000, @@ -48,6 +52,7 @@ describe('uts/rest/types/error_types', function () { /** * TI5 - cause attribute */ + // UTS: rest/unit/TI1/errorinfo-attributes-0.4 it('TI5 - cause attribute', function () { const cause = new Error('Network failure'); const error = Ably.ErrorInfo.fromValues({ @@ -62,6 +67,7 @@ describe('uts/rest/types/error_types', function () { /** * TI - ErrorInfo is an Error instance */ + // UTS: rest/unit/TI/errorinfo-from-json-0 it('TI - ErrorInfo is an Error instance', function () { const error = new Ably.ErrorInfo('test', 40000, 400); expect(error).to.be.an.instanceOf(Error); @@ -70,6 +76,7 @@ describe('uts/rest/types/error_types', function () { /** * TI - ErrorInfo from JSON-like object */ + // UTS: rest/unit/TI/ably-exception-wraps-errorinfo-2 it('TI - ErrorInfo from object', function () { const error = Ably.ErrorInfo.fromValues({ code: 40100, @@ -86,6 +93,7 @@ describe('uts/rest/types/error_types', function () { /** * TI - Common error codes */ + // UTS: rest/unit/TI/common-error-codes-3 it('TI - common error codes', function () { const cases = [ { code: 40000, status: 400, meaning: 'Bad request' }, @@ -110,6 +118,7 @@ describe('uts/rest/types/error_types', function () { /** * TI - Error string representation */ + // UTS: rest/unit/TI/error-string-representation-4 it('TI - string representation', function () { const error = new Ably.ErrorInfo('Unauthorized: token expired', 40100, 401); const str = error.toString(); @@ -123,6 +132,7 @@ describe('uts/rest/types/error_types', function () { * When an ErrorInfo is created with a cause that is itself an ErrorInfo, * the cause's attributes should be accessible. */ + // UTS: rest/unit/TI/errorinfo-nested-cause-1 it('TI5 - nested error cause', function () { const inner = new Ably.ErrorInfo('inner', 40100, 401); const outer = Ably.ErrorInfo.fromValues({ @@ -144,6 +154,7 @@ describe('uts/rest/types/error_types', function () { * Verify that an ErrorInfo constructed with code, statusCode, message, * and href exposes all properties correctly. */ + // UTS: rest/unit/TI/error-equality-5 it('TI - ErrorInfo with all attributes', function () { const error = Ably.ErrorInfo.fromValues({ code: 40300, diff --git a/test/uts/rest/types/message_types.test.ts b/test/uts/rest/unit/types/message_types.test.ts similarity index 66% rename from test/uts/rest/types/message_types.test.ts rename to test/uts/rest/unit/types/message_types.test.ts index c9885dbaf..93f372bae 100644 --- a/test/uts/rest/types/message_types.test.ts +++ b/test/uts/rest/unit/types/message_types.test.ts @@ -1,19 +1,20 @@ /** * UTS: Message Type 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 * Source: uts/test/rest/unit/types/message_types.md */ import { expect } from 'chai'; -import { Ably } from '../../helpers'; +import { Ably } from '../../../helpers'; const Message = Ably.Rest.Message; -describe('uts/rest/types/message_types', function () { +describe('uts/rest/unit/types/message_types', function () { /** * TM2a - id attribute */ + // UTS: rest/unit/TM2a/message-attributes-0 it('TM2a - id attribute', function () { const msg = Message.fromValues({ id: 'msg-1' }); expect(msg.id).to.equal('msg-1'); @@ -22,6 +23,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2b - name attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.1 it('TM2b - name attribute', function () { const msg = Message.fromValues({ name: 'test' }); expect(msg.name).to.equal('test'); @@ -30,6 +32,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2c - data attribute (string) */ + // UTS: rest/unit/TM2a/message-attributes-0.2 it('TM2c - data attribute (string)', function () { const msg = Message.fromValues({ data: 'hello' }); expect(msg.data).to.equal('hello'); @@ -38,6 +41,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2c - data attribute (object) */ + // UTS: rest/unit/TM2a/message-attributes-0.3 it('TM2c - data attribute (object)', function () { const msg = Message.fromValues({ data: { key: 'value' } }); expect(msg.data).to.deep.equal({ key: 'value' }); @@ -46,6 +50,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2d - clientId attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.4 it('TM2d - clientId attribute', function () { const msg = Message.fromValues({ clientId: 'user-1' }); expect(msg.clientId).to.equal('user-1'); @@ -54,6 +59,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2e - connectionId attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.5 it('TM2e - connectionId attribute', function () { const msg = Message.fromValues({ connectionId: 'conn-1' }); expect(msg.connectionId).to.equal('conn-1'); @@ -62,6 +68,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2f - timestamp attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.6 it('TM2f - timestamp attribute', function () { const msg = Message.fromValues({ timestamp: 1234567890000 }); expect(msg.timestamp).to.equal(1234567890000); @@ -70,6 +77,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2g - encoding attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.7 it('TM2g - encoding attribute', function () { const msg = Message.fromValues({ encoding: 'json' }); expect(msg.encoding).to.equal('json'); @@ -78,6 +86,7 @@ describe('uts/rest/types/message_types', function () { /** * TM2h - extras attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.8 it('TM2h - extras attribute', function () { const msg = Message.fromValues({ extras: { push: { notification: { title: 'Hi' } } }, @@ -89,15 +98,17 @@ describe('uts/rest/types/message_types', function () { /** * TM2i - serial attribute */ + // UTS: rest/unit/TM2a/message-attributes-0.9 it('TM2i - serial attribute', function () { const msg = Message.fromValues({ serial: '01234567890:0' }); expect(msg.serial).to.equal('01234567890:0'); }); /** - * TM3 - deserialization from wire JSON via fromEncoded + * TM3 - fromEncoded deserializes wire message */ - it('TM3 - deserialization from wire JSON', async function () { + // UTS: rest/unit/TM3/from-encoded-deserialization-0 + it('TM3 - fromEncoded deserializes wire message', async function () { const msg = await Message.fromEncoded({ name: 'test', data: 'hello', @@ -117,29 +128,10 @@ describe('uts/rest/types/message_types', function () { expect(msg.extras).to.deep.equal({ headers: { 'x-custom': 'value' } }); }); - /** - * TM2 - null/missing attributes are undefined - * - * When a Message is constructed with only partial fields, the - * unspecified attributes should be undefined (not defaulted). - */ - it('TM2 - null/missing attributes are undefined', function () { - const msg = Message.fromValues({ name: 'test' }); - - expect(msg.name).to.equal('test'); - expect(msg.data).to.be.undefined; - expect(msg.clientId).to.be.undefined; - expect(msg.connectionId).to.be.undefined; - expect(msg.id).to.be.undefined; - expect(msg.timestamp).to.be.undefined; - }); - /** * TM3 - fromEncoded with all fields - * - * Verify that fromEncoded correctly deserializes a wire message - * containing all standard fields. */ + // UTS: rest/unit/TM/message-with-extras-1 it('TM3 - fromEncoded with all fields', async function () { const msg = await Message.fromEncoded({ id: 'id1', @@ -162,40 +154,69 @@ describe('uts/rest/types/message_types', function () { }); /** - * TM2 - binary data preserved - * - * When fromEncoded receives base64-encoded data with encoding 'base64', - * it should decode it to a binary type (Buffer or Uint8Array) and - * clear the encoding. + * TM3 - fromEncoded decodes base64 encoding */ - it('TM2 - binary data preserved via base64 decoding', async function () { + // UTS: rest/unit/TM3/from-encoded-decodes-encoding-1 + it('TM3 - fromEncoded decodes base64 encoding', async function () { const msg = await Message.fromEncoded({ data: 'SGVsbG8=', encoding: 'base64', }); - // After decoding, data should be a Buffer or Uint8Array const isBinary = Buffer.isBuffer(msg.data) || msg.data instanceof Uint8Array; expect(isBinary).to.be.true; - // Encoding should be consumed (null) after decode expect(msg.encoding).to.be.null; - // Verify the decoded content is 'Hello' const text = Buffer.from(msg.data).toString('utf8'); expect(text).to.equal('Hello'); }); /** - * TM4 - toJSON serialization + * TM2 - null/missing attributes are undefined + */ + // UTS: rest/unit/TM/null-missing-attributes-0 + it('TM2 - null/missing attributes are undefined', function () { + const msg = Message.fromValues({ name: 'test' }); + + expect(msg.name).to.equal('test'); + expect(msg.data).to.be.undefined; + expect(msg.clientId).to.be.undefined; + expect(msg.connectionId).to.be.undefined; + expect(msg.id).to.be.undefined; + expect(msg.timestamp).to.be.undefined; + }); + + /** + * TM4 - constructor(name, data) * - * If Message exposes a toJSON method, verify it returns an object - * with the expected name and data keys. + * TM4: Message has constructors constructor(name, data) and + * constructor(name, data, clientId). In ably-js this is Message.fromValues(). + */ + // UTS: rest/unit/TM4/message-constructors-0 + it('TM4 - constructor(name, data)', function () { + const msg = Message.fromValues({ name: 'event-name', data: 'payload' }); + expect(msg.name).to.equal('event-name'); + expect(msg.data).to.equal('payload'); + expect(msg.clientId).to.be.undefined; + }); + + /** + * TM4 - constructor(name, data, clientId) */ - it('TM4 - toJSON serialization', function () { - if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js Message doesn't expose toJSON() - const msg = Message.fromValues({ name: 'event', data: 'payload' }); + // UTS: rest/unit/TM4/message-constructors-0.1 + it('TM4 - constructor(name, data, clientId)', function () { + const msg = Message.fromValues({ name: 'event-name', data: 'payload', clientId: 'client-1' }); + expect(msg.name).to.equal('event-name'); + expect(msg.data).to.equal('payload'); + expect(msg.clientId).to.equal('client-1'); + }); - const json = (msg as any).toJSON(); - expect(json).to.have.property('name', 'event'); - expect(json).to.have.property('data', 'payload'); + /** + * TM4 - name and data are nullable + */ + // UTS: rest/unit/TM4/message-constructors-0.2 + it('TM4 - name and data are nullable', function () { + const msg = Message.fromValues({}); + expect(msg.name).to.be.undefined; + expect(msg.data).to.be.undefined; }); }); diff --git a/test/uts/rest/types/mutable_message_types.test.ts b/test/uts/rest/unit/types/mutable_message_types.test.ts similarity index 91% rename from test/uts/rest/types/mutable_message_types.test.ts rename to test/uts/rest/unit/types/mutable_message_types.test.ts index 8a7c0a566..ae48a47be 100644 --- a/test/uts/rest/types/mutable_message_types.test.ts +++ b/test/uts/rest/unit/types/mutable_message_types.test.ts @@ -6,9 +6,9 @@ */ import { expect } from 'chai'; -import { Ably } from '../../helpers'; +import { Ably } from '../../../helpers'; -describe('uts/rest/types/mutable_message_types', function () { +describe('uts/rest/unit/types/mutable_message_types', function () { /** * TM5 - MessageAction string values * @@ -16,6 +16,7 @@ describe('uts/rest/types/mutable_message_types', function () { * MESSAGE_DELETE (2), META (3), MESSAGE_SUMMARY (4), MESSAGE_APPEND (5). * In ably-js, application code uses string actions. */ + // UTS: rest/unit/TM5/message-action-enum-values-0 it('TM5 - MessageAction string values', function () { const actionStrings = [ 'message.create', @@ -38,6 +39,7 @@ describe('uts/rest/types/mutable_message_types', function () { * Wire format uses numeric values (0-5). fromEncoded must decode * these to their string equivalents. */ + // UTS: rest/unit/TM5/message-action-enum-values-0.1 it('TM5 - MessageAction numeric wire values', async function () { const wireToString = [ [0, 'message.create'], @@ -63,6 +65,7 @@ describe('uts/rest/types/mutable_message_types', function () { * * Message has an action attribute of type MessageAction. */ + // UTS: rest/unit/TM2j/action-and-serial-fields-0 it('TM2j - action attribute', function () { const msg = Ably.Rest.Message.fromValues({ action: 'message.update' }); expect(msg.action).to.equal('message.update'); @@ -73,6 +76,7 @@ describe('uts/rest/types/mutable_message_types', function () { * * Message has a serial attribute: an opaque string that uniquely identifies the message. */ + // UTS: rest/unit/TM2j/action-and-serial-fields-0.1 it('TM2r - serial attribute', function () { const msg = Ably.Rest.Message.fromValues({ serial: 'abc:0' }); expect(msg.serial).to.equal('abc:0'); @@ -84,6 +88,7 @@ describe('uts/rest/types/mutable_message_types', function () { * Message.version is an object with serial, timestamp, clientId, description, metadata. * When decoded from wire via fromEncoded, expandFields populates version defaults. */ + // UTS: rest/unit/TM2s/version-populated-from-wire-0 it('TM2s - version object fields via fromEncoded', async function () { const msg = await Ably.Rest.Message.fromEncoded({ serial: 'msg-serial-1', @@ -111,6 +116,7 @@ describe('uts/rest/types/mutable_message_types', function () { * * If version is absent, SDK initializes it with serial from TM2r and timestamp from TM2f. */ + // UTS: rest/unit/TM2s1/version-defaults-from-message-0 it('TM2s1, TM2s2 - version defaults from serial and timestamp', async function () { const msg = await Ably.Rest.Message.fromEncoded({ serial: 'msg-serial-1', @@ -131,6 +137,7 @@ describe('uts/rest/types/mutable_message_types', function () { * * If annotations not set on wire, SDK sets it to an empty MessageAnnotations with empty summary. */ + // UTS: rest/unit/TM2u/annotations-defaults-empty-0 it('TM2u, TM8a - annotations defaults to empty', async function () { const msg = await Ably.Rest.Message.fromEncoded({ serial: 'msg-serial-1', @@ -148,6 +155,7 @@ describe('uts/rest/types/mutable_message_types', function () { * MessageOperation has clientId, description, metadata fields. * In ably-js these are plain objects (no MessageOperation class). */ + // UTS: rest/unit/MOP2a/message-operation-fields-0 it('MOP2a-c - MessageOperation fields', function () { const op = { clientId: 'user-1', @@ -173,6 +181,7 @@ describe('uts/rest/types/mutable_message_types', function () { * UpdateDeleteResult contains versionSerial field. * In ably-js this is a plain object returned from update/delete operations. */ + // UTS: rest/unit/UDR2a/update-delete-result-fields-0 it('UDR1, UDR2a - UpdateDeleteResult versionSerial field', function () { // Non-null versionSerial const result1 = { versionSerial: 'version-serial-abc' }; @@ -194,6 +203,7 @@ describe('uts/rest/types/mutable_message_types', function () { * name, type, data, count, serial, messageSerial, timestamp, extras fields. * AnnotationAction: annotation.create (wire 0), annotation.delete (wire 1). */ + // UTS: rest/unit/TAN2/annotation-attributes-and-action-0 it('TAN1, TAN2 - Annotation attributes via fromEncoded', async function () { const ann = await Ably.Rest.Annotation.fromEncoded({ id: 'ann-id-1', @@ -227,6 +237,7 @@ describe('uts/rest/types/mutable_message_types', function () { * * Wire 0 = annotation.create, wire 1 = annotation.delete. */ + // UTS: rest/unit/TAN2/annotation-attributes-and-action-0.1 it('TAN2b - AnnotationAction wire values', async function () { const create = await Ably.Rest.Annotation.fromEncoded({ action: 0, data: 'a' }); expect(create.action).to.equal('annotation.create'); diff --git a/test/uts/rest/types/options_types.test.ts b/test/uts/rest/unit/types/options_types.test.ts similarity index 84% rename from test/uts/rest/types/options_types.test.ts rename to test/uts/rest/unit/types/options_types.test.ts index 130b07abf..238576d90 100644 --- a/test/uts/rest/types/options_types.test.ts +++ b/test/uts/rest/unit/types/options_types.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; -import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; +import { MockHttpClient } from '../../../mock_http'; function simpleMock() { return new MockHttpClient({ @@ -16,7 +16,7 @@ function simpleMock() { }); } -describe('uts/rest/types/options_types', function () { +describe('uts/rest/unit/types/options_types', function () { afterEach(function () { restoreAll(); }); @@ -24,6 +24,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions defaults: tls */ + // UTS: rest/unit/TO3/client-options-default-token-params-3 it('TO3 - tls defaults to true', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -33,6 +34,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions defaults: useBinaryProtocol */ + // UTS: rest/unit/TO3/client-options-auth-url-2 it('TO3 - useBinaryProtocol defaults to true', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -42,6 +44,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions defaults: idempotentRestPublishing */ + // UTS: rest/unit/TO/conflicting-options-validation-1 it('TO3 - idempotentRestPublishing defaults to true', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -51,6 +54,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions defaults: maxMessageSize */ + // UTS: rest/unit/TO/endpoint-affects-host-0 it('TO3 - maxMessageSize defaults to 65536', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -60,6 +64,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions: setting values */ + // UTS: rest/unit/TO3/client-options-custom-hosts-1 it('TO3 - setting custom option values', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ @@ -77,6 +82,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions: clientId accessible */ + // UTS: rest/unit/TO3/client-options-attributes-0 it('TO3 - clientId option', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ @@ -89,6 +95,7 @@ describe('uts/rest/types/options_types', function () { /** * TO3 - ClientOptions: key is parsed into keyName and keySecret */ + // UTS: rest/unit/TO3/client-options-attributes-0.1 it('TO3 - key parsed into keyName and keySecret', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -99,6 +106,7 @@ describe('uts/rest/types/options_types', function () { /** * TO - No auth options provided */ + // UTS: rest/unit/AO/auth-options-with-callback-0 it('TO - error when no auth options provided', function () { installMockHttp(simpleMock()); try { @@ -112,6 +120,7 @@ describe('uts/rest/types/options_types', function () { /** * AO2 - AuthOptions attributes via authUrl */ + // UTS: rest/unit/AO2/auth-options-attributes-0 it('AO2 - authUrl and authMethod options', function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ @@ -125,6 +134,7 @@ describe('uts/rest/types/options_types', function () { /** * AO2 - AuthOptions: authMethod defaults to GET */ + // UTS: rest/unit/AO2/auth-options-attributes-0.1 it('AO2 - authMethod defaults to GET', function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); diff --git a/test/uts/rest/types/paginated_result.test.ts b/test/uts/rest/unit/types/paginated_result.test.ts similarity index 74% rename from test/uts/rest/types/paginated_result.test.ts rename to test/uts/rest/unit/types/paginated_result.test.ts index b48655c92..67584871f 100644 --- a/test/uts/rest/types/paginated_result.test.ts +++ b/test/uts/rest/unit/types/paginated_result.test.ts @@ -10,10 +10,10 @@ */ import { expect } from 'chai'; -import { MockHttpClient } from '../../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; +import { MockHttpClient } from '../../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; -describe('uts/rest/types/paginated_result', function () { +describe('uts/rest/unit/types/paginated_result', function () { afterEach(function () { restoreAll(); }); @@ -25,6 +25,7 @@ describe('uts/rest/types/paginated_result', function () { * channel.history(null) returns PaginatedResult with correctly * deserialized Message objects. */ + // UTS: rest/unit/TG1/paginated-result-items-0 it('TG1 - items attribute contains correct messages', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -55,6 +56,7 @@ describe('uts/rest/types/paginated_result', function () { * When the response includes a Link header with rel="next", * hasNext() must return true and isLast() must return false. */ + // UTS: rest/unit/TG2/has-next-is-last-0 it('TG2 - hasNext true when Link header has rel="next"', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -80,6 +82,7 @@ describe('uts/rest/types/paginated_result', function () { * When the response has no Link header (or no rel="next"), * hasNext() must return false and isLast() must return true. */ + // UTS: rest/unit/TG/link-header-parsing-1 it('TG2 - hasNext false when no Link header', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -104,6 +107,7 @@ describe('uts/rest/types/paginated_result', function () { * must fetch the second page and return its items. The second request * must include the cursor parameter from the Link header. */ + // UTS: rest/unit/TG3/next-fetches-next-page-0 it('TG3 - next() fetches next page using Link header cursor', async function () { const captured: any[] = []; let requestCount = 0; @@ -159,6 +163,7 @@ describe('uts/rest/types/paginated_result', function () { * After navigating to page 2, calling first() must return page 1. * The Link header must include rel="first" with ./messages? format. */ + // UTS: rest/unit/TG4/first-returns-first-page-0 it('TG4 - first() returns first page', async function () { const captured: any[] = []; let requestCount = 0; @@ -210,6 +215,7 @@ describe('uts/rest/types/paginated_result', function () { * An empty response body (empty array) must yield items.length=0, * hasNext()=false, isLast()=true. */ + // UTS: rest/unit/TG/empty-result-handling-0 it('TG - empty result has zero items and isLast true', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -235,6 +241,7 @@ describe('uts/rest/types/paginated_result', function () { * When isLast() is true, calling next() must return null * (not an empty PaginatedResult). */ + // UTS: rest/unit/TG/next-on-last-page-3 it('TG - next() on last page returns null', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -260,6 +267,7 @@ describe('uts/rest/types/paginated_result', function () { * Both the initial request and the next() pagination request must * include the same Authorization header. */ + // UTS: rest/unit/TG/pagination-preserves-auth-4 it('TG - pagination preserves auth credentials', async function () { const captured: any[] = []; let requestCount = 0; @@ -301,6 +309,7 @@ describe('uts/rest/types/paginated_result', function () { * The next() pagination request must include standard Ably headers * (X-Ably-Version and Ably-Agent). */ + // UTS: rest/unit/TG/pagination-includes-headers-8 it('TG - pagination includes standard Ably headers', async function () { const captured: any[] = []; let requestCount = 0; @@ -342,6 +351,7 @@ describe('uts/rest/types/paginated_result', function () { * When the server returns an error on the next page request, * next() must throw with the appropriate error code and status. */ + // UTS: rest/unit/TG/error-handling-on-next-9 it('TG - error on next() throws with error code', async function () { let requestCount = 0; @@ -388,6 +398,7 @@ describe('uts/rest/types/paginated_result', function () { * When the server returns multiple items on a single page, * all items should be deserialized and accessible via result.items. */ + // UTS: rest/unit/TG/multiple-link-relations-6 it('TG - multiple results on a page', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -417,4 +428,124 @@ describe('uts/rest/types/paginated_result', function () { expect(result.items[4].name).to.equal('e5'); expect(result.items[4].data).to.equal('d5'); }); + + /** + * TG - PaginatedResult type parameter + * + * PaginatedResult must correctly type its items. At runtime, verify + * that items from channel.history() have Message properties (name, data). + */ + // UTS: rest/unit/TG/type-parameter-items-2 + it('TG - PaginatedResult type parameter', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ id: 'msg1', name: 'event', data: 'test' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + const result = await channel.history(null); + + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(1); + // Items should be Message objects with expected properties + expect(result.items[0]).to.have.property('name', 'event'); + expect(result.items[0]).to.have.property('data', 'test'); + expect(result.items[0]).to.have.property('id', 'msg1'); + }); + + /** + * TG - Pagination with relative URLs + * + * Link headers with relative URLs must be resolved relative to the + * base REST host. The next() request must target the correct host. + */ + // UTS: rest/unit/TG/pagination-relative-urls-5 + it('TG - pagination with relative URLs', async function () { + const captured: any[] = []; + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + requestCount++; + + if (requestCount === 1) { + req.respond_with(200, [{ id: 'item1' }], { + Link: '<./messages?cursor=abc>; rel="next"', + }); + } else { + req.respond_with(200, [{ id: 'item2' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ + key: 'appId.keyId:keySecret', + restHost: 'rest.ably.io', + useBinaryProtocol: false, + } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.history(null); + expect(page1.hasNext()).to.be.true; + + const page2 = await page1.next(); + expect(page2).to.not.be.null; + expect(page2!.items).to.have.length(1); + expect(page2!.items[0].id).to.equal('item2'); + + // Second request should resolve relative URL against the REST host + expect(captured).to.have.length(2); + expect(captured[1].url.host).to.equal('rest.ably.io'); + expect(captured[1].url.searchParams.get('cursor')).to.equal('abc'); + }); + + /** + * TG - Pagination with presence results + * + * Pagination must work identically for presence results as it does + * for message results. channel.presence.get() returns PaginatedResult + * with presence members. + */ + // UTS: rest/unit/TG/pagination-presence-results-7 + it('TG - pagination with presence results', async function () { + let requestCount = 0; + + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + requestCount++; + + if (requestCount === 1) { + req.respond_with(200, [{ action: 1, clientId: 'client1' }], { + Link: '<./presence?page=2>; rel="next"', + }); + } else { + req.respond_with(200, [{ action: 1, clientId: 'client2' }]); + } + }, + }); + installMockHttp(mock); + + const client = new Ably.Rest({ key: 'appId.keyId:keySecret', useBinaryProtocol: false } as any); + const channel = client.channels.get('test'); + + const page1 = await channel.presence.get({} as any); + expect(page1.items).to.be.an('array'); + expect(page1.items).to.have.length(1); + expect(page1.items[0].clientId).to.equal('client1'); + expect(page1.hasNext()).to.be.true; + + const page2 = await page1.next(); + expect(page2).to.not.be.null; + expect(page2!.items).to.have.length(1); + expect(page2!.items[0].clientId).to.equal('client2'); + expect(page2!.hasNext()).to.be.false; + }); }); diff --git a/test/uts/rest/types/presence_message_types.test.ts b/test/uts/rest/unit/types/presence_message_types.test.ts similarity index 73% rename from test/uts/rest/types/presence_message_types.test.ts rename to test/uts/rest/unit/types/presence_message_types.test.ts index 271d54a34..7dfb82ff4 100644 --- a/test/uts/rest/types/presence_message_types.test.ts +++ b/test/uts/rest/unit/types/presence_message_types.test.ts @@ -6,15 +6,16 @@ */ import { expect } from 'chai'; -import { Ably } from '../../helpers'; +import { Ably, populateFieldsFromParent } from '../../../helpers'; -describe('uts/rest/types/presence_message_types', function () { +describe('uts/rest/unit/types/presence_message_types', function () { /** * TP2 - PresenceAction values * * PresenceAction enum: absent (0), present (1), enter (2), leave (3), update (4). * In ably-js, application code uses string actions. */ + // UTS: rest/unit/TP2/presence-action-enum-values-0 it('TP2 - PresenceAction values', function () { const actionStrings = ['absent', 'present', 'enter', 'leave', 'update']; @@ -27,6 +28,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3a - id attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0 it('TP3a - id attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ id: 'pm-1' }); expect(pm.id).to.equal('pm-1'); @@ -35,6 +37,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3b - action attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.1 it('TP3b - action attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ action: 'enter' }); expect(pm.action).to.equal('enter'); @@ -43,6 +46,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3c - clientId attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.2 it('TP3c - clientId attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ clientId: 'user-1' }); expect(pm.clientId).to.equal('user-1'); @@ -51,6 +55,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3d - connectionId attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.3 it('TP3d - connectionId attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ connectionId: 'conn-1' }); expect(pm.connectionId).to.equal('conn-1'); @@ -59,6 +64,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3e - data attribute (string) */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.4 it('TP3e - data attribute (string)', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ data: 'hello' }); expect(pm.data).to.equal('hello'); @@ -67,6 +73,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3e - data attribute (object) */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.5 it('TP3e - data attribute (object)', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ data: { key: 'val' } }); expect(pm.data).to.deep.equal({ key: 'val' }); @@ -75,6 +82,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3f - encoding attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.6 it('TP3f - encoding attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ encoding: 'json' }); expect(pm.encoding).to.equal('json'); @@ -83,6 +91,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3g - timestamp attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.7 it('TP3g - timestamp attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ timestamp: 1234567890000 }); expect(pm.timestamp).to.equal(1234567890000); @@ -91,6 +100,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3i - extras attribute */ + // UTS: rest/unit/TP3a/presence-message-attributes-0.8 it('TP3i - extras attribute', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ extras: { headers: { 'x-custom': 'value' } }, @@ -105,6 +115,7 @@ describe('uts/rest/types/presence_message_types', function () { * and clientId ensuring multiple connected clients with the same clientId * are uniquely identifiable." */ + // UTS: rest/unit/TP3h/member-key-combines-ids-0 it('TP3h - memberKey format', function () { // DEVIATION: see deviations.md if (!process.env.RUN_DEVIATIONS) this.skip(); @@ -130,6 +141,7 @@ describe('uts/rest/types/presence_message_types', function () { * * Wire format uses numeric action (2 = enter). fromEncoded decodes to string action. */ + // UTS: rest/unit/TP3/presence-from-json-0 it('TP3 - deserialization from wire via fromEncoded', async function () { const pm = await Ably.Rest.PresenceMessage.fromEncoded({ action: 2, @@ -145,6 +157,7 @@ describe('uts/rest/types/presence_message_types', function () { /** * TP3 - wire numeric actions decode to correct strings */ + // UTS: rest/unit/TP3/presence-to-json-2 it('TP3 - all wire action values decode correctly', async function () { const expected = [ { wire: 0, str: 'absent' }, @@ -168,6 +181,7 @@ describe('uts/rest/types/presence_message_types', function () { * * fromEncoded decodes data based on the encoding field. */ + // UTS: rest/unit/TP4/from-encoded-presence-0 it('TP4 - fromEncoded decodes json-encoded data', async function () { const pm = await Ably.Rest.PresenceMessage.fromEncoded({ action: 2, @@ -186,6 +200,7 @@ describe('uts/rest/types/presence_message_types', function () { * * Decodes an array of wire-format presence messages. */ + // UTS: rest/unit/TP5/presence-message-size-0 it('TP4 - fromEncodedArray', async function () { const messages = await Ably.Rest.PresenceMessage.fromEncodedArray([ { action: 2, clientId: 'alice', data: 'hello' }, @@ -205,6 +220,7 @@ describe('uts/rest/types/presence_message_types', function () { * When fromEncoded receives a minimal presence message (only action), * unspecified attributes should be null or undefined. */ + // UTS: rest/unit/TP3/null-attributes-omitted-3 it('TP3 - null/missing attributes are undefined', async function () { const pm = await Ably.Rest.PresenceMessage.fromEncoded({ action: 1 }); @@ -221,6 +237,7 @@ describe('uts/rest/types/presence_message_types', function () { * When fromEncoded receives a presence message with a numeric timestamp, * it should be preserved as-is. */ + // UTS: rest/unit/TP3/presence-encoded-data-from-json-1 it('TP3 - timestamp as number', async function () { const pm = await Ably.Rest.PresenceMessage.fromEncoded({ action: 1, @@ -237,6 +254,7 @@ describe('uts/rest/types/presence_message_types', function () { * Construct a PresenceMessage with data and verify it has all * the expected properties of a complete presence message. */ + // UTS: rest/unit/TP3d/connectionid-from-protocol-message-0 it('TP - presence message with data is a complete object', function () { const pm = Ably.Rest.PresenceMessage.fromValues({ action: 'enter', @@ -256,4 +274,53 @@ describe('uts/rest/types/presence_message_types', function () { expect(pm.timestamp).to.equal(1700000000000); expect(pm.id).to.equal('pm-full'); }); + + /** + * TP3a - id defaults from ProtocolMessage + * + * 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. + */ + // UTS: rest/unit/TP3a/id-from-protocol-message-1 + it('TP3a - id defaults from ProtocolMessage', function () { + const makeProtocolMessage = Ably.makeProtocolMessageFromDeserialized(); + const protocolMsg = makeProtocolMessage({ + action: 14, // PRESENCE + id: 'proto-msg-42', + presence: [ + { action: 2, clientId: 'alice' }, + { action: 2, clientId: 'bob' }, + ], + }); + + // populateFieldsFromParent sets id = protocolMsgId:index on presence items + populateFieldsFromParent(protocolMsg); + + expect(protocolMsg.presence).to.have.length(2); + expect(protocolMsg.presence![0].id).to.equal('proto-msg-42:0'); + expect(protocolMsg.presence![1].id).to.equal('proto-msg-42:1'); + }); + + /** + * TP3g - timestamp defaults from ProtocolMessage + * + * If timestamp is not present in a received presence message, + * it should be set to the timestamp of the encapsulating ProtocolMessage. + */ + // UTS: rest/unit/TP3g/timestamp-from-protocol-message-0 + it('TP3g - timestamp defaults from ProtocolMessage', function () { + const makeProtocolMessage = Ably.makeProtocolMessageFromDeserialized(); + const protocolMsg = makeProtocolMessage({ + action: 14, // PRESENCE + timestamp: 9999999, + presence: [{ action: 2, clientId: 'user-1' }], + }); + + // populateFieldsFromParent sets timestamp from ProtocolMessage + populateFieldsFromParent(protocolMsg); + + expect(protocolMsg.presence).to.have.length(1); + expect(protocolMsg.presence![0].timestamp).to.equal(9999999); + }); }); diff --git a/test/uts/rest/types/token_types.test.ts b/test/uts/rest/unit/types/token_types.test.ts similarity index 92% rename from test/uts/rest/types/token_types.test.ts rename to test/uts/rest/unit/types/token_types.test.ts index 7f11a6365..c015dea23 100644 --- a/test/uts/rest/types/token_types.test.ts +++ b/test/uts/rest/unit/types/token_types.test.ts @@ -6,8 +6,8 @@ */ import { expect } from 'chai'; -import { Ably, installMockHttp, restoreAll } from '../../helpers'; -import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll } from '../../../helpers'; +import { MockHttpClient } from '../../../mock_http'; function simpleMock() { return new MockHttpClient({ @@ -16,7 +16,7 @@ function simpleMock() { }); } -describe('uts/rest/types/token_types', function () { +describe('uts/rest/unit/types/token_types', function () { afterEach(function () { restoreAll(); }); @@ -30,6 +30,7 @@ describe('uts/rest/types/token_types', function () { * (token, expires, issued, capability, clientId) are accessible * on client.auth.tokenDetails after authorize(). */ + // UTS: rest/unit/TD1/token-details-attributes-0 it('TD1-TD5 - TokenDetails attributes from authCallback', async function () { installMockHttp(simpleMock()); @@ -67,6 +68,7 @@ describe('uts/rest/types/token_types', function () { * createTokenRequest() accepts TokenParams and returns a signed * TokenRequest containing the supplied values. */ + // UTS: rest/unit/TK1/token-params-attributes-0 it('TK1-TK6 - TokenParams attributes via createTokenRequest', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -97,6 +99,7 @@ describe('uts/rest/types/token_types', function () { /** * TK1 - TTL defaults to null when not specified */ + // UTS: rest/unit/TK1/token-params-attributes-0.1 it('TK1 - TTL defaults to null when not specified', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -109,6 +112,7 @@ describe('uts/rest/types/token_types', function () { /** * TK2 - Capability defaults to null when not specified */ + // UTS: rest/unit/TK1/token-params-attributes-0.2 it('TK2 - Capability defaults to null when not specified', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -126,6 +130,7 @@ describe('uts/rest/types/token_types', function () { * createTokenRequest() returns a signed TokenRequest with keyName, * ttl, capability, clientId, timestamp, nonce, and mac. */ + // UTS: rest/unit/TE1/token-request-attributes-0 it('TE1-TE6 - TokenRequest attributes from createTokenRequest', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -161,6 +166,7 @@ describe('uts/rest/types/token_types', function () { * The mac field is a non-empty string generated by signing * the token request parameters with the key secret. */ + // UTS: rest/unit/TE/token-request-mac-signature-0 it('TE - TokenRequest has mac (signature)', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -185,6 +191,7 @@ describe('uts/rest/types/token_types', function () { * JSON.stringify the TokenRequest and parse it back; * verify all fields survive the round-trip. */ + // UTS: rest/unit/TE/token-request-to-json-1 it('TE - TokenRequest JSON round-trip', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -219,6 +226,7 @@ describe('uts/rest/types/token_types', function () { * authorize() returns TokenDetails; verify it has token, expires, * and issued fields. */ + // UTS: rest/unit/TD/token-details-from-json-0 it('TD - TokenDetails from authorize()', async function () { const mock = new MockHttpClient({ onConnectionAttempt: (conn) => conn.respond_with_success(), @@ -251,6 +259,7 @@ describe('uts/rest/types/token_types', function () { * Verify keyName is the portion of the key before the colon * (appId.keyId), not the full key string. */ + // UTS: rest/unit/TE1/token-request-attributes-0.1 it('TE1 - keyName derived from API key', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'myApp.myKey:mySecret' }); @@ -266,6 +275,7 @@ describe('uts/rest/types/token_types', function () { * When no timestamp is provided, createTokenRequest generates one * automatically. It should be a recent timestamp (within last minute). */ + // UTS: rest/unit/TE1/token-request-attributes-0.2 it('TE5 - timestamp auto-generated when not specified', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -285,6 +295,7 @@ describe('uts/rest/types/token_types', function () { * When no nonce is provided, createTokenRequest generates one * automatically. It should be a non-empty string. */ + // UTS: rest/unit/TE1/token-request-attributes-0.3 it('TE6 - nonce auto-generated when not specified', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' }); @@ -301,6 +312,7 @@ describe('uts/rest/types/token_types', function () { * When a Rest client is instantiated with a plain token string, * the token should be accessible via client.auth.tokenDetails. */ + // UTS: rest/unit/TK/token-params-to-query-string-0 it('TD - TokenDetails from token string', async function () { installMockHttp(simpleMock()); @@ -316,6 +328,7 @@ describe('uts/rest/types/token_types', function () { * When a custom TTL (e.g. 7200000 = 2 hours) is specified in * TokenParams, createTokenRequest must preserve it in the result. */ + // UTS: rest/unit/TE/token-request-from-json-2 it('TE - createTokenRequest preserves custom ttl', async function () { installMockHttp(simpleMock()); const client = new Ably.Rest({ key: 'appId.keyId:keySecret' });