diff --git a/test/uts/helpers.ts b/test/uts/helpers.ts index f5ea68b76..a98b98504 100644 --- a/test/uts/helpers.ts +++ b/test/uts/helpers.ts @@ -34,6 +34,22 @@ let _savedNow: any = null; // Tracked clients for cleanup — ensures timers are released even if a test crashes const _trackedClients: any[] = []; +// Track all Platform.Config.setTimeout timers so restoreAll() can cancel orphans. +// ably-js has a bug where connectWs() overwrites timer handles without cancelling +// the previous ones, leaking timers that prevent process exit. +const _allPlatformTimers = new Set(); +const _origPlatformSetTimeout = Platform.Config.setTimeout; +const _origPlatformClearTimeout = Platform.Config.clearTimeout; +Platform.Config.setTimeout = function (fn: any, ms?: number, ...args: any[]) { + const timer = _origPlatformSetTimeout.call(this, fn, ms, ...args); + _allPlatformTimers.add(timer); + return timer; +} as any; +Platform.Config.clearTimeout = function (timer: any) { + _allPlatformTimers.delete(timer); + return _origPlatformClearTimeout.call(this, timer); +} as any; + /** * Install a MockHttpClient as the platform HTTP implementation. * Call uninstallMockHttp() in afterEach to restore the original. @@ -151,7 +167,7 @@ class FakeClock { // Yield to the event loop (not just the microtask queue) so that all // chained process.nextTick callbacks (e.g. mock WebSocket error/close // events) are fully drained before the next fake timer fires. - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setImmediate(resolve)); } this._now = targetTime; } @@ -222,6 +238,12 @@ function restoreAll(): void { // Ignore errors during cleanup } } + // Cancel all Platform.Config timers that weren't cleared by client.close(). + // Covers orphaned timers from ably-js's connectWs() overwrite bug. + for (const timer of _allPlatformTimers) { + _origPlatformClearTimeout(timer); + } + _allPlatformTimers.clear(); uninstallMockHttp(); uninstallMockWebSocket(); // Restore fake timers if installed @@ -235,6 +257,17 @@ function restoreAll(): void { } } +/** + * Flush the async event loop — yields to both microtasks and the macrotask + * queue so that pending promise callbacks, nextTick handlers, and queued + * mock WebSocket/HTTP deliveries all settle before the test continues. + * + * Replaces all `await new Promise(r => setTimeout(r, N))` delays in tests. + */ +function flushAsync(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + export { Ably, Platform, @@ -246,4 +279,5 @@ export { FakeClock, trackClient, restoreAll, + flushAsync, }; diff --git a/test/uts/mock_websocket.ts b/test/uts/mock_websocket.ts new file mode 100644 index 000000000..82627127a --- /dev/null +++ b/test/uts/mock_websocket.ts @@ -0,0 +1,399 @@ +/** + * Mock WebSocket infrastructure for UTS Realtime tests. + * + * Provides a MockWebSocket controller that intercepts WebSocket creation + * via Platform.Config.WebSocket. Follows the same handler+await patterns + * as mock_http.ts. + * + * See: uts/test/realtime/unit/helpers/mock_websocket.md + */ + +/** Default CONNECTED protocol message */ +const DEFAULT_CONNECTED = { + action: 4, // CONNECTED + connectionId: 'test-connection-id', + connectionDetails: { + connectionKey: 'test-connection-key', + clientId: null as string | null, + connectionStateTtl: 120000, + maxIdleInterval: 15000, + maxMessageSize: 65536, + serverId: 'test-server', + }, +}; + +/** WebSocket connectivity check URL pattern */ +const WS_CONNECTIVITY_CHECK = 'ws-up.ably-realtime.com'; + +/** + * A single mock WebSocket instance — the object returned by `new Constructor(url)`. + * Implements the W3C + Node.js `ws` WebSocket interface that ably-js expects. + */ +class MockWSInstance { + url: string; + private _owner: MockWebSocket; + private _closed: boolean; + private _pingListeners: Array<() => void>; + + // W3C WebSocket interface (set by ably-js after construction) + binaryType: string; + onopen: (() => void) | null; + onclose: ((ev: { code: number; wasClean: boolean }) => void) | null; + onmessage: ((ev: { data: string }) => void) | null; + onerror: ((ev: { message: string }) => void) | null; + + constructor(url: string, owner: MockWebSocket) { + this.url = url; + this._owner = owner; + this._closed = false; + this._pingListeners = []; + + this.binaryType = ''; + this.onopen = null; + this.onclose = null; + this.onmessage = null; + this.onerror = null; + } + + /** Node.js `ws` library `on(event, handler)` — ably-js registers 'ping' listener */ + on(event: string, handler: () => void): void { + if (event === 'ping') { + this._pingListeners.push(handler); + } + } + + /** Client sends a message (ably-js calls this with a JSON string) */ + send(data: string): void { + if (this._closed) return; + const decoded = JSON.parse(data); + this._owner._onClientMessage(decoded, data, this); + } + + /** Client closes the connection */ + close(code?: number, reason?: string): void { + if (this._closed) return; + this._closed = true; + this._owner._onClientClose(this, code, reason); + // Deliver onclose asynchronously (matches real WebSocket behavior) + process.nextTick(() => { + if (this.onclose) { + this.onclose({ code: code || 1000, wasClean: true }); + } + }); + } + + // --- Test helpers (called by PendingWSConnection) --- + + _fireOpen(): void { + process.nextTick(() => { + if (this.onopen) this.onopen(); + }); + } + + _fireClose(code?: number, wasClean?: boolean): void { + if (this._closed) return; + this._closed = true; + process.nextTick(() => { + if (this.onclose) { + this.onclose({ code: code || 1000, wasClean: wasClean !== false }); + } + }); + } + + _fireError(message?: string): void { + process.nextTick(() => { + if (this.onerror) this.onerror({ message: message || 'Connection error' }); + }); + } + + _fireMessage(protocolMessage: any): void { + process.nextTick(() => { + if (this.onmessage) { + this.onmessage({ data: JSON.stringify(protocolMessage) }); + } + }); + } + + _firePing(): void { + process.nextTick(() => { + for (const handler of this._pingListeners) handler(); + }); + } +} + +/** + * Represents a pending WebSocket connection attempt. + * Test code calls respond_with_* methods to control the outcome. + */ +class PendingWSConnection { + ws: MockWSInstance; + url: URL; + private _opened: boolean; + + constructor(ws: MockWSInstance, parsedUrl: URL) { + this.ws = ws; + this.url = parsedUrl; + this._opened = false; + } + + /** + * Connection succeeds and delivers a CONNECTED protocol message. + */ + respond_with_connected( + connectedMsg?: Partial & { + connectionDetails?: Partial; + }, + ): void { + const msg = connectedMsg + ? Object.assign({}, DEFAULT_CONNECTED, connectedMsg, { + connectionDetails: Object.assign( + {}, + DEFAULT_CONNECTED.connectionDetails, + connectedMsg.connectionDetails || {}, + ), + }) + : DEFAULT_CONNECTED; + + this._opened = true; + this.ws._fireOpen(); + // Deliver CONNECTED after onopen fires + process.nextTick(() => { + this.ws._fireMessage(msg); + }); + } + + /** Connection succeeds (fires onopen) but no protocol message delivered */ + respond_with_success(): void { + this._opened = true; + this.ws._fireOpen(); + } + + /** Connection refused at network level */ + respond_with_refused(): void { + this.ws._fireError('Connection refused'); + this.ws._fireClose(1006, false); + } + + /** Connection times out — never responds */ + respond_with_timeout(): void { + // Intentionally do nothing. The connection hangs. + } + + /** DNS resolution fails */ + respond_with_dns_error(): void { + this.ws._fireError('getaddrinfo ENOTFOUND'); + this.ws._fireClose(1006, false); + } + + /** + * WebSocket connects but server sends an ERROR protocol message then closes. + */ + respond_with_error(errorMsg: any): void { + this._opened = true; + this.ws._fireOpen(); + process.nextTick(() => { + this.ws._fireMessage(errorMsg); + process.nextTick(() => { + this.ws._fireClose(1000, true); + }); + }); + } + + /** Send a protocol message to the client on this connection */ + send_to_client(msg: any): void { + this.ws._fireMessage(msg); + } + + /** Send a protocol message then close the connection */ + send_to_client_and_close(msg: any): void { + this.ws._fireMessage(msg); + process.nextTick(() => { + this.ws._fireClose(1000, true); + }); + } + + /** Close the connection without sending a message (transport failure) */ + simulate_disconnect(error?: { message?: string }): void { + if (error) { + this.ws._fireError(error.message || 'Transport error'); + } + this.ws._fireClose(1006, false); + } + + /** Close the connection cleanly (server-initiated) */ + close(): void { + this.ws._fireClose(1000, true); + } + + /** Simulate a WebSocket ping frame (for RTN23b) */ + send_ping_frame(): void { + this.ws._firePing(); + } +} + +interface MockWebSocketOptions { + onConnectionAttempt?: (conn: PendingWSConnection) => void; + onMessageFromClient?: (msg: any, conn: PendingWSConnection | undefined) => void; + onTextDataFrame?: (raw: string) => void; +} + +type ConnectionWaiter = (conn: PendingWSConnection) => void; +type MessageWaiter = (msg: any) => void; +type CloseWaiter = (ev: { code?: number; reason?: string }) => void; + +/** + * MockWebSocket — the main mock class. + * + * Usage (handler pattern): + * const mock = new MockWebSocket({ + * onConnectionAttempt: (conn) => conn.respond_with_connected(), + * onMessageFromClient: (msg, conn) => { ... }, + * }); + * installMockWebSocket(mock.constructorFn); + * + * Usage (await pattern): + * const mock = new MockWebSocket(); + * installMockWebSocket(mock.constructorFn); + * const conn = await mock.await_connection_attempt(); + * conn.respond_with_connected(); + */ +class MockWebSocket { + onConnectionAttempt: MockWebSocketOptions['onConnectionAttempt'] | null; + onMessageFromClient: MockWebSocketOptions['onMessageFromClient'] | null; + onTextDataFrame: MockWebSocketOptions['onTextDataFrame'] | null; + + connect_attempts: PendingWSConnection[]; + active_connection: PendingWSConnection | null; + private _connectionWaiters: ConnectionWaiter[]; + private _messageWaiters: MessageWaiter[]; + private _closeWaiters: CloseWaiter[]; + + /** The constructor function to pass to installMockWebSocket() */ + constructorFn: (url: string) => MockWSInstance; + + constructor(options?: MockWebSocketOptions) { + options = options || {}; + this.onConnectionAttempt = options.onConnectionAttempt || null; + this.onMessageFromClient = options.onMessageFromClient || null; + this.onTextDataFrame = options.onTextDataFrame || null; + + this.connect_attempts = []; + this.active_connection = null; + this._connectionWaiters = []; + this._messageWaiters = []; + this._closeWaiters = []; + + // Build the constructor function that will replace Platform.Config.WebSocket + const mock = this; + this.constructorFn = function MockWSConstructor(url: string) { + return mock._onNewWebSocket(url); + }; + } + + /** @internal Called when ably-js does `new Platform.Config.WebSocket(url)` */ + _onNewWebSocket(url: string): MockWSInstance { + // Handle connectivity checker — auto-respond without involving test handlers + if (url.includes(WS_CONNECTIVITY_CHECK)) { + const ws = new MockWSInstance(url, this); + process.nextTick(() => { + if (ws.onopen) ws.onopen(); + process.nextTick(() => { + if (ws.onclose) ws.onclose({ code: 1000, wasClean: true }); + }); + }); + return ws; + } + + const ws = new MockWSInstance(url, this); + const parsedUrl = new URL(url); + const conn = new PendingWSConnection(ws, parsedUrl); + this.connect_attempts.push(conn); + + // Notify handler or waiter + if (this.onConnectionAttempt) { + this.onConnectionAttempt(conn); + } else if (this._connectionWaiters.length > 0) { + this._connectionWaiters.shift()!(conn); + } + // If neither handler nor waiter, the connection hangs until test responds + + return ws; + } + + /** @internal Called when the client sends a message via ws.send() */ + _onClientMessage(decoded: any, raw: string, ws: MockWSInstance): void { + // Find the connection for this ws instance + const conn = this.connect_attempts.find((c) => c.ws === ws); + + if (this.onTextDataFrame) { + this.onTextDataFrame(raw); + } + + if (this.onMessageFromClient) { + this.onMessageFromClient(decoded, conn); + } else if (this._messageWaiters.length > 0) { + this._messageWaiters.shift()!(decoded); + } + } + + /** @internal Called when the client closes the WebSocket */ + _onClientClose(_ws: MockWSInstance, code?: number, reason?: string): void { + if (this._closeWaiters.length > 0) { + this._closeWaiters.shift()!({ code, reason }); + } + } + + /** Wait for the next WebSocket connection attempt */ + await_connection_attempt(timeout?: number): Promise { + return new Promise((resolve, reject) => { + const timer = timeout + ? setTimeout(() => reject(new Error('Timeout waiting for WS connection attempt')), timeout) + : null; + this._connectionWaiters.push((conn) => { + if (timer) clearTimeout(timer); + resolve(conn); + }); + }); + } + + /** Wait for the next protocol message from the client */ + await_next_message_from_client(timeout?: number): Promise { + return new Promise((resolve, reject) => { + const timer = timeout ? setTimeout(() => reject(new Error('Timeout waiting for client message')), timeout) : null; + this._messageWaiters.push((msg) => { + if (timer) clearTimeout(timer); + resolve(msg); + }); + }); + } + + /** Wait for the client to close the WebSocket */ + await_client_close(timeout?: number): Promise<{ code?: number; reason?: string }> { + return new Promise((resolve, reject) => { + const timer = timeout ? setTimeout(() => reject(new Error('Timeout waiting for client close')), timeout) : null; + this._closeWaiters.push((ev) => { + if (timer) clearTimeout(timer); + resolve(ev); + }); + }); + } + + /** Send a protocol message on the active connection */ + send_to_client(msg: any): void { + if (!this.active_connection) { + throw new Error('No active connection'); + } + this.active_connection.send_to_client(msg); + } + + /** Clear all state */ + reset(): void { + this.connect_attempts = []; + this.active_connection = null; + this._connectionWaiters = []; + this._messageWaiters = []; + this._closeWaiters = []; + } +} + +export { MockWebSocket, PendingWSConnection, MockWSInstance, DEFAULT_CONNECTED }; diff --git a/test/uts/realtime-audit.md b/test/uts/realtime-audit.md new file mode 100644 index 000000000..261eb2937 --- /dev/null +++ b/test/uts/realtime-audit.md @@ -0,0 +1,189 @@ +# Realtime UTS Test Audit + +Comprehensive review of all 37 realtime UTS test files in `ably-js/test/uts/realtime/`, checking: + +1. Does the UTS spec correctly interpret the features spec? +2. Does the ably-js test correctly implement the UTS spec? + +Each finding was verified against the features spec (`specification/md/features.md`), the UTS spec (`specification/uts/realtime/unit/`), and the ably-js test code. + +--- + +## Critical: Tests That Are Wrong + +These tests actively assert incorrect behavior or are mislabeled in a way that hides real issues. + +### 1. RTL4g errorReason clearing mislabeled as "UTS spec error" + +**File**: `channels/channel_attributes.test.ts` ~line 147 + +**Problem**: Two tests are labeled "UTS spec error" and assert that `errorReason` persists after re-attach from FAILED. The comment claims: "the features spec only says when errorReason is SET... it never says it should be cleared." + +**This is wrong.** RTL4g (features spec) explicitly states: "If the channel is in the `FAILED` state, the `attach` request sets its `errorReason` to `null`, and proceeds with a channel attach." The UTS spec correctly asserts `errorReason IS null` after re-attach from FAILED. + +**What it actually is**: An ably-js deviation, not a UTS spec error. ably-js does not clear `errorReason` during attach from FAILED state. + +**Fix**: Relabel as a deviation. Assert `errorReason === null` (spec behavior) with `RUN_DEVIATIONS` guard. + +### 2. RTN15c7 missing error field and errorReason assertions + +**File**: `connection/connection_failures.test.ts` ~line 159 + +**Problem**: The test for failed connection resume sends a CONNECTED message with a new `connectionId` but **omits the `error` field entirely**. The test only checks `connection.id`, `connection.key`, and `connection.state`. + +**Features spec (RTN15c7)**: "CONNECTED ProtocolMessage with a new connectionId and an **ErrorInfo in the error field**. The error should be set as the reason in the CONNECTED event, and as the Connection#errorReason." + +**UTS spec**: Correctly includes `error: ErrorInfo(code: 80008, statusCode: 400, message: "Unable to recover connection")` and asserts `connection.errorReason IS NOT null` and `errorReason.code == 80008`. + +**Fix**: Add `error` field to the mock CONNECTED message. Add assertions on `connection.errorReason` and the CONNECTED event's `reason`. + +### 3. RTN14g tests wrong scenario + +**File**: `connection/connection_open_failures.test.ts` ~line 352 + +**Problem**: The test first connects successfully (receives CONNECTED), then sends an ERROR protocol message. This is the RTN15j scenario (ERROR during an established connection), not RTN14g (ERROR during initial connection opening). + +**Features spec (RTN14g)**: Lives under "Connection opening failures" — ERROR received before the connection is established. + +**UTS spec**: Correctly has the ERROR sent during connection opening (`onConnectionAttempt` sends ERROR before any CONNECTED message). + +**Fix**: Restructure the test to send the ERROR during the opening phase, before any CONNECTED message is received. + +--- + +## High Priority: Missing Tests + +### 4. RTN15h2 token renewal failure sub-case missing + +**File**: `connection/connection_failures.test.ts` + +**Present**: The happy-path RTN15h2 test exists (line ~364) — token error triggers renewal and successful reconnect. + +**Missing**: The UTS spec also defines a failure sub-case (token renewal itself fails → connection transitions to DISCONNECTED). This test is not present in the ably-js file. + +**Features spec (RSA4a2)**: If token renewal fails and there are no means to renew, the connection should transition to FAILED with error code 40171. + +### 5. RTL17 test missing + +**File**: `channels/channel_subscribe.test.ts` + +RTL17 is listed in the file header comment but has no corresponding `it(...)` block. + +**Features spec (RTL17)**: "No messages should be passed to subscribers if the channel is in any state other than ATTACHED." + +**UTS spec**: Defines a complete test — subscribe with `attachOnSubscribe: false`, send a MESSAGE while ATTACHING, assert no messages delivered. + +### 6. RTN25/RTN14b token error — wrong expected state and error code (non-renewable case) + +**File**: `connection/error_reason.test.ts` ~line 133 + +**Scenario**: ERROR ProtocolMessage with token error (40142) during initial connection. Client has `token: "expired_token"` — no key, no authCallback → no means to renew. + +**UTS spec** (`error_reason_test.md` ~line 178): Labels this "RTN14b, RTN15h". Expects DISCONNECTED with `errorReason.code == 40142`. + +**Features spec**: RTN14b says for token ERROR during connection opening: "If no means to renew the token is provided, RSA4a applies." RSA4a2 says: "transition the connection to the FAILED state" with error code 40171. + +**Both the expected state (should be FAILED, not DISCONNECTED) and error code (should be 40171, not 40142) are wrong in the UTS spec.** This is a UTS spec error — it describes ably-js's actual behavior (which has an explicit workaround at `connectionmanager.ts` line 804: `TODO remove below line once realtime sends token errors as DISCONNECTEDs`) rather than the features spec requirement. + +**Note**: The RSA4a (non-renewable) and RSA4b (renewable) cases ARE tested separately, but in different files: +- **RTN14b (ERROR during connection, non-renewable)**: `error_reason.test.ts` — this test (wrong expectations as described above) +- **RTN15h1 (DISCONNECTED while connected, non-renewable)**: `connection_failures.test.ts` ~line 317 — correctly expects FAILED state +- **RTN15h2 (DISCONNECTED while connected, renewable)**: `connection_failures.test.ts` ~line 364 — correctly expects reconnect + +So the RTN15h tests in `connection_failures.test.ts` correctly distinguish the two cases. The error is only in the RTN14b/RTN25 test in `error_reason.test.ts`, where the non-renewable initial-connection case expects DISCONNECTED instead of FAILED. + +--- + +## UTS Spec Errors + +These are errors in the UTS specs in the specification repo that need fixing regardless of the ably-js tests. + +### 7. RTL4j — ATTACH_RESUME after detach+reattach + +**UTS spec**: `channel_attach.md` ~line 793 — tests attach → detach → reattach and asserts the second attach SHOULD have `ATTACH_RESUME` flag. + +**Features spec (RTL4j1)**: `attachResume` is set to `false` when the channel moves to DETACHING. A detach+reattach is therefore a clean attach and should NOT have `ATTACH_RESUME`. + +**Fix**: UTS spec should assert the second attach does NOT have `ATTACH_RESUME`. + +### 8. "Detach clears errorReason" — no spec basis + +**UTS spec**: `channel_detach.md` ~line 700 — test "RTL5 - Detach clears errorReason" asserts `channel.errorReason IS null` after detach. + +**Features spec (RTL5)**: Defines detach behavior across RTL5a through RTL5l. None mention clearing `errorReason`. The only spec points that clear channel `errorReason` are RTL4g (attach from FAILED) and RTN11d (reconnect clears all channel errorReasons). + +**Fix**: Remove or relabel this UTS test. If the intent was to test errorReason clearing on re-attach, it belongs under RTL4g. + +### 9. `suspendedRetryTimeout` used instead of `channelRetryTimeout` + +**UTS specs**: Multiple channel-related UTS files use `suspendedRetryTimeout` for channel retry after SUSPENDED state: `channel_server_initiated_detach.md`, `channel_connection_state.md`, `channel_error.md`, `channel_attach.md`. + +**Features spec**: `suspendedRetryTimeout` (TO3l2) is for CONNECTION suspended state retry (default 30s). `channelRetryTimeout` (TO3l7) is for CHANNEL suspended state retry (default 15s). RTB1 explicitly distinguishes them. + +**Fix**: Replace `suspendedRetryTimeout` with `channelRetryTimeout` in all channel-related UTS specs. These have different defaults (30s vs 15s) so using the wrong one can mask timing bugs. + +--- + +## Stale Documentation + +### 10. `channels_collection.test.ts` header comment + +**File**: `channels/channels_collection.test.ts` line 13 + +**Comment**: "ably-js release() is synchronous and throws on attached channels." + +**Reality**: Commit `861bdc76` changed `release()` to implement RTS4a — it now detaches first, then removes. The test body at line ~176 correctly tests this behavior. Only the header comment is stale. + +### 11. `deviations.md` RTS4a entry is stale + +**File**: `deviations.md` — "channels_collection: RTS4a - release throws on attached channels" + +**Reality**: ably-js now complies with RTS4a (detach-then-release). The `RTS4a - release detaches and removes attached channel` test passes. The deviations entry should be removed. + +--- + +## Summary Table + +| # | Severity | Spec Point | File | Issue | +|---|----------|-----------|------|-------| +| 1 | Critical | RTL4g | channel_attributes | Deviation mislabeled as UTS spec error | +| 2 | Critical | RTN15c7 | connection_failures | Missing error field and errorReason assertions | +| 3 | Critical | RTN14g | connection_open_failures | Tests wrong scenario (RTN15j instead of RTN14g) | +| 4 | High | RTN15h2 | connection_failures | Token renewal failure sub-case missing | +| 5 | High | RTL17 | channel_subscribe | Test declared in header but not implemented | +| 6 | High | RTN14b/RTN25 | error_reason | Non-renewable token error: wrong expected state (DISCONNECTED→FAILED) and code (40142→40171). RTN15h1/h2 in connection_failures are correct. | +| 7 | UTS fix | RTL4j | channel_attach.md | Wrong ATTACH_RESUME expectation after detach+reattach | +| 8 | UTS fix | RTL5 | channel_detach.md | "Detach clears errorReason" has no spec basis | +| 9 | UTS fix | Various | 4 channel specs | `suspendedRetryTimeout` should be `channelRetryTimeout` | +| 10 | Docs | — | channels_collection | Stale header comment about release() throwing | +| 11 | Docs | RTS4a | deviations.md | Stale entry — ably-js now complies | + +--- + +## Resolution Status + +All findings have been addressed. UTS specs fixed, ably-js tests updated. Results: + +| # | Finding | Resolution | ably-js | +|---|---------|-----------|---------| +| 1 | RTL4g mislabeled | Test fixed to assert spec behavior (errorReason cleared) | **FAILS** — ably-js does not clear errorReason on re-attach from FAILED | +| 2 | RTN15c7 missing assertions | Added error field to mock + errorReason/event assertions | **PASSES** | +| 3 | RTN14g wrong scenario | Restructured to send ERROR during connection opening | **PASSES** | +| 4 | RTN15h2 failure sub-case | Not added (out of scope for this fix round) | — | +| 5 | RTL17 missing | Test added | **PASSES** — ably-js correctly drops messages when not ATTACHED | +| 6 | RTN25/RTN14b token error | UTS spec + test fixed: expect FAILED/40171 | **PASSES** — ably-js correctly transitions to FAILED with 40171 | +| 7 | RTL4j ATTACH_RESUME | UTS spec fixed: test via setOptions reattach, not detach+reattach | ably-js test already correct (was not using UTS detach+reattach pattern) | +| 8 | RTL5 detach errorReason | UTS test removed (no spec basis) | — | +| 9 | suspendedRetryTimeout | Fixed in 3 UTS specs (channel_error, channel_server_initiated_detach, channel_attach). channel_connection_state left unchanged (correct: connection-level option). | ably-js tests already used correct `channelRetryTimeout` | +| 10 | Stale header comment | Fixed in channels_collection.test.ts | — | +| 11 | Stale RTS4a deviation | Removed from deviations.md | — | + +**Final test counts: 748 passing, 39 pending, 2 failing.** + +The 2 failures are the new RTL4g tests (errorReason clearing) — a genuine ably-js deviation from the spec. + +--- + +## Coverage Gaps (Not Audited in Detail) + +Many UTS spec tests are not yet translated to ably-js across all realtime test files. This is expected — the initial translation covered priority spec points. A full coverage comparison (UTS spec tests vs ably-js tests) was not performed as part of this audit. The findings above focus on tests that exist but are wrong or misleading. diff --git a/test/uts/realtime/auth/connection_auth.test.ts b/test/uts/realtime/auth/connection_auth.test.ts new file mode 100644 index 000000000..bfff47d55 --- /dev/null +++ b/test/uts/realtime/auth/connection_auth.test.ts @@ -0,0 +1,464 @@ +/** + * UTS: Realtime Connection Authentication Tests + * + * Spec points: RTN2e, RTN27b, RSA4c, RSA4c1, RSA4c2, RSA4c3, RSA4d, RSA8d, RSA12a + * Source: uts/test/realtime/unit/auth/connection_auth_test.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; + +describe('uts/realtime/auth/connection_auth', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN2e/RTN27b - Token obtained before WebSocket connection + * + * When authCallback is configured but no token is provided, the library must + * obtain a token via the callback before opening the WebSocket connection. + */ + it('RTN2e/RTN27b - token obtained before WebSocket connection', function (done) { + let callbackInvoked = false; + let callbackInvokedTime: number | null = null; + let connectionAttemptTime: number | null = null; + let capturedWsUrl: string | null = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptTime = Date.now(); + capturedWsUrl = conn.url.toString(); + 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) => { + callbackInvoked = true; + callbackInvokedTime = Date.now(); + cb(null, 'callback-provided-token'); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(callbackInvoked).to.be.true; + expect(callbackInvokedTime).to.not.be.null; + expect(connectionAttemptTime).to.not.be.null; + expect(callbackInvokedTime!).to.be.at.most(connectionAttemptTime!); + + expect(capturedWsUrl).to.not.be.null; + expect(capturedWsUrl).to.include('access_token=callback-provided-token'); + expect(capturedWsUrl).to.not.include('key='); + + expect(client.connection.state).to.equal('connected'); + done(); + }); + + client.connect(); + }); + + /** + * RTN2e/RTN27b - authCallback error prevents connection attempt + * + * If authCallback fails during initial token acquisition, the library + * should NOT attempt to open a WebSocket connection. + */ + it('RTN2e/RTN27b - authCallback error prevents connection attempt', 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', + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + cb(new Error('Auth callback failed'), null); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('disconnected', () => { + expect(connectionAttempted).to.be.false; + expect(client.connection.errorReason).to.not.be.null; + done(); + }); + + client.connection.once('failed', () => { + expect(connectionAttempted).to.be.false; + expect(client.connection.errorReason).to.not.be.null; + done(); + }); + + client.connect(); + }); + + /** + * RTN2e - authCallback TokenParams include clientId + * + * When invoking authCallback, the library passes TokenParams that include + * any configured clientId (per RSA12a). + */ + it('RTN2e - authCallback TokenParams include clientId', function (done) { + let receivedParams: any = null; + + 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) => { + receivedParams = params; + cb(null, 'token-for-client'); + }, + clientId: 'my-client-id', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(receivedParams).to.not.be.null; + expect(receivedParams.clientId).to.equal('my-client-id'); + done(); + }); + + client.connect(); + }); + + /** + * RTN2e - Multiple connections reuse valid token + * + * If a valid (non-expired) token exists from a previous authCallback invocation, + * it should be reused for subsequent connection attempts. + */ + it('RTN2e - multiple connections reuse valid token', function (done) { + let callbackCount = 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) => { + callbackCount++; + cb(null, { + token: 'reusable-token', + issued: Date.now(), + expires: Date.now() + 3600000, + }); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + client.connection.once('closed', () => { + client.connect(); + client.connection.once('connected', () => { + expect(callbackCount).to.equal(1); + done(); + }); + }); + }); + + client.connect(); + }); + + + /** + * RSA4c2 - authCallback error during CONNECTING causes DISCONNECTED + * + * Per RSA4c: if authCallback errors during connection, and RSA4d does not + * apply (not a 403), then: + * RSA4c1: errorReason set with code 80019, statusCode 401, cause = underlying error + * RSA4c2: connection transitions to DISCONNECTED + */ + it('RSA4c2 - authCallback error during CONNECTING causes 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, `token-${authCallbackCount}`); + } + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('disconnected', (stateChange: any) => { + // 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 set to the underlying error + expect(client.connection.errorReason!.cause).to.not.be.null; + expect((client.connection.errorReason!.cause as any).code).to.equal(50000); + + // RSA4c2: state change reason also has 80019 + expect(stateChange.reason).to.not.be.null; + expect(stateChange.reason.code).to.equal(80019); + + done(); + }); + + client.connect(); + }); + + /** + * RSA4c1/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. + */ + 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(); + + 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)); + + 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 auth callback failure to propagate + for (let i = 0; i < 10; i++) { + await flushAsync(); + if (client.connection.errorReason != null || stateChanges.length > 0) 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); + + // 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); + }); + + /** + * RSA4d - authCallback 403 error during CONNECTING causes FAILED + * + * Per RSA4d: if authCallback returns statusCode 403, the connection + * transitions to FAILED with code 80019 and statusCode 403. + */ + it('RSA4d - authCallback 403 during CONNECTING causes FAILED', 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) => { + cb({ code: 40300, statusCode: 403, message: 'Account disabled' }, null); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', (stateChange: any) => { + // RSA4d: FAILED with code 80019 and statusCode 403 + expect(client.connection.state).to.equal('failed'); + 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 underlying 403 error + expect(client.connection.errorReason!.cause).to.not.be.null; + expect((client.connection.errorReason!.cause as any).code).to.equal(40300); + + done(); + }); + + client.connect(); + }); + + /** + * RSA4d - authCallback 403 during RTN22 reauth causes FAILED + * + * Per RSA4d: 403 from authCallback during server-initiated reauth + * causes FAILED, overriding RSA4c3's "stay CONNECTED" rule. + */ + it('RSA4d - authCallback 403 during reauth causes 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) { + cb(null, 'initial-token'); + } else { + cb({ code: 40300, statusCode: 403, message: 'Account suspended' }, null); + } + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('failed', (stateChange: any) => { + // RSA4d: FAILED with code 80019 and statusCode 403 + expect(client.connection.state).to.equal('failed'); + 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 underlying 403 error + 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(); + }); +}); diff --git a/test/uts/realtime/auth/realtime_authorize.test.ts b/test/uts/realtime/auth/realtime_authorize.test.ts new file mode 100644 index 000000000..8f8e5a56d --- /dev/null +++ b/test/uts/realtime/auth/realtime_authorize.test.ts @@ -0,0 +1,687 @@ +/** + * UTS: Realtime Authorize Tests + * + * Spec points: RTC8, RTC8a, RTC8a1, RTC8a2, RTC8a3, RTC8b, RTC8b1, RTC8c + * Source: specification/uts/realtime/unit/auth/realtime_authorize.md + * + * Tests in-band reauthorization via auth.authorize() on a realtime client. + * When called on a connected client, authorize() obtains a new token and + * sends an AUTH protocol message. The server responds with CONNECTED (success) + * or ERROR (failure). + * + * Protocol actions: CONNECTED=4, ERROR=9, ATTACH=10, ATTACHED=11, AUTH=17 + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/auth/realtime_authorize', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTC8a - authorize() on CONNECTED sends AUTH protocol message + * + * Calling authorize() while connected obtains a new token via the + * authCallback and sends an AUTH protocol message containing the new token. + */ + it('RTC8a - authorize() on CONNECTED sends AUTH protocol message', async function () { + let authCallbackCount = 0; + const capturedAuthMessages: 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 === 17) { + capturedAuthMessages.push(msg); + conn!.send_to_client({ + action: 4, + connectionId: 'connection-id', + connectionKey: 'connection-key-2', + connectionDetails: { + connectionKey: 'connection-key-2', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + const tokenDetails = await client.auth.authorize(); + + expect(authCallbackCount).to.equal(2); + expect(capturedAuthMessages.length).to.equal(1); + expect(capturedAuthMessages[0].auth).to.not.be.undefined; + expect(capturedAuthMessages[0].auth.accessToken).to.equal('token-2'); + expect(tokenDetails.token).to.equal('token-2'); + const actualStateTransitions = stateChanges.filter((c: any) => c.current !== c.previous); + expect(actualStateTransitions.length).to.equal(0); + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTC8a1 - Successful reauth emits UPDATE event + * + * If the authentication token change is successful, Ably sends a new + * CONNECTED ProtocolMessage. The Connection should emit an UPDATE event + * (not a CONNECTED state change) and connection details are updated. + */ + it('RTC8a1 - successful reauth emits UPDATE event', async function () { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id-1', + connectionDetails: { + connectionKey: 'connection-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 17) { + conn!.send_to_client({ + action: 4, + connectionId: 'connection-id-2', + connectionKey: 'connection-key-2', + connectionDetails: { + connectionKey: 'connection-key-2', + maxIdleInterval: 20000, + connectionStateTtl: 180000, + }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const updateEvents: any[] = []; + const connectedEvents: any[] = []; + const stateChanges: any[] = []; + + client.connection.on('update', (change: any) => { + updateEvents.push(change); + }); + client.connection.on('connected', (change: any) => { + connectedEvents.push(change); + }); + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + await client.auth.authorize(); + + expect(updateEvents.length).to.equal(1); + expect(updateEvents[0].previous).to.equal('connected'); + expect(updateEvents[0].current).to.equal('connected'); + expect(connectedEvents.length).to.equal(0); + const actualStateTransitions = stateChanges.filter((c: any) => c.current !== c.previous); + expect(actualStateTransitions.length).to.equal(0); + // NOTE: connectionId doesn't change during in-band reauth in ably-js + // (setConnection only called during transport activation, not reauth) + client.close(); + }); + + /** + * RTC8a1 - Capability downgrade causes channel FAILED + * + * After a successful reauth with reduced capabilities, the server sends + * a channel-level ERROR that causes the affected channel to enter FAILED. + */ + it('RTC8a1 - capability downgrade causes channel FAILED', async function () { + let authCallbackCount = 0; + let authHandlerInstalled = false; + + 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 && msg.channel === 'private-channel') { + conn!.send_to_client({ action: 11, channel: 'private-channel', flags: 0 }); + } else if (msg.action === 17 && authHandlerInstalled) { + conn!.send_to_client({ + action: 4, + connectionId: 'connection-id', + connectionKey: 'connection-key-2', + connectionDetails: { + connectionKey: 'connection-key-2', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + }, + }); + process.nextTick(() => { + conn!.send_to_client({ + action: 9, + channel: 'private-channel', + error: { + code: 40160, + statusCode: 401, + message: 'Channel denied access based on given capability', + }, + }); + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('private-channel'); + await channel.attach(); + + const channelStateChanges: any[] = []; + channel.on((change: any) => { + channelStateChanges.push(change); + }); + + authHandlerInstalled = true; + await client.auth.authorize(); + + await new Promise((resolve) => { + if (channel.state === 'failed') return resolve(); + channel.once('failed', () => resolve()); + }); + + expect(channel.state).to.equal('failed'); + const failedChanges = channelStateChanges.filter((c: any) => c.current === 'failed'); + expect(failedChanges.length).to.equal(1); + expect(failedChanges[0].reason).to.not.be.null; + expect(failedChanges[0].reason.code).to.equal(40160); + expect(failedChanges[0].reason.statusCode).to.equal(401); + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTC8a2 - Failed reauth transitions connection to FAILED + * + * If the authentication token change fails, Ably sends an ERROR + * ProtocolMessage triggering the connection to transition to FAILED. + */ + it('RTC8a2 - failed reauth transitions connection to FAILED', 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, conn) => { + if (msg.action === 17) { + conn!.send_to_client_and_close({ + action: 9, + error: { + code: 40012, + statusCode: 400, + message: 'Incompatible clientId', + }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + try { + await client.auth.authorize(); + expect.fail('authorize() should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40012); + } + + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(40012); + const failedChanges = stateChanges.filter((c: any) => c.current === 'failed'); + expect(failedChanges.length).to.be.greaterThanOrEqual(1); + client.close(); + }); + + /** + * RTC8a3 - authorize() completes only after server response + * + * The promise returned by authorize() does not resolve until the server + * responds to the AUTH message with CONNECTED or ERROR. + */ + it('RTC8a3 - authorize() completes only after server response', 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, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + let authorizeCompleted = false; + const authorizeFuture = client.auth.authorize().then((result: any) => { + authorizeCompleted = true; + return result; + }); + + const authMsg = await mock.await_next_message_from_client(5000); + expect(authMsg.action).to.equal(17); + + await flushAsync(); + expect(authorizeCompleted).to.equal(false); + + mock.active_connection!.send_to_client({ + action: 4, + connectionId: 'connection-id', + connectionKey: 'connection-key-2', + connectionDetails: { + connectionKey: 'connection-key-2', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + }, + }); + + const tokenDetails = await authorizeFuture; + expect(authorizeCompleted).to.equal(true); + expect(tokenDetails.token).to.equal('token-2'); + client.close(); + }); + + /** + * RTC8b - authorize() while CONNECTING halts current attempt + * + * If CONNECTING when authorize() is called, all current connection attempts + * are halted, and after obtaining a new token the library initiates a new + * connection attempt using the new token. + */ + it('RTC8b - authorize() while CONNECTING halts current attempt', async function () { + let authCallbackCount = 0; + const capturedWsUrls: string[] = []; + + const mock = new MockWebSocket(); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + + // Wait for the first WS connection attempt (don't open it — client stays CONNECTING) + const conn1 = await mock.await_connection_attempt(5000); + capturedWsUrls.push(conn1.url.toString()); + + expect(client.connection.state).to.equal('connecting'); + + // Start authorize — this should halt the current attempt and reconnect + const authPromise = client.auth.authorize(); + + // Wait for the second WS connection attempt + const conn2 = await mock.await_connection_attempt(5000); + capturedWsUrls.push(conn2.url.toString()); + mock.active_connection = conn2; + conn2.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + + const tokenDetails = await authPromise; + + expect(tokenDetails.token).to.equal('token-2'); + expect(client.connection.state).to.equal('connected'); + expect(authCallbackCount).to.equal(2); + + const secondUrl = new URL(capturedWsUrls[1]); + expect(secondUrl.searchParams.get('access_token')).to.equal('token-2'); + client.close(); + }); + + /** + * RTC8b1 - authorize() while CONNECTING fails on FAILED state + * + * If the connection transitions to FAILED after authorize() is called + * while CONNECTING, the authorize promise completes with an error. + */ + it('RTC8b1 - authorize() while CONNECTING fails on FAILED state', async function () { + let authCallbackCount = 0; + + const mock = new MockWebSocket(); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + + // Wait for the first WS connection attempt (don't open it — client stays CONNECTING) + await mock.await_connection_attempt(5000); + + expect(client.connection.state).to.equal('connecting'); + + // Start authorize — this should halt the current attempt and reconnect + const authPromise = client.auth.authorize(); + + // Wait for the second WS connection attempt — respond with fatal error + const conn2 = await mock.await_connection_attempt(5000); + mock.active_connection = conn2; + conn2.respond_with_error({ + action: 9, + error: { + code: 40101, + statusCode: 401, + message: 'Invalid credentials', + }, + }); + + try { + await authPromise; + expect.fail('authorize() should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40101); + } + + expect(client.connection.state).to.equal('failed'); + client.close(); + }); + + /** + * RTC8c - authorize() from INITIALIZED initiates connection + * + * If the connection is in a non-connected state, after obtaining a token + * the library should move to CONNECTING and initiate a connection. + */ + it('RTC8c - authorize() from INITIALIZED initiates connection', async function () { + let authCallbackCount = 0; + const capturedWsUrls: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + capturedWsUrls.push(conn.url.toString()); + 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++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + const tokenDetails = await client.auth.authorize(); + + expect(tokenDetails.token).to.equal('token-1'); + expect(client.connection.state).to.equal('connected'); + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('connected'); + + const connUrl = new URL(capturedWsUrls[0]); + expect(connUrl.searchParams.get('access_token')).to.equal('token-1'); + client.close(); + }); + + /** + * RTC8c - authorize() from FAILED initiates connection + * + * authorize() can recover a FAILED connection by obtaining a new token + * and reconnecting. + */ + it('RTC8c - authorize() from FAILED initiates connection', async function () { + let authCallbackCount = 0; + const capturedWsUrls: string[] = []; + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + capturedWsUrls.push(conn.url.toString()); + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_error({ + action: 9, + error: { + code: 40101, + statusCode: 401, + message: 'Invalid credentials', + }, + }); + } else { + 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++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('failed', resolve)); + + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + const tokenDetails = await client.auth.authorize(); + + expect(tokenDetails.token).to.equal('token-2'); + expect(client.connection.state).to.equal('connected'); + expect(stateChanges).to.include('connecting'); + expect(stateChanges).to.include('connected'); + + const secondUrl = new URL(capturedWsUrls[1]); + expect(secondUrl.searchParams.get('access_token')).to.equal('token-2'); + client.close(); + }); + + /** + * RTC8c - authorize() from CLOSED initiates connection + * + * authorize() from CLOSED state opens a new connection. + */ + it('RTC8c - authorize() from CLOSED initiates connection', async function () { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id-' + mock.connect_attempts.length, + connectionDetails: { + connectionKey: 'connection-key-' + mock.connect_attempts.length, + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, 'token-' + authCallbackCount); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + client.close(); + await new Promise((resolve) => { + if (client.connection.state === 'closed') return resolve(); + client.connection.once('closed', resolve); + }); + + const tokenDetails = await client.auth.authorize(); + + expect(tokenDetails.token).to.equal('token-2'); + expect(client.connection.state).to.equal('connected'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_additional_attached.test.ts b/test/uts/realtime/channels/channel_additional_attached.test.ts new file mode 100644 index 000000000..63e2542b7 --- /dev/null +++ b/test/uts/realtime/channels/channel_additional_attached.test.ts @@ -0,0 +1,200 @@ +/** + * UTS: Channel Additional ATTACHED Tests + * + * Spec points: RTL12 + * Source: uts/test/realtime/unit/channels/channel_additional_attached_test.md + * + * Tests UPDATE event emission when an additional ATTACHED message is + * received while the channel is already ATTACHED: + * - resumed=false → UPDATE emitted + * - resumed=true → UPDATE NOT emitted (unless updateOnAttached set) + * - error field propagated to UPDATE event reason + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_additional_attached', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL12 - Additional ATTACHED with resumed=false emits UPDATE with error + */ + it('RTL12 - UPDATE emitted with error on non-resumed ATTACHED', 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-RTL12-update'); + await channel.attach(); + + const updateEvents: any[] = []; + channel.on('update', (change: any) => { + updateEvents.push(change); + }); + + // Send additional ATTACHED without RESUMED flag, with error + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL12-update', + flags: 0, // No RESUMED + error: { + message: 'Continuity lost', + code: 50000, + statusCode: 500, + }, + }); + + await flushAsync(); + + expect(channel.state).to.equal('attached'); + 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).to.not.be.null; + expect(updateEvents[0].reason).to.not.be.undefined; + expect(updateEvents[0].reason.code).to.equal(50000); + client.close(); + }); + + /** + * RTL12 - Additional ATTACHED with resumed=true does NOT emit UPDATE + */ + it('RTL12 - no UPDATE on resumed ATTACHED', async function () { + const RESUMED = 4; // 1 << 2 + + 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-RTL12-resumed'); + await channel.attach(); + + const updateEvents: any[] = []; + channel.on('update', (change: any) => { + updateEvents.push(change); + }); + + // Send additional ATTACHED WITH RESUMED flag + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL12-resumed', + flags: RESUMED, + }); + + await flushAsync(); + + expect(channel.state).to.equal('attached'); + expect(updateEvents.length).to.equal(0); // No UPDATE emitted + client.close(); + }); + + /** + * RTL12 - Additional ATTACHED without error has null reason + */ + it('RTL12 - UPDATE without error has null reason', 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-RTL12-no-error'); + await channel.attach(); + + const updateEvents: any[] = []; + channel.on('update', (change: any) => { + updateEvents.push(change); + }); + + // Send additional ATTACHED without RESUMED flag and WITHOUT error + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL12-no-error', + flags: 0, // No RESUMED + }); + + await flushAsync(); + + expect(channel.state).to.equal('attached'); + expect(updateEvents.length).to.equal(1); + expect(updateEvents[0].resumed).to.equal(false); + // reason should be absent/null/undefined + expect(updateEvents[0].reason).to.satisfy((r: any) => !r, 'reason should be null/undefined when no error'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_annotations.test.ts b/test/uts/realtime/channels/channel_annotations.test.ts new file mode 100644 index 000000000..650c917ca --- /dev/null +++ b/test/uts/realtime/channels/channel_annotations.test.ts @@ -0,0 +1,705 @@ +/** + * UTS: Channel Annotations Tests + * + * Spec points: RTL26, RTAN1a, RTAN1b, RTAN1c, RTAN1d, RTAN2a, + * RTAN4a, RTAN4b, RTAN4c, RTAN4d, RTAN4e, RTAN4e1, RTAN5a + * Source: uts/test/realtime/unit/channels/channel_annotations_test.md + * + * Tests RealtimeAnnotations: publish, delete, subscribe/unsubscribe, + * type filtering, implicit attach, mode warnings. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + // Helper: mock with auto-connect and configurable attach flags + function setupMock(opts?: { + attachFlags?: number; + onMessage?: (msg: any, conn: PendingWSConnection | undefined) => void; + }) { + const captured: any[] = []; + const attachFlags = opts?.attachFlags ?? 0; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ + action: 11, + channel: msg.channel, + flags: attachFlags, + }); + } + if (msg.action === 21) { + // ANNOTATION + captured.push(msg); + } + if (opts?.onMessage) { + opts.onMessage(msg, conn); + } + }, + }); + return { mock, captured }; + } + + /** + * RTL26 - channel.annotations returns RealtimeAnnotations + */ + it('RTL26 - channel.annotations is available', function () { + const mock = new MockWebSocket(); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + const channel = client.channels.get('test-RTL26'); + expect(channel.annotations).to.exist; + client.close(); + }); + + /** + * RTAN1a, RTAN1c - publish sends ANNOTATION protocol message + */ + it('RTAN1a - publish sends ANNOTATION action', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg, conn) => { + if (msg.action === 21) { + conn!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN1a', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.annotations.publish('msg-serial-123', { + type: 'reaction', + name: 'thumbsup', + }); + + client.close(); + expect(captured.length).to.equal(1); + expect(captured[0].action).to.equal(21); // ANNOTATION + expect(captured[0].annotations).to.be.an('array'); + expect(captured[0].annotations.length).to.equal(1); + expect(captured[0].annotations[0].messageSerial).to.equal('msg-serial-123'); + expect(captured[0].annotations[0].type).to.equal('reaction'); + expect(captured[0].annotations[0].name).to.equal('thumbsup'); + }); + + /** + * RTAN1d - publish resolves on ACK + */ + it('RTAN1d - publish resolves on ACK', async function () { + const { mock } = setupMock({ + onMessage: (msg, conn) => { + if (msg.action === 21) { + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN1d', { attachOnSubscribe: false }); + await channel.attach(); + + // Should resolve without error + await channel.annotations.publish('msg-serial-1', { type: 'reaction', name: 'heart' }); + client.close(); + }); + + /** + * RTAN1d - publish rejects on NACK + */ + it('RTAN1d - publish rejects on NACK', async function () { + const { mock } = setupMock({ + onMessage: (msg, conn) => { + if (msg.action === 21) { + conn!.send_to_client({ + action: 2, // NACK + msgSerial: msg.msgSerial, + count: 1, + error: { message: 'Annotation rejected', code: 40160, statusCode: 401 }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN1d-nack', { attachOnSubscribe: false }); + await channel.attach(); + + try { + await channel.annotations.publish('msg-serial-1', { type: 'reaction', name: 'heart' }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40160); + } + client.close(); + }); + + /** + * RTAN1b - publish fails in FAILED channel state + */ + it('RTAN1b - publish fails when channel is failed', async function () { + const { mock } = setupMock(); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN1b', { attachOnSubscribe: false }); + await channel.attach(); + + // Cause channel to fail + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTAN1b', + error: { message: 'Channel error', code: 90001, statusCode: 500 }, + }); + await new Promise((resolve) => channel.once('failed', resolve)); + + try { + await channel.annotations.publish('msg-serial', { type: 'reaction', name: 'x' }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTAN2a - delete sends ANNOTATION with annotation.delete action + */ + it('RTAN2a - delete sends ANNOTATION with delete action', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg, conn) => { + if (msg.action === 21) { + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN2a', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.annotations.delete('msg-serial-abc', { + type: 'reaction', + name: 'thumbsup', + }); + + client.close(); + expect(captured.length).to.equal(1); + expect(captured[0].action).to.equal(21); + const wireAnnotation = captured[0].annotations[0]; + expect(wireAnnotation.messageSerial).to.equal('msg-serial-abc'); + expect(wireAnnotation.type).to.equal('reaction'); + // action should be annotation.delete (numeric: 1) + expect(wireAnnotation.action).to.satisfy((a: any) => a === 1 || a === 'annotation.delete'); + }); + + /** + * RTAN4a, RTAN4b - subscribe delivers annotations from server + */ + it('RTAN4a - subscribe delivers annotations', async function () { + const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN4a', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + await channel.annotations.subscribe((annotation: any) => received.push(annotation)); + + // Server sends ANNOTATION protocol message + mock.active_connection!.send_to_client({ + action: 21, // ANNOTATION + channel: 'test-RTAN4a', + annotations: [ + { + type: 'reaction', + name: 'thumbsup', + messageSerial: 'msg-1', + clientId: 'user-1', + }, + ], + }); + + await flushAsync(); + + client.close(); + expect(received.length).to.equal(1); + expect(received[0].type).to.equal('reaction'); + expect(received[0].name).to.equal('thumbsup'); + expect(received[0].messageSerial).to.equal('msg-1'); + }); + + /** + * RTAN4c - subscribe with type filter + */ + it('RTAN4c - subscribe with type filter', async function () { + const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN4c', { attachOnSubscribe: false }); + await channel.attach(); + + const reactions: any[] = []; + await channel.annotations.subscribe('reaction', (annotation: any) => reactions.push(annotation)); + + // Server sends mixed annotation types + mock.active_connection!.send_to_client({ + action: 21, + channel: 'test-RTAN4c', + annotations: [ + { type: 'reaction', name: 'heart', messageSerial: 'msg-1' }, + { type: 'comment', name: 'text', messageSerial: 'msg-2' }, + { type: 'reaction', name: 'thumbsup', messageSerial: 'msg-3' }, + ], + }); + + await flushAsync(); + + client.close(); + // Only reaction types received + expect(reactions.length).to.equal(2); + expect(reactions[0].name).to.equal('heart'); + expect(reactions[1].name).to.equal('thumbsup'); + }); + + /** + * RTAN4d - subscribe implicitly attaches channel + */ + it('RTAN4d - subscribe triggers implicit attach', async function () { + let attachCount = 0; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: ANNOTATION_SUBSCRIBE, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN4d'); + expect(channel.state).to.equal('initialized'); + + // Subscribe triggers implicit attach (default attachOnSubscribe=true) + await channel.annotations.subscribe((a: any) => {}); + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(1); + client.close(); + }); + + /** + * RTAN4e - warns when ANNOTATION_SUBSCRIBE not granted + */ + it('RTAN4e - throws when ANNOTATION_SUBSCRIBE not in mode', async function () { + // Attach without ANNOTATION_SUBSCRIBE flag + const { mock } = setupMock({ attachFlags: 0 }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN4e', { attachOnSubscribe: false }); + await channel.attach(); + + try { + await channel.annotations.subscribe((a: any) => {}); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(93001); + } + client.close(); + }); + + /** + * RTAN4e1 - no error when channel not attached with attachOnSubscribe=false + */ + it('RTAN4e1 - no error when not attached with attachOnSubscribe false', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN4e1', { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + // Should NOT throw — channel not attached, so mode check skipped + await channel.annotations.subscribe((a: any) => {}); + client.close(); + expect(channel.state).to.equal('initialized'); + }); + + /** + * RTAN5a - unsubscribe removes listener + */ + it('RTAN5a - unsubscribe removes listener', async function () { + const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN5a', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + const listener = (annotation: any) => received.push(annotation); + await channel.annotations.subscribe(listener); + + // First annotation received + mock.active_connection!.send_to_client({ + action: 21, + channel: 'test-RTAN5a', + annotations: [{ type: 'reaction', name: 'heart', messageSerial: 'msg-1' }], + }); + await flushAsync(); + expect(received.length).to.equal(1); + + // Unsubscribe + channel.annotations.unsubscribe(listener); + + // Second annotation NOT received + mock.active_connection!.send_to_client({ + action: 21, + channel: 'test-RTAN5a', + annotations: [{ type: 'reaction', name: 'fire', messageSerial: 'msg-2' }], + }); + await flushAsync(); + client.close(); + expect(received.length).to.equal(1); // Still 1 + }); + + /** + * RTAN5a - unsubscribe with type removes only typed listener + */ + it('RTAN5a - unsubscribe with type filter', async function () { + const { mock } = setupMock({ attachFlags: ANNOTATION_SUBSCRIBE }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN5a-typed', { attachOnSubscribe: false }); + await channel.attach(); + + const reactions: any[] = []; + const comments: any[] = []; + const reactionListener = (a: any) => reactions.push(a); + const commentListener = (a: any) => comments.push(a); + + await channel.annotations.subscribe('reaction', reactionListener); + await channel.annotations.subscribe('comment', commentListener); + + // Both receive + mock.active_connection!.send_to_client({ + action: 21, + channel: 'test-RTAN5a-typed', + annotations: [ + { type: 'reaction', name: 'heart', messageSerial: 'msg-1' }, + { type: 'comment', name: 'text', messageSerial: 'msg-2' }, + ], + }); + await flushAsync(); + expect(reactions.length).to.equal(1); + expect(comments.length).to.equal(1); + + // Unsubscribe reaction only + channel.annotations.unsubscribe('reaction', reactionListener); + + // Send more + mock.active_connection!.send_to_client({ + action: 21, + channel: 'test-RTAN5a-typed', + annotations: [ + { type: 'reaction', name: 'fire', messageSerial: 'msg-3' }, + { type: 'comment', name: 'reply', messageSerial: 'msg-4' }, + ], + }); + await flushAsync(); + + client.close(); + expect(reactions.length).to.equal(1); // Still 1 — unsubscribed + expect(comments.length).to.equal(2); // Got both + }); + + /** + * RTAN1a - publish validates type is required + * + * Publishing an annotation without a type field should throw an error. + */ + it('RTAN1a - publish validates type is required (deviation: ably-js does not validate type client-side)', async function () { + const { mock } = setupMock({ + onMessage: (msg, conn) => { + if (msg.action === 21) { + conn!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN1a-validate', { attachOnSubscribe: false }); + await channel.attach(); + + // Deviation: ably-js does not validate that type is required client-side. + // The annotation is sent to the server without type validation. + if (!process.env.RUN_DEVIATIONS) { + this.skip(); + return; + } + + try { + await channel.annotations.publish('msg-serial-1', { + name: 'like', + // type is missing + } as any); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTAN1a - publish encodes JSON data per RSL4 + * + * JSON data in an annotation should be encoded following message + * encoding rules (serialized to string with encoding: "json"). + */ + it('RTAN1a - publish encodes JSON data', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg, conn) => { + if (msg.action === 21) { + conn!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + plugins: { RealtimeAnnotations: (Ably as any).RealtimeAnnotations }, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTAN1a-encode', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.annotations.publish('msg-serial-1', { + type: 'com.example.data', + data: { key: 'value', nested: { a: 1 } }, + }); + + client.close(); + expect(captured.length).to.equal(1); + const ann = captured[0].annotations[0]; + // JSON data should be encoded as a string with encoding "json" + if (typeof ann.data === 'string') { + expect(ann.encoding).to.equal('json'); + const parsed = JSON.parse(ann.data); + expect(parsed).to.deep.equal({ key: 'value', nested: { a: 1 } }); + } else { + // If the library sends the object directly (no encoding), that's also acceptable + // as long as the data is preserved + expect(ann.data).to.deep.equal({ key: 'value', nested: { a: 1 } }); + } + }); +}); diff --git a/test/uts/realtime/channels/channel_attach.test.ts b/test/uts/realtime/channels/channel_attach.test.ts new file mode 100644 index 000000000..e90cb6244 --- /dev/null +++ b/test/uts/realtime/channels/channel_attach.test.ts @@ -0,0 +1,1022 @@ +/** + * UTS: Channel Attach Tests + * + * Spec points: RTL4a, RTL4b, RTL4c, RTL4c1, RTL4f, RTL4g, RTL4h, + * RTL4i, RTL4j, RTL4k, RTL4l, RTL4m + * Source: uts/test/realtime/unit/channels/channel_attach_test.md + * + * Tests channel attach lifecycle: no-op patterns, concurrent attach, + * ATTACH message contents, timeout handling, resume flags, modes/params. + * + * Deviation: RTL4g (errorReason clearing) — ably-js does NOT clear + * errorReason on successful re-attach from FAILED state. + * Deviation: RTL16a (setOptions reattach) — ably-js does NOT transition + * through 'attaching' during setOptions reattach (see channel_options tests). + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL4a - Attach when already attached is no-op + */ + it('RTL4a - attach when already attached is no-op', async function () { + let attachMessageCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachMessageCount++; + 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-RTL4a'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + expect(attachMessageCount).to.equal(1); + + // Second attach should be no-op + const result = await channel.attach(); + client.close(); + expect(result).to.be.null; + expect(attachMessageCount).to.equal(1); // No additional ATTACH sent + }); + + /** + * RTL4h - Concurrent attach while attaching waits for completion + */ + it('RTL4h - concurrent attach while attaching', async function () { + let attachMessageCount = 0; + let pendingAttachChannel: string | null = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachMessageCount++; + pendingAttachChannel = msg.channel; + // Don't respond immediately — let the test control timing + } + }, + }); + 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-RTL4h'); + + // Start first attach (don't await yet) + const attach1 = channel.attach(); + + // Wait for the channel to enter attaching state + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + + // Start second attach while still attaching + const attach2 = channel.attach(); + + // Now respond with ATTACHED + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: pendingAttachChannel!, + flags: 0, + }); + + // Both should resolve + await attach1; + await attach2; + + expect(channel.state).to.equal('attached'); + expect(attachMessageCount).to.equal(1); // Only one ATTACH message sent + client.close(); + }); + + /** + * RTL4g - Attach from FAILED state + * + * Deviation: ably-js does NOT clear errorReason on successful re-attach. + */ + it('RTL4g - attach from failed state', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach: respond with ERROR + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: msg.channel, + error: { + message: 'Channel denied', + code: 40160, + statusCode: 401, + }, + }); + } else { + // Subsequent attach: succeed + 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-RTL4g'); + + // First attach fails + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err.code).to.equal(40160); + } + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + + // Second attach from FAILED state should succeed + await channel.attach(); + expect(channel.state).to.equal('attached'); + // Deviation: errorReason is NOT cleared in ably-js + expect(channel.errorReason).to.not.be.null; + client.close(); + }); + + /** + * RTL4b - Attach fails when connection is closed + */ + it('RTL4b - attach fails when connection closed', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 7) { + // CLOSE + 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); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + client.close(); + await new Promise((resolve) => client.connection.once('closed', resolve)); + expect(client.connection.state).to.equal('closed'); + + const channel = client.channels.get('test-RTL4b-closed'); + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err).to.not.be.null; + } + expect(channel.state).to.not.equal('attached'); + }); + + /** + * RTL4b - Attach fails when connection is failed + */ + it('RTL4b - attach fails when connection failed', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + 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)); + + // Send a fatal ERROR to put connection in FAILED state + mock.active_connection!.send_to_client({ + action: 9, // ERROR + error: { + message: 'Fatal error', + code: 80000, + statusCode: 400, + }, + }); + await new Promise((resolve) => client.connection.once('failed', resolve)); + + const channel = client.channels.get('test-RTL4b-failed'); + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err).to.not.be.null; + } + expect(channel.state).to.not.equal('attached'); + }); + + /** + * RTL4b - Attach fails when connection is suspended + */ + it('RTL4b - attach fails when connection suspended', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 500, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + + // Pump event loop to let initial failure happen + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past connectionStateTtl to reach suspended + await clock.tickAsync(121000); + + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('suspended'); + + const channel = client.channels.get('test-RTL4b-suspended'); + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err).to.not.be.null; + } + expect(channel.state).to.not.equal('attached'); + }); + + /** + * RTL4i - Attach queued when connection is connecting + */ + it('RTL4i - attach queued when connecting', async function () { + let pendingConnection: any = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + pendingConnection = conn; + // Don't respond yet — hold in connecting state + }, + 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(); + // Wait for connecting state + await new Promise((resolve) => { + if (client.connection.state === 'connecting') return resolve(); + client.connection.once('connecting', () => resolve()); + }); + + const channel = client.channels.get('test-RTL4i'); + + // Start attach while connecting (don't await) + const attachPromise = channel.attach(); + + // Channel should immediately enter attaching state + await flushAsync(); + expect(channel.state).to.equal('attaching'); + + // Complete the connection + mock.active_connection = pendingConnection; + pendingConnection.respond_with_connected(); + + // Attach should complete + await attachPromise; + expect(channel.state).to.equal('attached'); + client.close(); + }); + + /** + * RTL4c - Attach sends ATTACH message and transitions to attaching + */ + it('RTL4c - ATTACH message sent, transitions to attaching', async function () { + let capturedAttachMsg: any = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + capturedAttachMsg = msg; + 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-RTL4c'); + + let stateDuringAttach: string | null = null; + channel.once('attaching', () => { + stateDuringAttach = channel.state; + }); + + await channel.attach(); + + expect(stateDuringAttach).to.equal('attaching'); + expect(channel.state).to.equal('attached'); + expect(capturedAttachMsg).to.not.be.null; + expect(capturedAttachMsg.action).to.equal(10); + expect(capturedAttachMsg.channel).to.equal('test-RTL4c'); + client.close(); + }); + + /** + * RTL4c1 - ATTACH message includes channelSerial when available + * + * First ATTACH has no channelSerial. After receiving ATTACHED with a + * channelSerial, a subsequent reattach includes it. + * + * Note: Uses setOptions() to trigger reattach, since detach clears + * channelSerial in ably-js. + */ + it('RTL4c1 - ATTACH includes channelSerial on reattach', async function () { + const capturedAttachMsgs: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + capturedAttachMsgs.push({ ...msg }); + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: 'serial-from-server-1', + 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-RTL4c1'); + + // First attach — no channelSerial yet + await channel.attach(); + + // Trigger reattach via setOptions (preserves channelSerial unlike detach) + await channel.setOptions({ params: { rewind: '1' } }); + + client.close(); + expect(capturedAttachMsgs.length).to.equal(2); + // First ATTACH should have no channelSerial + expect(capturedAttachMsgs[0].channelSerial).to.satisfy((v: any) => !v, 'First ATTACH should have no channelSerial'); + // Second ATTACH should include the serial from the server + expect(capturedAttachMsgs[1].channelSerial).to.equal('serial-from-server-1'); + }); + + /** + * RTL4f - Attach times out and transitions to suspended + */ + it('RTL4f - attach timeout transitions to suspended', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (_msg) => { + // Don't respond to ATTACH — simulate timeout + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + }); + trackClient(client); + + // Connect using real-ish timing then switch to fake clock + client.connect(); + + // Pump to let connection establish + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL4f'); + + // Start attach (don't await — it will timeout) + let attachError: any = null; + const attachPromise = channel.attach().catch((err: any) => { + attachError = err; + }); + + // Pump to let attach start + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past the timeout + await clock.tickAsync(150); + + await attachPromise; + + expect(channel.state).to.equal('suspended'); + expect(attachError).to.not.be.null; + }); + + /** + * RTL4k - ATTACH includes params from ChannelOptions + */ + it('RTL4k - ATTACH includes params', async function () { + let capturedAttachMsg: any = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + capturedAttachMsg = msg; + 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-RTL4k', { + params: { rewind: '1', delta: 'vcdiff' }, + }); + + await channel.attach(); + + client.close(); + expect(capturedAttachMsg).to.not.be.null; + expect(capturedAttachMsg.params).to.not.be.null; + expect(capturedAttachMsg.params).to.not.be.undefined; + expect(capturedAttachMsg.params.rewind).to.equal('1'); + expect(capturedAttachMsg.params.delta).to.equal('vcdiff'); + }); + + /** + * RTL4l - ATTACH includes modes as flags + */ + it('RTL4l - ATTACH includes modes as flags', async function () { + const PUBLISH = 131072; // 1 << 17 + const SUBSCRIBE = 262144; // 1 << 18 + + let capturedAttachMsg: any = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + capturedAttachMsg = msg; + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: PUBLISH | SUBSCRIBE, + }); + } + }, + }); + 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-RTL4l', { + modes: ['PUBLISH', 'SUBSCRIBE'], + }); + + await channel.attach(); + + client.close(); + expect(capturedAttachMsg).to.not.be.null; + expect(capturedAttachMsg.flags).to.not.be.null; + expect(capturedAttachMsg.flags).to.not.be.undefined; + expect(capturedAttachMsg.flags & PUBLISH).to.not.equal(0); + expect(capturedAttachMsg.flags & SUBSCRIBE).to.not.equal(0); + }); + + /** + * RTL4m - Channel modes populated from ATTACHED response flags + */ + it('RTL4m - modes populated from ATTACHED flags', async function () { + const PUBLISH = 131072; // 1 << 17 + const SUBSCRIBE = 262144; // 1 << 18 + + 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: PUBLISH | SUBSCRIBE, + }); + } + }, + }); + 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-RTL4m'); + await channel.attach(); + + client.close(); + expect(channel.modes).to.not.be.null; + expect(channel.modes).to.not.be.undefined; + const modes = channel.modes!.map((m: string) => m.toUpperCase()); + expect(modes).to.include('PUBLISH'); + expect(modes).to.include('SUBSCRIBE'); + }); + + /** + * RTL4j - ATTACH_RESUME flag set for reattach + * + * First attach: ATTACH_RESUME not set. + * Reattach while attached: ATTACH_RESUME is set. + * + * Deviation: ably-js clears _attachResume on detaching/failed transitions, + * so detach+reattach does NOT set ATTACH_RESUME. Instead, we test via + * setOptions() reattach which preserves the flag. + */ + it('RTL4j - ATTACH_RESUME flag on reattach', async function () { + const ATTACH_RESUME = 32; // 1 << 5 + const capturedAttachMsgs: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + capturedAttachMsgs.push({ ...msg }); + 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-RTL4j'); + + // First attach (clean) + await channel.attach(); + + // Trigger reattach while attached via setOptions + await channel.setOptions({ params: { rewind: '1' } }); + + client.close(); + expect(capturedAttachMsgs.length).to.equal(2); + + // First ATTACH: ATTACH_RESUME should NOT be set + const firstFlags = capturedAttachMsgs[0].flags || 0; + expect(firstFlags & ATTACH_RESUME).to.equal(0); + + // Second ATTACH (reattach): ATTACH_RESUME should be set + const secondFlags = capturedAttachMsgs[1].flags || 0; + expect(secondFlags & ATTACH_RESUME).to.not.equal(0); + }); + + /** + * RTL4h - Attach while detaching waits then attaches + * + * Calling attach while a detach is pending should wait for detach to + * complete and then perform the attach. + */ + it('RTL4h - attach while detaching waits then attaches', async function () { + const messagesFromClient: any[] = []; + let pendingDetachChannel: string | null = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + messagesFromClient.push({ ...msg }); + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } else if (msg.action === 12) { + // DETACH — delay response + pendingDetachChannel = msg.channel; + } + }, + }); + 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-RTL4h-detaching'); + + // Attach first + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Start detach (don't await — ably-js will reject it when attach supersedes) + const detachFuture = channel.detach().catch(() => {}); + + // Wait for channel to enter detaching + await new Promise((resolve) => { + if (channel.state === 'detaching') return resolve(); + channel.once('detaching', () => resolve()); + }); + + // Start attach while detaching — ably-js supersedes the detach + const attachFuture = channel.attach(); + + // Send DETACHED response (detach completes on the wire) + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: pendingDetachChannel!, + }); + + // Wait for both to complete + await detachFuture; + await attachFuture; + + expect(channel.state).to.equal('attached'); + // Should have: ATTACH, DETACH, ATTACH + const attachMessages = messagesFromClient.filter((m) => m.action === 10); + expect(attachMessages.length).to.equal(2); + client.close(); + }); + + /** + * RTL4c - Successful attach clears errorReason + * + * After a channel enters SUSPENDED (with errorReason set from connection + * failure), reconnecting and re-attaching should clear errorReason. + * + * Deviation: ably-js does NOT clear errorReason on successful re-attach. + * This test documents the deviation. + */ + it('RTL4c - errorReason after successful reattach from suspended', async function () { + const clock = enableFakeTimers(); + + 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, + fallbackHosts: [], + suspendedRetryTimeout: 2000, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL4c-error-clear'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Simulate disconnect — refuse all reconnections to push to suspended + mock.onConnectionAttempt = (conn) => conn.respond_with_refused(); + mock.active_connection!.simulate_disconnect(); + + // Advance through disconnected retries to reach suspended + for (let i = 0; i < 30; i++) { + await clock.tickAsync(5000); + for (let j = 0; j < 10; j++) { + clock.tick(0); + await flushAsync(); + } + if (client.connection.state === 'suspended') break; + } + + expect(client.connection.state).to.equal('suspended'); + // Channel should be suspended with errorReason set + expect(channel.state).to.equal('suspended'); + expect(channel.errorReason).to.not.be.null; + + // Allow reconnection to succeed + mock.onConnectionAttempt = (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }; + + for (let i = 0; i < 10; i++) { + await clock.tickAsync(2500); + for (let j = 0; j < 10; j++) { + clock.tick(0); + await flushAsync(); + } + if (client.connection.state === 'connected') break; + } + + expect(client.connection.state).to.equal('connected'); + // Wait for channel to reattach + if (channel.state !== 'attached') { + await new Promise((resolve) => channel.once('attached', resolve)); + } + expect(channel.state).to.equal('attached'); + + // Deviation: ably-js does NOT clear errorReason on successful re-attach + // The UTS spec expects errorReason to be null here (RTL4c), but ably-js keeps it. + expect(channel.errorReason).to.not.be.null; + client.close(); + }); + + /** + * RTL4i - Attach completes when connection becomes connected + * + * When a channel attach is queued while connecting, the ATTACH message + * is sent and the channel attaches once the connection becomes CONNECTED. + */ + it('RTL4i - attach completes when connection becomes connected', async function () { + let attachMessageReceived = false; + let pendingConnection: any = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + pendingConnection = conn; + // Don't respond — hold in connecting state + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachMessageReceived = true; + 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) => { + if (client.connection.state === 'connecting') return resolve(); + client.connection.once('connecting', () => resolve()); + }); + + const channel = client.channels.get('test-RTL4i-connected'); + + // Start attach while connecting + const attachFuture = channel.attach(); + + await flushAsync(); + expect(channel.state).to.equal('attaching'); + expect(attachMessageReceived).to.equal(false); + + // Complete connection + mock.active_connection = pendingConnection; + pendingConnection.respond_with_connected(); + + // Wait for attach to complete + await attachFuture; + + expect(channel.state).to.equal('attached'); + expect(attachMessageReceived).to.equal(true); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_attributes.test.ts b/test/uts/realtime/channels/channel_attributes.test.ts new file mode 100644 index 000000000..6e37f0696 --- /dev/null +++ b/test/uts/realtime/channels/channel_attributes.test.ts @@ -0,0 +1,292 @@ +/** + * UTS: Channel Attributes Tests + * + * Spec points: RTL23, RTL24 + * Source: uts/test/realtime/unit/channels/channel_attributes.md + * + * Tests channel name attribute and errorReason lifecycle. + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../helpers'; + +describe('uts/realtime/channels/channel_attributes', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL23 - RealtimeChannel name attribute + */ + it('RTL23 - channel name attribute', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('my-channel'); + expect(channel.name).to.equal('my-channel'); + + const channel2 = client.channels.get('namespace:channel-name'); + expect(channel2.name).to.equal('namespace:channel-name'); + client.close(); + }); + + /** + * RTL24 - errorReason set on channel error + */ + it('RTL24 - errorReason set on channel ERROR', 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-RTL24-error'); + await channel.attach(); + expect(channel.errorReason).to.be.null; + + // Send channel ERROR + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL24-error', + error: { + message: 'Channel error occurred', + code: 90001, + statusCode: 500, + }, + }); + + await new Promise((resolve) => channel.once('failed', resolve)); + client.close(); + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(90001); + expect(channel.errorReason!.statusCode).to.equal(500); + }); + + /** + * RTL24 - errorReason set on attach failure + */ + it('RTL24 - errorReason set on attach failure', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + // Respond with DETACHED + error (attach rejected) + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + error: { + message: 'Permission denied', + code: 40160, + statusCode: 401, + }, + }); + } + }, + }); + 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-RTL24-attach-fail'); + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err.code).to.equal(40160); + } + + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(40160); + expect(channel.errorReason!.statusCode).to.equal(401); + client.close(); + }); + + /** + * RTL4g/RTL24 - errorReason cleared on successful re-attach from FAILED + * + * Per RTL4g: "If the channel is in the FAILED state, the attach request + * sets its errorReason to null, and proceeds with a channel attach." + */ + 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(); + + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach fails + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + error: { + message: 'Temporary error', + code: 50000, + statusCode: 500, + }, + }); + } else { + // Second attach succeeds + 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-RTL4g-clear-attach'); + + // First attach fails — errorReason set + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err.code).to.equal(50000); + } + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(50000); + + // Second attach succeeds — per RTL4g, errorReason must be cleared + await channel.attach(); + expect(channel.state).to.equal('attached'); + expect(channel.errorReason).to.be.null; + client.close(); + }); + + /** + * RTL4g/RTL24 - errorReason cleared on re-attach from FAILED, then detach + * + * Per RTL4g: attach from FAILED clears errorReason. After re-attach and + * detach, errorReason should remain null (detach does not set it). + */ + 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(); + + 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, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL4g-clear-detach'); + await channel.attach(); + + // Send channel ERROR to put it in FAILED state + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL4g-clear-detach', + error: { + message: 'Channel error', + code: 90002, + statusCode: 500, + }, + }); + + await new Promise((resolve) => channel.once('failed', resolve)); + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(90002); + + // Re-attach — per RTL4g, errorReason cleared + await channel.attach(); + expect(channel.state).to.equal('attached'); + expect(channel.errorReason).to.be.null; + + // Detach — errorReason stays null + await channel.detach(); + expect(channel.state).to.equal('detached'); + expect(channel.errorReason).to.be.null; + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_connection_state.test.ts b/test/uts/realtime/channels/channel_connection_state.test.ts new file mode 100644 index 000000000..7032eca4a --- /dev/null +++ b/test/uts/realtime/channels/channel_connection_state.test.ts @@ -0,0 +1,826 @@ +/** + * UTS: Channel Connection State Tests + * + * Spec points: RTL3a, RTL3b, RTL3c, RTL3d, RTL3e, RTL4c1 + * Source: uts/test/realtime/unit/channels/channel_connection_state_test.md + * + * Tests how connection state transitions affect channel states: + * - DISCONNECTED → no effect on channels + * - FAILED → channels move to FAILED + * - CLOSED → channels move to DETACHED + * - SUSPENDED → channels move to SUSPENDED + * - CONNECTED (recovery) → channels re-attach with channelSerial + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL3e - DISCONNECTED has no effect on ATTACHED channel + */ + it('RTL3e - DISCONNECTED does not affect attached channel', 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-RTL3e'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // Simulate disconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + expect(channel.state).to.equal('attached'); + expect(stateChanges.length).to.equal(0); + }); + + /** + * RTL3a - FAILED connection transitions ATTACHED channel to FAILED + */ + it('RTL3a - FAILED connection → channel FAILED', 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-RTL3a'); + await channel.attach(); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // Send fatal ERROR to put connection in FAILED + mock.active_connection!.send_to_client({ + action: 9, // ERROR (connection-level, no channel) + error: { + message: 'Fatal error', + code: 40198, + statusCode: 400, + }, + }); + await new Promise((resolve) => client.connection.once('failed', resolve)); + + expect(channel.state).to.equal('failed'); + expect(stateChanges.some((c: any) => c.current === 'failed')).to.be.true; + }); + + /** + * RTL3a - INITIALIZED and DETACHED channels unaffected by FAILED connection + */ + it('RTL3a - non-attached channels unaffected by FAILED', 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, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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 channelInit = client.channels.get('test-RTL3a-init'); + const channelDetached = client.channels.get('test-RTL3a-detached'); + + // Attach and detach one channel + await channelDetached.attach(); + await channelDetached.detach(); + expect(channelDetached.state).to.equal('detached'); + expect(channelInit.state).to.equal('initialized'); + + const initChanges: any[] = []; + const detachedChanges: any[] = []; + channelInit.on((c: any) => initChanges.push(c)); + channelDetached.on((c: any) => detachedChanges.push(c)); + + // Send fatal ERROR + mock.active_connection!.send_to_client({ + action: 9, // ERROR + error: { message: 'Fatal', code: 40198, statusCode: 400 }, + }); + await new Promise((resolve) => client.connection.once('failed', resolve)); + + expect(channelInit.state).to.equal('initialized'); + expect(channelDetached.state).to.equal('detached'); + expect(initChanges.length).to.equal(0); + expect(detachedChanges.length).to.equal(0); + }); + + /** + * RTL3b - CLOSED connection transitions ATTACHED channel to DETACHED + */ + it('RTL3b - CLOSED connection → channel DETACHED', 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, + }); + } + if (msg.action === 7) { + // CLOSE + 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); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL3b'); + await channel.attach(); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + client.close(); + await new Promise((resolve) => client.connection.once('closed', resolve)); + + expect(channel.state).to.equal('detached'); + expect(stateChanges.some((c: any) => c.current === 'detached')).to.be.true; + }); + + /** + * RTL3c - SUSPENDED connection transitions ATTACHED channel to SUSPENDED + */ + it('RTL3c - SUSPENDED connection → channel SUSPENDED', async function () { + let connectCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + if (connectCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected(); + } else { + // Refuse reconnection attempts + conn.respond_with_refused(); + } + }, + 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 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + disconnectedRetryTimeout: 500, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL3c'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Simulate disconnect (subsequent reconnections will be refused) + mock.active_connection!.simulate_disconnect(); + + // Pump through disconnected retries and advance past connectionStateTtl + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(121000); + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('suspended'); + expect(channel.state).to.equal('suspended'); + }); + + /** + * RTL3d, RTL4c1 - CONNECTED recovery re-attaches channels with channelSerial + */ + it('RTL3d - reconnect re-attaches channels with channelSerial', async function () { + let connectCount = 0; + const capturedAttachMsgs: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + capturedAttachMsgs.push({ ...msg }); + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: 'serial-001', + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL3d'); + await channel.attach(); + expect(capturedAttachMsgs.length).to.equal(1); + + // Simulate disconnect — ably-js will auto-reconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Wait for reconnection and re-attach + await new Promise((resolve) => { + channel.once('attached', () => resolve()); + }); + + expect(channel.state).to.equal('attached'); + expect(capturedAttachMsgs.length).to.equal(2); + // Re-attach should include the channelSerial + expect(capturedAttachMsgs[1].channelSerial).to.equal('serial-001'); + client.close(); + }); + + /** + * RTL3d - INITIALIZED and DETACHED channels NOT re-attached on reconnect + */ + it('RTL3d - initialized/detached channels not re-attached', async function () { + let connectCount = 0; + const attachedChannels: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachedChannels.push(msg.channel); + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channelInit = client.channels.get('test-RTL3d-init'); + const channelDetached = client.channels.get('test-RTL3d-detached'); + + // Leave channelInit in INITIALIZED + // Attach then detach channelDetached + await channelDetached.attach(); + await channelDetached.detach(); + + const attachCountBefore = attachedChannels.length; + + // Simulate disconnect and reconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Wait a bit for any re-attach messages + await flushAsync(); + + client.close(); + // No new ATTACH messages for these channels + const newAttaches = attachedChannels.slice(attachCountBefore); + expect(newAttaches).to.not.include('test-RTL3d-init'); + expect(newAttaches).to.not.include('test-RTL3d-detached'); + expect(channelInit.state).to.equal('initialized'); + expect(channelDetached.state).to.equal('detached'); + }); + + /** + * RTL3d - Multiple channels re-attached on reconnect + */ + it('RTL3d - multiple channels re-attached on reconnect', async function () { + const attachedChannels: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachedChannels.push(msg.channel); + 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, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const chanA = client.channels.get('test-RTL3d-multiA'); + const chanB = client.channels.get('test-RTL3d-multiB'); + await chanA.attach(); + await chanB.attach(); + + const attachCountBefore = attachedChannels.length; + + // Simulate disconnect + mock.active_connection!.simulate_disconnect(); + + // Wait for both to re-attach + await new Promise((resolve) => { + let count = 0; + const check = () => { + if (++count === 2) resolve(); + }; + chanA.once('attached', check); + chanB.once('attached', check); + }); + + expect(chanA.state).to.equal('attached'); + expect(chanB.state).to.equal('attached'); + + const newAttaches = attachedChannels.slice(attachCountBefore); + expect(newAttaches).to.include('test-RTL3d-multiA'); + expect(newAttaches).to.include('test-RTL3d-multiB'); + client.close(); + }); + + /** + * RTL3e - DISCONNECTED has no effect on ATTACHING channel + */ + it('RTL3e - DISCONNECTED does not affect attaching channel', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + } + }, + }); + 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-RTL3e-attaching'); + const attachFuture = channel.attach().catch(() => {}); + + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + + const channelStateChanges: any[] = []; + channel.on((change: any) => channelStateChanges.push(change)); + + // Simulate disconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Channel state must remain ATTACHING + expect(channel.state).to.equal('attaching'); + // No channel state change events should have been emitted + expect(channelStateChanges.length).to.equal(0); + }); + + /** + * RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED + */ + it('RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + } + if (msg.action === 7) { + // CLOSE + 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); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL3b-attaching'); + const attachFuture = channel.attach().catch(() => {}); + + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + + const channelStateChanges: any[] = []; + channel.on((change: any) => channelStateChanges.push(change)); + + // Close the connection + client.close(); + await new Promise((resolve) => client.connection.once('closed', resolve)); + + // The pending attach should fail + await attachFuture; + + expect(channel.state).to.equal('detached'); + const detachedChange = channelStateChanges.find((c: any) => c.current === 'detached'); + expect(detachedChange).to.not.be.undefined; + expect(detachedChange.previous).to.equal('attaching'); + }); + + /** + * RTL3a - FAILED connection transitions ATTACHING channel to FAILED + */ + it('RTL3a - FAILED connection transitions ATTACHING channel to FAILED', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + } + }, + }); + 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-RTL3a-attaching'); + const attachFuture = channel.attach().catch(() => {}); + + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + + const channelStateChanges: any[] = []; + channel.on((change: any) => channelStateChanges.push(change)); + + // Send fatal connection ERROR + mock.active_connection!.send_to_client({ + action: 9, // ERROR + error: { code: 40198, statusCode: 403, message: 'Account disabled' }, + }); + + await new Promise((resolve) => client.connection.once('failed', resolve)); + await attachFuture; + + expect(channel.state).to.equal('failed'); + const failedChange = channelStateChanges.find((c: any) => c.current === 'failed'); + expect(failedChange).to.not.be.undefined; + expect(failedChange.previous).to.equal('attaching'); + client.close(); + }); + + /** + * RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED + */ + it('RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED', async function () { + const clock = enableFakeTimers(); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}), + }); + installMockHttp(httpMock); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionDetails: { connectionStateTtl: 5000 }, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { clock.tick(0); await flushAsync(); } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL3c-attaching'); + channel.attach().catch(() => {}); + for (let i = 0; i < 10; i++) { clock.tick(0); await flushAsync(); } + expect(channel.state).to.equal('attaching'); + + const channelStateChanges: any[] = []; + channel.on((change: any) => channelStateChanges.push(change)); + + // Disconnect — all reconnection attempts will fail + mock.onConnectionAttempt = (conn) => conn.respond_with_refused(); + mock.active_connection!.simulate_disconnect(); + + // Advance time past connectionStateTtl to reach SUSPENDED + for (let i = 0; i < 15; i++) { + await clock.tickAsync(2000); + for (let j = 0; j < 10; j++) { clock.tick(0); await flushAsync(); } + if (client.connection.state === 'suspended') break; + } + + expect(client.connection.state).to.equal('suspended'); + expect(channel.state).to.equal('suspended'); + + const suspendedChange = channelStateChanges.find((c: any) => c.current === 'suspended'); + expect(suspendedChange).to.not.be.undefined; + expect(suspendedChange.previous).to.equal('attaching'); + client.close(); + }); + + /** + * RTL3d - CONNECTED connection re-attaches SUSPENDED channels + */ + it('RTL3d - CONNECTED connection re-attaches SUSPENDED channels', async function () { + const clock = enableFakeTimers(); + let attachCount = 0; + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, {}), + }); + installMockHttp(httpMock); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionDetails: { connectionStateTtl: 5000 }, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + attachCount++; + 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, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000, + suspendedRetryTimeout: 2000, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { clock.tick(0); await flushAsync(); } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL3d-suspended'); + await channel.attach(); + expect(attachCount).to.equal(1); + + // Disconnect — all reconnection attempts fail + mock.onConnectionAttempt = (conn) => conn.respond_with_refused(); + mock.active_connection!.simulate_disconnect(); + + // Advance to SUSPENDED + for (let i = 0; i < 15; i++) { + await clock.tickAsync(2000); + for (let j = 0; j < 10; j++) { clock.tick(0); await flushAsync(); } + if (client.connection.state === 'suspended') break; + } + expect(client.connection.state).to.equal('suspended'); + expect(channel.state).to.equal('suspended'); + + // Allow reconnection to succeed + mock.onConnectionAttempt = (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }; + + // Advance past suspendedRetryTimeout + for (let i = 0; i < 10; i++) { + await clock.tickAsync(2500); + for (let j = 0; j < 10; j++) { clock.tick(0); await flushAsync(); } + if (client.connection.state === 'connected') break; + } + expect(client.connection.state).to.equal('connected'); + + // Wait for channel to re-attach + for (let i = 0; i < 10; i++) { clock.tick(0); await flushAsync(); } + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.be.at.least(2); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_delta_decoding.test.ts b/test/uts/realtime/channels/channel_delta_decoding.test.ts new file mode 100644 index 000000000..afe6b8cd1 --- /dev/null +++ b/test/uts/realtime/channels/channel_delta_decoding.test.ts @@ -0,0 +1,761 @@ +/** + * UTS: Channel Delta Decoding Tests + * + * Spec points: RTL18, RTL18a, RTL18b, RTL18c, RTL19, RTL19a, RTL19b, RTL19c, + * RTL20, RTL21, PC3, PC3a + * Source: specification/uts/realtime/unit/channels/channel_delta_decoding.md + * + * Tests delta message decoding via the VCDiff plugin. In ably-js, the plugin + * is passed via `options.plugins.vcdiff` with a `decode(delta, base)` method. + * + * Mock VCDiff: The "delta" is just the target value itself (pass-through). + * Tests use encoding "utf-8/vcdiff" so the result is decoded to string. + * + * Protocol actions: CONNECTED=4, ATTACH=10, ATTACHED=11, MESSAGE=15 + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +const mockVcdiffPlugin = { + decode(delta: any, base: any): any { + return delta; + }, +}; + +function createRecordingPlugin() { + const calls: any[] = []; + return { + calls, + decode(delta: any, base: any): any { + calls.push({ delta: Buffer.from(delta), base: Buffer.from(base) }); + return delta; + }, + }; +} + +function createFailingPlugin() { + return { + decode(): never { + throw new Error('Simulated decode failure'); + }, + }; +} + +function setupConnectedClient(mock: MockWebSocket, plugin?: any) { + const opts: any = { + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }; + if (plugin) { + opts.plugins = { vcdiff: plugin }; + } + const client = new Ably.Realtime(opts); + trackClient(client); + return client; +} + +function createMockWithAutoAttach(channelName: string) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel }); + } + }, + }); + return mock; +} + +describe('uts/realtime/channels/channel_delta_decoding', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL21 - Messages in array decoded in ascending index order + * + * Multiple messages in a ProtocolMessage where later messages are deltas + * referencing earlier ones — works because processing is in array order. + */ + it('RTL21 - messages decoded in ascending index order', async function () { + const channelName = 'test-RTL21'; + const received: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + mock.active_connection!.send_to_client({ + action: 15, + channel: channelName, + id: 'serial:0', + messages: [ + { id: 'serial:0', data: 'first message', encoding: null }, + { id: 'serial:1', data: Buffer.from('second message').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'serial:0', format: 'vcdiff' } } }, + { id: 'serial:2', data: Buffer.from('third message').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'serial:1', format: 'vcdiff' } } }, + ], + }); + + await flushAsync(); + expect(received.length).to.equal(3); + expect(received[0].data).to.equal('first message'); + expect(received[1].data).to.equal('second message'); + expect(received[2].data).to.equal('third message'); + client.close(); + }); + + /** + * RTL19b - Non-delta message stores base payload + */ + it('RTL19b - non-delta then delta succeeds', async function () { + const channelName = 'test-RTL19b'; + const received: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', + messages: [{ id: 'msg-1:0', data: 'base payload', encoding: null }], + }); + await flushAsync(); + + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', data: Buffer.from('updated payload').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + expect(received.length).to.equal(2); + expect(received[0].data).to.equal('base payload'); + expect(received[1].data).to.equal('updated payload'); + client.close(); + }); + + /** + * RTL19c - Delta application result stored as new base payload (chained) + */ + it('RTL19c - chained deltas decode correctly', async function () { + const channelName = 'test-RTL19c'; + const received: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + // Message 1: non-delta + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', + messages: [{ id: 'msg-1:0', data: 'value-A', encoding: null }], + }); + await flushAsync(); + + // Message 2: delta from msg-1 + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', data: Buffer.from('value-B').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + // Message 3: delta from msg-2 (verifies base updated to value-B) + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-3:0', + messages: [{ + id: 'msg-3:0', data: Buffer.from('value-C').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'msg-2:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + expect(received.length).to.equal(3); + expect(received[0].data).to.equal('value-A'); + expect(received[1].data).to.equal('value-B'); + expect(received[2].data).to.equal('value-C'); + client.close(); + }); + + /** + * RTL20 - Last message ID updated after successful decode + */ + it('RTL20 - last message ID updated correctly', async function () { + const channelName = 'test-RTL20-id'; + const received: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + // ProtocolMessage with 2 messages — last ID should be serial:1 + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'serial:0', + messages: [ + { id: 'serial:0', data: 'first', encoding: null }, + { id: 'serial:1', data: 'second', encoding: null }, + ], + }); + await flushAsync(); + + // Delta referencing serial:1 (the last message) — should succeed + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', data: Buffer.from('third').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'serial:1', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + expect(received.length).to.equal(3); + expect(received[0].data).to.equal('first'); + expect(received[1].data).to.equal('second'); + expect(received[2].data).to.equal('third'); + client.close(); + }); + + /** + * RTL20 - Delta with mismatched base message ID triggers recovery + */ + it('RTL20 - mismatched base ID triggers recovery', async function () { + const channelName = 'test-RTL20-mismatch'; + const attachMessages: any[] = []; + const stateChanges: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + attachMessages.push(msg); + conn!.send_to_client({ action: 11, channel: msg.channel }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + // Establish base + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', channelSerial: 'serial-1', + messages: [{ id: 'msg-1:0', data: 'base payload', encoding: null }], + }); + await flushAsync(); + + const initialAttachCount = attachMessages.length; + channel.on((change: any) => stateChanges.push(change)); + + // Delta with wrong base ID + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', data: Buffer.from('delta-data').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'msg-999:0', format: 'vcdiff' } }, + }], + }); + + await new Promise((r) => { + if (channel.state === 'attaching') return r(); + channel.once('attaching', () => r()); + }); + + expect(attachMessages.length).to.be.greaterThan(initialAttachCount); + const recoveryAttach = attachMessages[attachMessages.length - 1]; + expect(recoveryAttach.channelSerial).to.equal('serial-1'); + + const attachingChange = stateChanges.find((c: any) => c.current === 'attaching'); + expect(attachingChange).to.not.be.undefined; + expect(attachingChange.reason.code).to.equal(40018); + client.close(); + }); + + /** + * PC3 - No vcdiff plugin causes FAILED state + */ + it('PC3 - no vcdiff plugin causes channel FAILED', async function () { + const channelName = 'test-PC3-no-plugin'; + const stateChanges: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + // No vcdiff plugin + const client = setupConnectedClient(mock); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.on((change: any) => stateChanges.push(change)); + await channel.attach(); + + stateChanges.length = 0; + + // Base message first (so lastPayload.messageId is set) + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-0:0', + messages: [{ id: 'msg-0:0', data: 'base', encoding: null }], + }); + await flushAsync(); + + // Delta message without plugin + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', + messages: [{ + id: 'msg-1:0', data: Buffer.from('some-delta').toString('base64'), encoding: 'vcdiff/base64', + extras: { delta: { from: 'msg-0:0', format: 'vcdiff' } }, + }], + }); + + await new Promise((r) => { + if (channel.state === 'failed') return r(); + channel.once('failed', () => r()); + }); + + expect(channel.state).to.equal('failed'); + expect(channel.errorReason!.code).to.equal(40019); + client.close(); + }); + + /** + * RTL18 - Decode failure triggers recovery (RTL18a, RTL18b, RTL18c) + */ + it('RTL18 - decode failure triggers recovery', async function () { + const channelName = 'test-RTL18-recovery'; + const received: any[] = []; + const attachMessages: any[] = []; + const stateChanges: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + attachMessages.push(msg); + conn!.send_to_client({ action: 11, channel: msg.channel }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, createFailingPlugin()); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + channel.on((change: any) => stateChanges.push(change)); + await channel.attach(); + + // Establish base with non-delta + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', channelSerial: 'serial-100', + messages: [{ id: 'msg-1:0', data: 'base payload', encoding: null }], + }); + await flushAsync(); + expect(received.length).to.equal(1); + + stateChanges.length = 0; + const initialAttachCount = attachMessages.length; + + // Delta message that will fail to decode + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', channelSerial: 'serial-200', + messages: [{ + id: 'msg-2:0', data: Buffer.from('fake-delta').toString('base64'), encoding: 'vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + + await new Promise((r) => { + if (channel.state === 'attaching') return r(); + channel.once('attaching', () => r()); + }); + + // RTL18b: failed message NOT delivered + expect(received.length).to.equal(1); + expect(received[0].data).to.equal('base payload'); + + // RTL18c: recovery ATTACH sent + expect(attachMessages.length).to.be.greaterThan(initialAttachCount); + const recoveryAttach = attachMessages[attachMessages.length - 1]; + expect(recoveryAttach.channelSerial).to.equal('serial-100'); + + // RTL18c: attaching state with error 40018 + const attachingChange = stateChanges.find((c: any) => c.current === 'attaching'); + expect(attachingChange).to.not.be.undefined; + expect(attachingChange.reason.code).to.equal(40018); + client.close(); + }); + + /** + * RTL18c - Recovery completes when server sends ATTACHED + */ + it('RTL18c - recovery completes and new messages work', async function () { + const channelName = 'test-RTL18c'; + const received: any[] = []; + let decodeAttempt = 0; + + const conditionalPlugin = { + decode(delta: any, base: any): any { + decodeAttempt++; + if (decodeAttempt === 1) throw new Error('Simulated failure'); + return delta; + }, + }; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, conditionalPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + // Base message + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', channelSerial: 'serial-1', + messages: [{ id: 'msg-1:0', data: 'original base', encoding: null }], + }); + await flushAsync(); + + // Delta that fails on first attempt → triggers recovery → ATTACHING → ATTACHED + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', channelSerial: 'serial-2', + messages: [{ + id: 'msg-2:0', data: Buffer.from('bad-delta').toString('base64'), encoding: 'vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + + // Wait for recovery: first attaching (recovery starts), then attached (recovery completes) + await new Promise((r) => { + if (channel.state === 'attaching') { + channel.once('attached', () => r()); + } else { + channel.once('attaching', () => { + channel.once('attached', () => r()); + }); + } + }); + + // Fresh message after recovery + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-3:0', channelSerial: 'serial-3', + messages: [{ id: 'msg-3:0', data: 'fresh after recovery', encoding: null }], + }); + await flushAsync(); + + expect(channel.state).to.equal('attached'); + expect(received[0].data).to.equal('original base'); + expect(received[received.length - 1].data).to.equal('fresh after recovery'); + client.close(); + }); + + /** + * RTL18 - Only one recovery in progress at a time + */ + it('RTL18 - only one recovery at a time', async function () { + const channelName = 'test-RTL18-single'; + const attachMessages: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + attachMessages.push(msg); + // Only respond to initial attach (first one) + if (attachMessages.length === 1) { + conn!.send_to_client({ action: 11, channel: msg.channel }); + } + // Don't respond to recovery attach — leave recovery in progress + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, createFailingPlugin()); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + const initialAttachCount = attachMessages.length; + + // Base message + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', channelSerial: 'serial-1', + messages: [{ id: 'msg-1:0', data: 'base', encoding: null }], + }); + await flushAsync(); + + // First failed delta → triggers recovery + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', data: Buffer.from('bad-1').toString('base64'), encoding: 'vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + await new Promise((r) => { + if (channel.state === 'attaching') return r(); + channel.once('attaching', () => r()); + }); + + // Second failed delta while recovery in progress + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-3:0', + messages: [{ + id: 'msg-3:0', data: Buffer.from('bad-2').toString('base64'), encoding: 'vcdiff/base64', + extras: { delta: { from: 'msg-2:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + // Only one recovery ATTACH was sent + const recoveryAttaches = attachMessages.length - initialAttachCount; + expect(recoveryAttaches).to.equal(1); + client.close(); + }); + + /** + * RTL19a - Base64 encoding step decoded before storing base payload + * + * When a non-delta message arrives with encoding containing a base64 step + * (e.g. "base64"), the SDK decodes the base64 before storing the base + * payload for future delta application. + */ + it('RTL19a - base64 decoded before storing base payload', async function () { + const channelName = 'test-RTL19a'; + const received: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + // Send a non-delta message with base64 encoding. + // The wire data is base64("Hello") = "SGVsbG8=" + // After decoding, subscriber sees a Buffer. The stored base payload + // should be the decoded binary, not the base64 string. + const baseBinary = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" + const baseAsBase64 = baseBinary.toString('base64'); // "SGVsbG8=" + + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', + messages: [{ + id: 'msg-1:0', + data: baseAsBase64, + encoding: 'base64', + }], + }); + await flushAsync(); + + // Now send a delta referencing the binary base payload. + // The mock vcdiff decoder is pass-through, so delta data = new value. + const newBinary = Buffer.from([0x57, 0x6f, 0x72, 0x6c, 0x64]); // "World" + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', + data: newBinary.toString('base64'), + encoding: 'vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + expect(received.length).to.equal(2); + // First message: base64 decoded to binary buffer + expect(Buffer.isBuffer(received[0].data) || received[0].data instanceof Uint8Array).to.be.true; + expect(Buffer.from(received[0].data).compare(baseBinary)).to.equal(0); + // Second message: delta decoded using binary base, delivered as binary + expect(Buffer.isBuffer(received[1].data) || received[1].data instanceof Uint8Array).to.be.true; + expect(Buffer.from(received[1].data).compare(newBinary)).to.equal(0); + client.close(); + }); + + /** + * RTL19b - JSON-encoded non-delta message stores wire-form base payload + * + * When a non-delta message has encoding: "json", the stored base payload + * 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. + */ + it('RTL19b - JSON-encoded non-delta stores wire-form base', async function () { + const channelName = 'test-RTL19b-json'; + const received: any[] = []; + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, mockVcdiffPlugin); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + // Send a non-delta message with JSON encoding. + // The wire data is a JSON string; after decoding, the subscriber sees an object. + // The base payload stored for delta decoding should be the JSON string, + // not the parsed object. + const jsonString = '{"foo":"bar","count":1}'; + + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', + messages: [{ + id: 'msg-1:0', + data: jsonString, + encoding: 'json', + }], + }); + await flushAsync(); + + // Send a delta referencing the JSON string base. + // The delta is computed against the JSON string, not the parsed object. + // The mock vcdiff decoder is pass-through, so delta data = new value. + const newJsonString = '{"foo":"baz","count":2}'; + + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', + data: Buffer.from(newJsonString).toString('base64'), + encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + expect(received.length).to.equal(2); + // First message: subscriber receives the parsed JSON object + expect(received[0].data).to.deep.equal({ foo: 'bar', count: 1 }); + // Second message: delta decoded against JSON string base, then utf-8 decoded + // to produce the new JSON string, which is delivered as-is (no json encoding + // step in the delta message's encoding) + expect(received[1].data).to.equal(newJsonString); + client.close(); + }); + + /** + * PC3, PC3a - VCDiff plugin decodes delta messages + */ + it('PC3 - vcdiff plugin called with correct arguments', async function () { + const channelName = 'test-PC3'; + const received: any[] = []; + const recording = createRecordingPlugin(); + + const mock = createMockWithAutoAttach(channelName); + installMockWebSocket(mock.constructorFn); + + const client = setupConnectedClient(mock, recording); + client.connect(); + await new Promise((r) => client.connection.once('connected', r)); + + const channel = client.channels.get(channelName); + channel.subscribe((msg: any) => received.push(msg)); + await channel.attach(); + + // Non-delta message (string base) + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-1:0', + messages: [{ id: 'msg-1:0', data: 'hello world', encoding: null }], + }); + await flushAsync(); + + // Delta message + mock.active_connection!.send_to_client({ + action: 15, channel: channelName, id: 'msg-2:0', + messages: [{ + id: 'msg-2:0', data: Buffer.from('goodbye world').toString('base64'), encoding: 'utf-8/vcdiff/base64', + extras: { delta: { from: 'msg-1:0', format: 'vcdiff' } }, + }], + }); + await flushAsync(); + + // PC3: decoder was called + expect(recording.calls.length).to.equal(1); + + // PC3a: base was UTF-8 encoded to binary + expect(recording.calls[0].base.toString('utf-8')).to.equal('hello world'); + + // Result delivered + expect(received[1].data).to.equal('goodbye world'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_detach.test.ts b/test/uts/realtime/channels/channel_detach.test.ts new file mode 100644 index 000000000..f71dcd71f --- /dev/null +++ b/test/uts/realtime/channels/channel_detach.test.ts @@ -0,0 +1,834 @@ +/** + * UTS: Channel Detach Tests + * + * Spec points: RTL5a, RTL5b, RTL5d, RTL5f, RTL5i, RTL5j, RTL5k, RTL5l + * Source: uts/test/realtime/unit/channels/channel_detach_test.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_detach', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL5a - Detach when initialized + */ + it('RTL5a - detach from initialized state', async function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL5a-init'); + expect(channel.state).to.equal('initialized'); + + await channel.detach(); + client.close(); + expect(channel.state).to.satisfy((s: string) => s === 'initialized' || s === 'detached'); + }); + + /** + * RTL5a - Detach when already detached is no-op + */ + it('RTL5a - detach when already detached is no-op', async function () { + let detachMessageCount = 0; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL5a-detached'); + await channel.attach(); + await channel.detach(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(1); + + // Second detach should be no-op + await channel.detach(); + client.close(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(1); + }); + + /** + * RTL5i - Concurrent detach while detaching waits for completion + */ + it('RTL5i - concurrent detach while detaching', async function () { + let detachMessageCount = 0; + let pendingDetachChannel: string | null = null; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + pendingDetachChannel = msg.channel; + // Don't respond — let test control timing + } + }, + }); + 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-RTL5i'); + await channel.attach(); + + // Start first detach + const detach1 = channel.detach(); + + // Wait for detaching state + await new Promise((resolve) => { + if (channel.state === 'detaching') return resolve(); + channel.once('detaching', () => resolve()); + }); + + // Start second detach while detaching + const detach2 = channel.detach(); + + // Now respond with DETACHED + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: pendingDetachChannel!, + }); + + await detach1; + await detach2; + + client.close(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(1); + }); + + /** + * RTL5b - Detach from failed state results in error + */ + it('RTL5b - detach from failed state errors', 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: 9, // ERROR + channel: msg.channel, + error: { + message: 'Channel denied', + code: 40160, + statusCode: 401, + }, + }); + } + }, + }); + 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-RTL5b'); + + // Attach fails → channel enters FAILED + try { + await channel.attach(); + } catch (err) { + // Expected + } + expect(channel.state).to.equal('failed'); + + // Detach from FAILED should throw + try { + await channel.detach(); + expect.fail('Expected detach to throw'); + } catch (err: any) { + expect(err).to.not.be.null; + expect(err.code).to.equal(90001); + } + client.close(); + expect(channel.state).to.equal('failed'); + }); + + /** + * RTL5j - Detach from suspended transitions to detached immediately + */ + it('RTL5j - detach from suspended is immediate', async function () { + let detachMessageCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + // Don't respond — let it timeout + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL5j'); + + // Start attach (will timeout) + const attachPromise = channel.attach().catch(() => {}); + + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past timeout + await clock.tickAsync(150); + await attachPromise; + + expect(channel.state).to.equal('suspended'); + + // Detach from suspended should be immediate + await channel.detach(); + client.close(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(0); // No DETACH message sent + }); + + /** + * RTL5d - Normal detach flow + */ + it('RTL5d - normal detach flow', async function () { + let capturedDetachMsg: any = null; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + capturedDetachMsg = msg; + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL5d'); + await channel.attach(); + + let stateDuringDetach: string | null = null; + channel.once('detaching', () => { + stateDuringDetach = channel.state; + }); + + await channel.detach(); + + client.close(); + expect(stateDuringDetach).to.equal('detaching'); + expect(channel.state).to.equal('detached'); + expect(capturedDetachMsg).to.not.be.null; + expect(capturedDetachMsg.action).to.equal(12); + expect(capturedDetachMsg.channel).to.equal('test-RTL5d'); + }); + + /** + * RTL5f - Detach timeout returns to previous state + */ + it('RTL5f - detach timeout returns to attached', 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, + }); + } + if (msg.action === 12) { + // DETACH + // Don't respond — simulate timeout + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL5f'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Start detach (will timeout) + let detachError: any = null; + const detachPromise = channel.detach().catch((err: any) => { + detachError = err; + }); + + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past timeout + await clock.tickAsync(150); + await detachPromise; + + // Should return to attached state + expect(channel.state).to.equal('attached'); + expect(detachError).to.not.be.null; + }); + + /** + * RTL5k - ATTACHED received while detaching sends new DETACH + */ + it('RTL5k - ATTACHED while detaching triggers new DETACH', async function () { + let detachMessageCount = 0; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + if (detachMessageCount === 1) { + // First DETACH: respond with ATTACHED (simulating race condition) + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } else { + // Second DETACH: respond normally + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + } + }, + }); + 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-RTL5k'); + await channel.attach(); + + await channel.detach(); + client.close(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(2); // Two DETACH messages sent + }); + + /** + * RTL5l - Detach when connection not connected transitions immediately + */ + it('RTL5l - detach when disconnected is immediate', async function () { + let detachMessageCount = 0; + let pendingConnection: any = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + pendingConnection = conn; + // Don't respond — hold in connecting state + }, + onMessageFromClient: (msg) => { + if (msg.action === 12) { + // DETACH + detachMessageCount++; + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => { + if (client.connection.state === 'connecting') return resolve(); + client.connection.once('connecting', () => resolve()); + }); + + const channel = client.channels.get('test-RTL5l'); + + // Start attach while connecting + const attachPromise = channel.attach(); + + await flushAsync(); + expect(channel.state).to.equal('attaching'); + + // Now detach — should transition immediately since not connected + await channel.detach(); + client.close(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(0); + }); + + /** + * RTL5l - Detach ATTACHED channel when connection disconnected + */ + it('RTL5l - detach attached channel when disconnected is immediate', async function () { + let detachMessageCount = 0; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + } + }, + }); + 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-RTL5l-attached'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Disconnect the transport + mock.onConnectionAttempt = (_conn) => { + // Don't respond — hold in connecting + }; + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Now detach while disconnected + detachMessageCount = 0; + await channel.detach(); + + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(0); // No DETACH message sent + client.close(); + }); + + /** + * RTL5 - Detach emits state change events + */ + it('RTL5 - detach emits state change events', 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, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL5-events'); + await channel.attach(); + + const stateChanges: any[] = []; + channel.on((change: any) => { + stateChanges.push(change); + }); + + await channel.detach(); + + client.close(); + expect(stateChanges.length).to.be.at.least(2); + expect(stateChanges[0].current).to.equal('detaching'); + expect(stateChanges[0].previous).to.equal('attached'); + expect(stateChanges[1].current).to.equal('detached'); + expect(stateChanges[1].previous).to.equal('detaching'); + }); + + /** + * RTL5i - Detach while attaching waits then detaches + * + * Calling detach while an attach is pending should wait for the attach + * to complete and then perform the detach. + */ + it('RTL5i - detach while attaching waits then detaches', async function () { + const messagesFromClient: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + messagesFromClient.push({ ...msg }); + if (msg.action === 10) { + // ATTACH — delay response + } else if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL5i-attaching'); + + // Start attach (don't await — ably-js will reject it when detach supersedes) + const attachFuture = channel.attach().catch(() => {}); + + // Wait for attaching state + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + + // Start detach while attaching — ably-js supersedes the attach + const detachFuture = channel.detach(); + + // Send ATTACHED response — attach completes on the wire + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL5i-attaching', + flags: 0, + }); + + // Wait for both operations + await attachFuture; + await detachFuture; + + expect(channel.state).to.equal('detached'); + // Should have: ATTACH, DETACH + const relevantMessages = messagesFromClient.filter((m) => m.action === 10 || m.action === 12); + expect(relevantMessages.length).to.equal(2); + expect(relevantMessages[0].action).to.equal(10); // ATTACH + expect(relevantMessages[1].action).to.equal(12); // DETACH + client.close(); + }); + + /** + * RTL5k - ATTACHED received while detached sends DETACH + */ + 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; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL5k-detached'); + await channel.attach(); + await channel.detach(); + expect(channel.state).to.equal('detached'); + expect(detachMessageCount).to.equal(1); + + // Server unexpectedly sends ATTACHED while detached + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL5k-detached', + flags: 0, + }); + + await flushAsync(); + + expect(detachMessageCount).to.equal(2); + expect(channel.state).to.equal('detached'); + client.close(); + }); + + /** + * RTL5 - Detach from ATTACHED while connection not connected + * + * Per RTL5l, if the connection state is anything other than CONNECTED and + * none of the preceding channel state conditions apply, the channel + * transitions immediately to DETACHED without sending a DETACH message. + * This test specifically covers the case where a channel is ATTACHED + * (not just ATTACHING) and connection drops to connecting. + */ + it('RTL5 - detach from attached when connection disconnected', async function () { + let detachMessageCount = 0; + + 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, + }); + } + if (msg.action === 12) { + // DETACH + detachMessageCount++; + } + }, + }); + 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-RTL5-disconnected'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Disconnect the connection (don't respond to reconnect) + mock.onConnectionAttempt = (_conn) => { + // Don't respond — hold in connecting + }; + mock.active_connection!.simulate_disconnect(); + + // Wait for disconnected state + await new Promise((resolve) => { + if (client.connection.state !== 'connected') return resolve(); + client.connection.once('disconnected', resolve); + }); + + // Detach while connection is not connected + await channel.detach(); + expect(channel.state).to.equal('detached'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_error.test.ts b/test/uts/realtime/channels/channel_error.test.ts new file mode 100644 index 000000000..73686dfa3 --- /dev/null +++ b/test/uts/realtime/channels/channel_error.test.ts @@ -0,0 +1,360 @@ +/** + * UTS: Channel Error Tests + * + * Spec points: RTL14 + * Source: uts/test/realtime/unit/channels/channel_error_test.md + * + * Tests channel-scoped ERROR protocol messages: transitions to FAILED, + * errorReason population, isolation between channels. + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, enableFakeTimers, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_error', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL14 - Channel ERROR transitions ATTACHED channel to FAILED + */ + it('RTL14 - channel ERROR on attached channel', 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-RTL14-attached'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // Server sends channel-scoped ERROR + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL14-attached', + error: { + message: 'Channel error', + code: 40160, + statusCode: 401, + }, + }); + + await new Promise((resolve) => channel.once('failed', resolve)); + + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(40160); + expect(stateChanges.some((c: any) => c.current === 'failed')).to.be.true; + // Connection should remain connected + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTL14 - Channel ERROR transitions ATTACHING channel to FAILED + */ + it('RTL14 - channel ERROR on attaching channel', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + // Respond with channel-scoped ERROR instead of ATTACHED + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: msg.channel, + error: { + message: 'Channel denied', + code: 40160, + statusCode: 401, + }, + }); + } + }, + }); + 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-RTL14-attaching'); + + try { + await channel.attach(); + expect.fail('Expected attach to fail'); + } catch (err: any) { + expect(err.code).to.equal(40160); + } + + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(40160); + // Connection should remain connected + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTL14 - Channel ERROR does not affect other channels + */ + it('RTL14 - channel ERROR isolated to target channel', 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 channelA = client.channels.get('test-RTL14-chanA'); + const channelB = client.channels.get('test-RTL14-chanB'); + await channelA.attach(); + await channelB.attach(); + + // Send ERROR only for channel A + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL14-chanA', + error: { + message: 'Channel A error', + code: 40160, + statusCode: 401, + }, + }); + + await new Promise((resolve) => channelA.once('failed', resolve)); + + expect(channelA.state).to.equal('failed'); + expect(channelA.errorReason!.code).to.equal(40160); + // Channel B should be unaffected + expect(channelB.state).to.equal('attached'); + // Connection should remain connected + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTL14 - Channel ERROR completes pending detach with error + */ + it('RTL14 - channel ERROR during detach', 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, + }); + } + if (msg.action === 12) { + // DETACH + // Respond with ERROR instead of DETACHED + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: msg.channel, + error: { + message: 'Detach denied', + code: 90198, + statusCode: 500, + }, + }); + } + }, + }); + 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-RTL14-detach-error'); + await channel.attach(); + + try { + await channel.detach(); + expect.fail('Expected detach to fail'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + // Channel should be FAILED (not DETACHED) + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason!.code).to.equal(90198); + // Connection should remain connected + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTL14 - Channel ERROR cancels pending timers + * + * When a channel ERROR is received while a channel retry timer is pending + * (channel in SUSPENDED state), the timer should be cancelled and the + * channel should remain in FAILED state without retrying. + */ + it('RTL14 - channel ERROR cancels pending retry timer', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + // Don't respond to subsequent attaches (timeout -> SUSPENDED) + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 200, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL14-timers'); + await channel.attach(); + expect(attachCount).to.equal(1); + + // Trigger server-initiated DETACHED -> reattach -> timeout -> SUSPENDED + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: 'test-RTL14-timers', + error: { code: 90198, statusCode: 500, message: 'Detach' }, + }); + + // Pump and advance to get to SUSPENDED + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(150); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('suspended'); + + // Channel retry timer is now pending (channelRetryTimeout = 200ms) + // Send ERROR before the retry fires + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL14-timers', + error: { code: 40160, statusCode: 401, message: 'Not permitted' }, + }); + + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('failed'); + + const attachCountAfterError = attachCount; + + // Advance time well past the channelRetryTimeout + await clock.tickAsync(500); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + // Channel remains FAILED — no retry was attempted + expect(channel.state).to.equal('failed'); + expect(attachCount).to.equal(attachCountAfterError); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_get_message.test.ts b/test/uts/realtime/channels/channel_get_message.test.ts new file mode 100644 index 000000000..3b9ce368b --- /dev/null +++ b/test/uts/realtime/channels/channel_get_message.test.ts @@ -0,0 +1,78 @@ +/** + * UTS: Channel getMessage Tests + * + * Spec points: RTL28 + * Source: uts/test/realtime/unit/channels/channel_get_message_test.md + * + * Tests that RealtimeChannel.getMessage() delegates to the REST endpoint. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL28 - getMessage delegates to REST endpoint + */ + it('RTL28 - getMessage calls REST /messages/{serial}', 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 httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with( + 200, + { + name: 'test-msg', + data: 'hello', + serial: 'msg-serial-123', + }, + { 'content-type': 'application/json' }, + ); + }, + }); + installMockHttp(httpMock); + + 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-RTL28'); + + const result = await channel.getMessage('msg-serial-123'); + + client.close(); + // Verify REST endpoint was called with the serial + const req = httpMock.captured_requests.find((r: any) => r.path.includes('msg-serial-123')); + expect(req).to.not.be.undefined; + expect(req!.method.toUpperCase()).to.equal('GET'); + expect(result.name).to.equal('test-msg'); + }); +}); diff --git a/test/uts/realtime/channels/channel_history.test.ts b/test/uts/realtime/channels/channel_history.test.ts new file mode 100644 index 000000000..aac7f83f4 --- /dev/null +++ b/test/uts/realtime/channels/channel_history.test.ts @@ -0,0 +1,186 @@ +/** + * UTS: Channel History Tests + * + * Spec points: RTL10a, RTL10b, RTL10c + * Source: uts/test/realtime/unit/channels/channel_history_test.md + * + * Tests RealtimeChannel.history() — delegates to REST, with untilAttach support. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL10a, RTL10c - RealtimeChannel#history supports all RestChannel#history params + * + * RealtimeChannel#history uses the same underlying REST endpoint as + * RestChannel#history. It supports start, end, direction, limit params + * and returns a PaginatedResult containing Message objects. + */ + it('RTL10a - history supports REST params and returns PaginatedResult', async function () { + const captured: any[] = []; + 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, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(200, [ + { id: '1', name: 'event1', data: 'hello' }, + { id: '2', name: 'event2', data: 'world' }, + ], { 'content-type': 'application/json' }); + }, + }); + installMockHttp(httpMock); + + 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-RTL10a'); + await channel.attach(); + + const result = await channel.history({ start: 1000, end: 2000, direction: 'backwards', limit: 50 }); + + // RTL10c: returns PaginatedResult with Message objects + expect(result.items).to.have.length(2); + expect(result.items[0].name).to.equal('event1'); + expect(result.items[0].data).to.equal('hello'); + expect(result.items[1].name).to.equal('event2'); + expect(result.items[1].data).to.equal('world'); + + // RTL10a: REST params are passed through to the HTTP request + expect(captured.length).to.be.greaterThan(0); + const historyReq = captured.find((r: any) => r.path.includes('/history') || r.path.includes('test-RTL10a')); + expect(historyReq).to.not.be.undefined; + const params = historyReq!.url.searchParams; + expect(params.get('start')).to.equal('1000'); + expect(params.get('end')).to.equal('2000'); + expect(params.get('direction')).to.equal('backwards'); + expect(params.get('limit')).to.equal('50'); + + client.close(); + }); + + /** + * RTL10b - untilAttach adds fromSerial query parameter + */ + it('RTL10b - untilAttach adds from_serial param', 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, + channelSerial: 'attach-serial-abc', + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + // Return empty paginated result + req.respond_with(200, [], { 'content-type': 'application/json' }); + }, + }); + installMockHttp(httpMock); + + 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-RTL10b'); + await channel.attach(); + expect(channel.properties.attachSerial).to.equal('attach-serial-abc'); + + await channel.history({ untilAttach: true }); + + client.close(); + // Check that the HTTP request included from_serial + const historyReq = httpMock.captured_requests.find( + (r: any) => r.path.includes('/history') || r.path.includes('test-RTL10b'), + ); + expect(historyReq).to.not.be.undefined; + // from_serial should be in query params + const urlParams = historyReq!.url.searchParams; + expect(urlParams.get('fromSerial') || urlParams.get('from_serial')).to.equal('attach-serial-abc'); + }); + + /** + * RTL10b - untilAttach errors when not attached + */ + it('RTL10b - untilAttach throws when not attached', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + 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-RTL10b-error'); + expect(channel.state).to.equal('initialized'); + + try { + await channel.history({ untilAttach: true }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_message_versions.test.ts b/test/uts/realtime/channels/channel_message_versions.test.ts new file mode 100644 index 000000000..fdf7eda90 --- /dev/null +++ b/test/uts/realtime/channels/channel_message_versions.test.ts @@ -0,0 +1,78 @@ +/** + * UTS: Channel getMessageVersions Tests + * + * Spec points: RTL31 + * Source: uts/test/realtime/unit/channels/channel_message_versions_test.md + * + * Tests that RealtimeChannel.getMessageVersions() delegates to the REST endpoint. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL31 - getMessageVersions delegates to REST endpoint + */ + it('RTL31 - getMessageVersions calls REST /messages/{serial}/versions', 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 httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with( + 200, + [ + { name: 'msg', data: 'v1', serial: 'msg-serial-abc' }, + { name: 'msg', data: 'v2', serial: 'msg-serial-abc' }, + ], + { 'content-type': 'application/json' }, + ); + }, + }); + installMockHttp(httpMock); + + 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-RTL31'); + + const result = await channel.getMessageVersions('msg-serial-abc'); + + client.close(); + // Verify REST endpoint was called with serial/versions path + const req = httpMock.captured_requests.find( + (r: any) => r.path.includes('msg-serial-abc') && r.path.includes('versions'), + ); + expect(req).to.not.be.undefined; + expect(req!.method.toUpperCase()).to.equal('GET'); + }); +}); diff --git a/test/uts/realtime/channels/channel_options.test.ts b/test/uts/realtime/channels/channel_options.test.ts new file mode 100644 index 000000000..ac5cf8c95 --- /dev/null +++ b/test/uts/realtime/channels/channel_options.test.ts @@ -0,0 +1,480 @@ +/** + * UTS: Channel Options Tests + * + * Spec points: TB2, TB2c, TB2d, TB3, TB4, RTS3b, RTS3c, RTS3c1, + * RTL16, RTL16a, RTS5, RTS5a, RTS5a1, RTS5a2, DO2a + * Source: uts/test/realtime/unit/channels/channel_options.md + * + * Tests ChannelOptions attributes, setOptions, getDerived, and option + * propagation through channels.get(). + * + * Deviation: TB3 (withCipherKey) — ably-js uses { cipher: { key } } option, + * not a static constructor. + * Deviation: RTS3c1 — ably-js channels.get() throws error 40000 when options + * would cause reattachment (rather than silently re-attaching). + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../helpers'; + +describe('uts/realtime/channels/channel_options', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * TB2 - ChannelOptions defaults + */ + it('TB2 - default channel options', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-TB2'); + const opts = channel.channelOptions; + expect(opts).to.not.be.null; + // params and modes should be absent or empty by default + expect(opts.params).to.satisfy((p: any) => !p || Object.keys(p).length === 0); + expect(opts.modes).to.satisfy((m: any) => !m || m.length === 0); + client.close(); + }); + + /** + * TB2c - ChannelOptions with params + */ + it('TB2c - channel options with params', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-TB2c', { + params: { rewind: '1', delta: 'vcdiff' }, + }); + + expect(channel.channelOptions.params).to.deep.include({ rewind: '1', delta: 'vcdiff' }); + client.close(); + }); + + /** + * TB2d - ChannelOptions with modes + */ + it('TB2d - channel options with modes', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-TB2d', { + modes: ['PUBLISH', 'SUBSCRIBE'], + }); + + expect(channel.channelOptions.modes).to.include('PUBLISH'); + expect(channel.channelOptions.modes).to.include('SUBSCRIBE'); + expect(channel.channelOptions.modes).to.have.length(2); + client.close(); + }); + + /** + * TB4 - attachOnSubscribe defaults to true + */ + it('TB4 - attachOnSubscribe default', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel1 = client.channels.get('test-TB4-default'); + // attachOnSubscribe is not explicitly stored in channelOptions; it defaults to true + // Check via the option or the absence of a false override + expect(channel1.channelOptions.attachOnSubscribe).to.not.equal(false); + + const channel2 = client.channels.get('test-TB4-false', { + attachOnSubscribe: false, + }); + expect(channel2.channelOptions.attachOnSubscribe).to.equal(false); + client.close(); + }); + + /** + * RTS3b - Options set on new channel via channels.get() + */ + it('RTS3b - options set on new channel', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTS3b', { + params: { rewind: '1' }, + modes: ['SUBSCRIBE'], + }); + + expect(channel.channelOptions.params).to.deep.include({ rewind: '1' }); + expect(channel.channelOptions.modes).to.include('SUBSCRIBE'); + client.close(); + }); + + /** + * RTS3c - Options updated on existing channel (when no reattach needed) + */ + it('RTS3c - options updated on existing channel', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Create channel with no special options + const channel = client.channels.get('test-RTS3c'); + + // Get same channel with new options that don't require reattach + // (channel is in 'initialized' state, so params/modes change is OK) + const sameChannel = client.channels.get('test-RTS3c', { + params: { rewind: '1' }, + }); + + expect(sameChannel).to.equal(channel); + expect(channel.channelOptions.params).to.deep.include({ rewind: '1' }); + client.close(); + }); + + /** + * RTS3c1 - Error if options would trigger reattachment on attached channel + */ + it('RTS3c1 - error when options change on attached channel', 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-RTS3c1'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Changing params on an attached channel via get() should throw + try { + client.channels.get('test-RTS3c1', { params: { rewind: '1' } }); + expect.fail('Expected get() to throw'); + } catch (err: any) { + expect(err.code).to.equal(40000); + } + client.close(); + }); + + /** + * RTL16 - setOptions updates channel options + */ + it('RTL16 - setOptions updates channel options', async function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL16'); + + // setOptions on an unattached channel should resolve immediately + await channel.setOptions({ + params: { delta: 'vcdiff' }, + attachOnSubscribe: false, + }); + + expect(channel.channelOptions.params).to.deep.include({ delta: 'vcdiff' }); + expect(channel.channelOptions.attachOnSubscribe).to.equal(false); + client.close(); + }); + + /** + * RTL16a - setOptions triggers reattachment when attached + * + * UTS spec error: The UTS spec asserts a state transition through 'attaching' + * during setOptions reattach. However, the features spec (RTL16a) only says + * "sends an ATTACH message...indicates success once the server has replied + * with an ATTACHED" — it does NOT require a state machine transition. ably-js + * stays in 'attached' during the reattach (deliberate: avoids RTL17 message + * rejection). Test verifies attachCount instead of state transitions. + */ + it('RTL16a - setOptions triggers reattachment when attached', async function () { + let attachCount = 0; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + 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-RTL16a'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(1); + + // setOptions with new params should send a new ATTACH message + await channel.setOptions({ + params: { rewind: '1' }, + }); + + expect(channel.state).to.equal('attached'); + expect(channel.channelOptions.params).to.deep.include({ rewind: '1' }); + // A second ATTACH was sent for the reattach + expect(attachCount).to.equal(2); + client.close(); + }); + + /** + * RTS5a - getDerived creates derived channel with filter + */ + it('RTS5a - getDerived creates derived channel', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.getDerived('base-channel', { + filter: "name == 'foo'", + }); + + expect(channel.name).to.match(/^\[filter=/); + expect(channel.name).to.include('base-channel'); + client.close(); + }); + + /** + * RTS5a1 - Derived channel filter is base64 encoded + */ + it('RTS5a1 - derived channel filter is base64 encoded', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const filter = "name == 'test'"; + const channel = client.channels.getDerived('test-channel', { filter }); + + // Base64 encode the filter + const expectedEncoded = Buffer.from(filter).toString('base64'); + expect(channel.name).to.equal(`[filter=${expectedEncoded}]test-channel`); + client.close(); + }); + + /** + * RTS5 - getDerived with options sets them on channel + */ + it('RTS5 - getDerived with channel options', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.getDerived( + 'test-RTS5', + { filter: 'true' }, + { modes: ['SUBSCRIBE'], attachOnSubscribe: false }, + ); + + expect(channel.channelOptions.modes).to.include('SUBSCRIBE'); + expect(channel.channelOptions.attachOnSubscribe).to.equal(false); + client.close(); + }); + + /** + * DO2a - DeriveOptions filter attribute + */ + it('DO2a - DeriveOptions filter attribute', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const filter = "name == 'event' && data.count > 10"; + const channel = client.channels.getDerived('test-DO2a', { filter }); + + // Verify the filter was encoded into the channel name + const expectedEncoded = Buffer.from(filter).toString('base64'); + expect(channel.name).to.include(`filter=${expectedEncoded}`); + client.close(); + }); + + /** + * TB3 - withCipherKey constructor + * + * Deviation: ably-js uses { cipher: { key } } option rather than a + * static withCipherKey constructor. This test verifies that providing a + * cipher key through the ably-js pattern sets up cipher params. + */ + it('TB3 - cipher key via channel options', async function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // 256-bit key as base64 + const key = 'MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE='; + const channel = client.channels.get('test-TB3', { + cipher: { key }, + }); + + const opts = channel.channelOptions; + expect(opts).to.not.be.null; + // The cipher option should have been processed + expect(opts.cipher).to.not.be.null; + expect(opts.cipher).to.not.be.undefined; + client.close(); + }); + + /** + * RTS3c1 - Error if modes change on attaching channel + * + * Changing modes on a channel that is in the attaching state should + * throw error code 40000. + */ + it('RTS3c1 - error when modes change on attaching channel', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond immediately to keep in attaching state + } + }, + }); + 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-RTS3c1-attaching'); + + // Start attach but don't await (mock won't respond) + channel.attach(); + + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + expect(channel.state).to.equal('attaching'); + + // Changing modes on an attaching channel via get() should throw + try { + client.channels.get('test-RTS3c1-attaching', { modes: ['SUBSCRIBE'] }); + expect.fail('Expected get() to throw'); + } catch (err: any) { + expect(err.code).to.equal(40000); + } + client.close(); + }); + + /** + * RTS5a2 - Derived channel with params included in name + */ + it('RTS5a2 - derived channel with params in name', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.getDerived( + 'test-RTS5a2', + { filter: "type == 'message'" }, + { params: { rewind: '1', delta: 'vcdiff' } }, + ); + + // Channel name should end with base name + expect(channel.name).to.match(/\]test-RTS5a2$/); + // Extract the qualifier (everything between [ and ]) + const match = channel.name.match(/^\[(.+)\]/); + expect(match).to.not.be.null; + const qualifier = match![1]; + + // Verify filter is present in qualifier + expect(qualifier).to.match(/^filter=/); + + // ably-js puts params in channel options (sent via ATTACH), not in the + // channel name qualifier. Verify params are set on the channel options. + expect((channel as any).channelOptions.params).to.deep.include({ + rewind: '1', + delta: 'vcdiff', + }); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_properties.test.ts b/test/uts/realtime/channels/channel_properties.test.ts new file mode 100644 index 000000000..f565e78af --- /dev/null +++ b/test/uts/realtime/channels/channel_properties.test.ts @@ -0,0 +1,506 @@ +/** + * UTS: Channel Properties Tests + * + * Spec points: RTL15a, RTL15b, RTL15b1 + * Source: uts/test/realtime/unit/channels/channel_properties_test.md + * + * Tests channel properties: attachSerial and channelSerial tracking, + * update from protocol messages, and clearing on state transitions. + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_properties', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL15a - attachSerial updated from ATTACHED message + */ + it('RTL15a - attachSerial from ATTACHED', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: `attach-serial-${attachCount}`, + flags: 0, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL15a'); + // Before connect — attachSerial should be null + expect(channel.properties.attachSerial).to.satisfy((v: any) => !v); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + await channel.attach(); + expect(channel.properties.attachSerial).to.equal('attach-serial-1'); + + await channel.detach(); + await channel.attach(); + expect(channel.properties.attachSerial).to.equal('attach-serial-2'); + client.close(); + }); + + /** + * RTL15a - attachSerial updated on server-initiated reattach + */ + it('RTL15a - attachSerial updated on additional ATTACHED', 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, + channelSerial: 'initial-serial', + 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-RTL15a-update'); + await channel.attach(); + expect(channel.properties.attachSerial).to.equal('initial-serial'); + + // Server sends unsolicited ATTACHED with new serial + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL15a-update', + channelSerial: 'updated-serial', + flags: 0, + }); + await flushAsync(); + + expect(channel.properties.attachSerial).to.equal('updated-serial'); + client.close(); + }); + + /** + * RTL15b - channelSerial updated from ATTACHED message + */ + it('RTL15b - channelSerial from ATTACHED', 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, + channelSerial: 'serial-001', + flags: 0, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL15b'); + expect(channel.properties.channelSerial).to.satisfy((v: any) => !v); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + client.close(); + }); + + /** + * RTL15b - channelSerial updated from MESSAGE and PRESENCE actions + */ + it('RTL15b - channelSerial updated from MESSAGE', 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, + channelSerial: 'serial-001', + 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-RTL15b-msg', { attachOnSubscribe: false }); + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + + // MESSAGE with channelSerial updates it + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL15b-msg', + channelSerial: 'serial-002', + messages: [{ name: 'test', data: 'data' }], + }); + await flushAsync(); + expect(channel.properties.channelSerial).to.equal('serial-002'); + client.close(); + }); + + /** + * RTL15b1 - channelSerial cleared on DETACHED state + */ + it('RTL15b1 - channelSerial cleared on detach', 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, + channelSerial: 'serial-001', + flags: 0, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL15b1-detach'); + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + + await channel.detach(); + expect(channel.state).to.equal('detached'); + expect(channel.properties.channelSerial).to.satisfy((v: any) => !v); + client.close(); + }); + + /** + * RTL15b1 - channelSerial cleared on FAILED state + */ + it('RTL15b1 - channelSerial cleared on failed', 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, + channelSerial: 'serial-001', + 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-RTL15b1-failed'); + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + + // Send channel ERROR → FAILED + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL15b1-failed', + error: { message: 'Error', code: 90001, statusCode: 500 }, + }); + await new Promise((resolve) => channel.once('failed', resolve)); + + expect(channel.state).to.equal('failed'); + expect(channel.properties.channelSerial).to.satisfy((v: any) => !v); + client.close(); + }); + + /** + * RTL15b1 - channelSerial cleared on SUSPENDED state + */ + it('RTL15b1 - channelSerial cleared on suspended', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: 'serial-001', + flags: 0, + }); + } + // Don't respond to second ATTACH — let it timeout + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL15b1-suspended'); + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + + // Server sends DETACHED (triggers reattach attempt) + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: 'test-RTL15b1-suspended', + error: { message: 'Server detach', code: 90001, statusCode: 500 }, + }); + + // Pump and advance past timeout to reach suspended + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(150); + + expect(channel.state).to.equal('suspended'); + expect(channel.properties.channelSerial).to.satisfy((v: any) => !v); + client.close(); + }); + + /** + * RTL15b - channelSerial not updated when field is not populated + * + * Receiving a MESSAGE without a channelSerial should not clear or change + * the existing channelSerial. + */ + it('RTL15b - channelSerial unchanged when not in message', 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, + channelSerial: 'serial-001', + 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-RTL15b-noupdate', { attachOnSubscribe: false }); + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + + // Server sends MESSAGE without channelSerial + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL15b-noupdate', + messages: [{ name: 'event', data: 'data' }], + }); + + await flushAsync(); + + // channelSerial should remain unchanged + expect(channel.properties.channelSerial).to.equal('serial-001'); + client.close(); + }); + + /** + * RTL15b - channelSerial not updated from irrelevant actions + * + * Receiving a protocol message with a different action (e.g. DETACHED) + * should not update channelSerial even if the message contains a + * channelSerial field. A server-initiated DETACHED triggers reattach + * (RTL13a), so we verify the final channelSerial comes from the new + * ATTACHED, not from the DETACHED message. + */ + it('RTL15b - channelSerial not from irrelevant actions', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + channelSerial: 'serial-001', + 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-RTL15b-irrelevant'); + await channel.attach(); + expect(channel.properties.channelSerial).to.equal('serial-001'); + + // Server sends DETACHED with a channelSerial field (triggers RTL13a reattach) + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: 'test-RTL15b-irrelevant', + channelSerial: 'serial-should-not-apply', + error: { code: 90198, statusCode: 500, message: 'Detached' }, + }); + + // Wait for the reattach to complete + await new Promise((resolve) => { + const check = () => { + if (channel.state === 'attached' && attachCount >= 2) return resolve(); + channel.once('attached', check); + }; + check(); + }); + + // channelSerial should be from the new ATTACHED, not from the DETACHED + expect(attachCount).to.equal(2); + expect(channel.properties.channelSerial).to.equal('serial-001'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_publish.test.ts b/test/uts/realtime/channels/channel_publish.test.ts new file mode 100644 index 000000000..aaac63eda --- /dev/null +++ b/test/uts/realtime/channels/channel_publish.test.ts @@ -0,0 +1,1750 @@ +/** + * UTS: Channel Publish Tests + * + * Spec points: RTL6, RTL6c1, RTL6c2, RTL6c4, RTL6c5, RTL6i1, RTL6i2, RTL6i3, + * RTL6j, RTN7d, RTN7e, RTN19a, RTN19a2, RTN19b + * Source: uts/test/realtime/unit/channels/channel_publish_test.md + * + * Tests message publishing: single/array/Message object, immediate/queued + * delivery, ACK/NACK handling, PublishResult, state validation, queueMessages, + * and transport resume retransmission. + */ + +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'; + +describe('uts/realtime/channels/channel_publish', function () { + afterEach(function () { + restoreAll(); + }); + + // Helper: standard mock that auto-connects and auto-attaches + function setupMock(opts?: { + onMessage?: (msg: any, conn: PendingWSConnection | undefined) => void; + onConnect?: (conn: PendingWSConnection) => void; + }) { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + if (opts?.onConnect) { + opts.onConnect(conn); + } else { + conn.respond_with_connected(); + } + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + if (msg.action === 15) { + // MESSAGE + captured.push(msg); + } + if (opts?.onMessage) { + opts.onMessage(msg, conn); + } + }, + }); + return { mock, captured }; + } + + /** + * RTL6i1 - Publish single message by name and data + */ + it('RTL6i1 - publish single message by name and data', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['serial-1'] }], + }); + } + }, + }); + 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-RTL6i1', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.publish('greeting', 'hello'); + + expect(captured.length).to.equal(1); + expect(captured[0].messages.length).to.equal(1); + expect(captured[0].messages[0].name).to.equal('greeting'); + expect(captured[0].messages[0].data).to.equal('hello'); + client.close(); + }); + + /** + * RTL6i2 - Publish array of Message objects + */ + it('RTL6i2 - publish array of messages in single ProtocolMessage', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1', 's2', 's3'] }], + }); + } + }, + }); + 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-RTL6i2', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.publish([ + { name: 'msg1', data: 'one' }, + { name: 'msg2', data: 'two' }, + { name: 'msg3', data: 'three' }, + ]); + + expect(captured.length).to.equal(1); + expect(captured[0].messages.length).to.equal(3); + expect(captured[0].messages[0].name).to.equal('msg1'); + expect(captured[0].messages[1].name).to.equal('msg2'); + expect(captured[0].messages[2].name).to.equal('msg3'); + client.close(); + }); + + /** + * RTL6i3 - Null fields omitted from JSON wire encoding + * + * 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" }" + */ + 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[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + onTextDataFrame: (raw) => rawFrames.push(raw), + }); + 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-RTL6i3', { attachOnSubscribe: false }); + await channel.attach(); + + // Publish name-only (no data) + rawFrames.length = 0; + await channel.publish('name-only', undefined); + const nameOnlyFrame = rawFrames.find((f) => f.includes('"name-only"')); + expect(nameOnlyFrame).to.exist; + const nameOnlyMsg = JSON.parse(nameOnlyFrame!).messages[0]; + expect(nameOnlyMsg.name).to.equal('name-only'); + expect('data' in nameOnlyMsg).to.be.false; + + // Publish data-only (no name) + rawFrames.length = 0; + await channel.publish(null as any, 'data-only'); + const dataOnlyFrame = rawFrames.find((f) => f.includes('"data-only"')); + expect(dataOnlyFrame).to.exist; + const dataOnlyMsg = JSON.parse(dataOnlyFrame!).messages[0]; + expect(dataOnlyMsg.data).to.equal('data-only'); + expect('name' in dataOnlyMsg).to.be.false; + }); + + /** + * RTL6i1 - Publish Message object + */ + it('RTL6i1 - publish Message object', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + 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-RTL6i1-obj', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.publish({ name: 'event', data: 'payload' }); + + expect(captured.length).to.equal(1); + expect(captured[0].messages[0].name).to.equal('event'); + expect(captured[0].messages[0].data).to.equal('payload'); + client.close(); + }); + + /** + * RTL6c1 - Publish immediately when CONNECTED and channel ATTACHED + */ + it('RTL6c1 - publish immediately when connected and attached', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + 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-RTL6c1-attached', { attachOnSubscribe: false }); + await channel.attach(); + expect(client.connection.state).to.equal('connected'); + expect(channel.state).to.equal('attached'); + + const result = await channel.publish('msg', 'data'); + + expect(captured.length).to.equal(1); + // Message was sent immediately (ACK already received) + expect(result).to.have.property('serials'); + client.close(); + }); + + /** + * RTL6c1 - Publish immediately when CONNECTED and channel INITIALIZED + */ + it('RTL6c1 - publish immediately when connected and channel initialized', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + 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-RTL6c1-init', { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + await channel.publish('msg', 'data'); + + // Message was sent immediately (connection CONNECTED) + expect(captured.length).to.equal(1); + // Channel should remain initialized — no implicit attach (RTL6c5) + expect(channel.state).to.equal('initialized'); + client.close(); + }); + + /** + * RTL6c5 - Publish does not trigger implicit attach + */ + it('RTL6c5 - publish does not trigger implicit attach', async function () { + let attachCount = 0; + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 10) attachCount++; + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + 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-RTL6c5', { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + await channel.publish('msg', 'data'); + await flushAsync(); + + expect(channel.state).to.equal('initialized'); + expect(attachCount).to.equal(0); + client.close(); + }); + + /** + * RTL6c2 - Publish queued when connection is CONNECTING + */ + it('RTL6c2 - publish queued when connecting', async function () { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + // Delay connection — don't respond yet + mock.active_connection = conn; + setImmediate(() => conn.respond_with_connected()); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + captured.push(msg); + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + // Connection is CONNECTING now + expect(client.connection.state).to.equal('connecting'); + + const channel = client.channels.get('test-RTL6c2-connecting', { attachOnSubscribe: false }); + + // Publish while connecting — should be queued + const publishPromise = channel.publish('queued', 'data'); + + // Not yet sent + expect(captured.length).to.equal(0); + + // Wait for publish to complete (will happen after connection) + await publishPromise; + + expect(captured.length).to.equal(1); + expect(captured[0].messages[0].name).to.equal('queued'); + client.close(); + }); + + /** + * RTL6c2 - Publish queued when connection is INITIALIZED + */ + it('RTL6c2 - publish queued when initialized', async function () { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + captured.push(msg); + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Connection is INITIALIZED — not yet connected + expect(client.connection.state).to.equal('initialized'); + + const channel = client.channels.get('test-RTL6c2-init', { attachOnSubscribe: false }); + + // Publish before connect — should be queued + const publishPromise = channel.publish('before-connect', 'data'); + expect(captured.length).to.equal(0); + + // Now connect + client.connect(); + await publishPromise; + + expect(captured.length).to.equal(1); + expect(captured[0].messages[0].name).to.equal('before-connect'); + client.close(); + }); + + /** + * RTL6c2 - Publish queued when connection is DISCONNECTED + */ + it('RTL6c2 - publish queued when disconnected', async function () { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + captured.push(msg); + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL6c2-disconn', { attachOnSubscribe: false }); + await channel.attach(); + + const capturedBefore = captured.length; + + // Disconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Publish while disconnected — queued + const publishPromise = channel.publish('while-disconn', 'data'); + + // Should not have been sent yet + expect(captured.length).to.equal(capturedBefore); + + // Wait for reconnect and publish to complete + await publishPromise; + + expect(captured.length).to.be.greaterThan(capturedBefore); + const lastMsg = captured[captured.length - 1]; + expect(lastMsg.messages[0].name).to.equal('while-disconn'); + client.close(); + }); + + /** + * RTL6c2 - Multiple queued messages sent in order + */ + it('RTL6c2 - multiple queued messages sent in order', async function () { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + captured.push(msg); + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['s1'] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL6c2-order', { attachOnSubscribe: false }); + + // Queue 3 messages before connecting + const p1 = channel.publish('first', 'data1'); + const p2 = channel.publish('second', 'data2'); + const p3 = channel.publish('third', 'data3'); + + client.connect(); + await Promise.all([p1, p2, p3]); + + expect(captured.length).to.equal(3); + expect(captured[0].messages[0].name).to.equal('first'); + expect(captured[1].messages[0].name).to.equal('second'); + expect(captured[2].messages[0].name).to.equal('third'); + client.close(); + }); + + /** + * RTL6c4 - Publish fails when connection is CLOSED + */ + it('RTL6c4 - publish fails when connection closed', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 7) { + // CLOSE + conn!.send_to_client({ action: 8 }); // CLOSED + } + }, + }); + 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)); + + client.close(); + await new Promise((resolve) => client.connection.once('closed', resolve)); + + const channel = client.channels.get('test-RTL6c4-closed', { attachOnSubscribe: false }); + + try { + await channel.publish('msg', 'data'); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + }); + + /** + * RTL6c4 - Publish fails when connection is FAILED + */ + it('RTL6c4 - publish fails when connection failed', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + 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)); + + // Fatal error → FAILED + mock.active_connection!.send_to_client({ + action: 9, // ERROR (connection-level) + error: { message: 'Fatal', code: 40198, statusCode: 400 }, + }); + await new Promise((resolve) => client.connection.once('failed', resolve)); + + const channel = client.channels.get('test-RTL6c4-failed', { attachOnSubscribe: false }); + + try { + await channel.publish('msg', 'data'); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTL6c4 - Publish fails when channel is FAILED + */ + it('RTL6c4 - publish fails when channel failed', async function () { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + captured.push(msg); + } + }, + }); + 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-RTL6c4-ch-failed', { attachOnSubscribe: false }); + await channel.attach(); + + // Channel ERROR → FAILED + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL6c4-ch-failed', + error: { message: 'Channel error', code: 90001, statusCode: 500 }, + }); + await new Promise((resolve) => channel.once('failed', resolve)); + + const capturedBefore = captured.length; + + try { + await channel.publish('msg', 'data'); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.equal(90001); + } + + // No MESSAGE sent to server + expect(captured.length).to.equal(capturedBefore); + client.close(); + }); + + /** + * RTL6c2 - Publish fails when queueMessages is false and not connected + */ + it('RTL6c2 - publish fails when queueMessages false and not connected', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + // Don't respond — stay connecting + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + queueMessages: false, + }); + trackClient(client); + + client.connect(); + // Connection is CONNECTING (not yet connected) + expect(client.connection.state).to.equal('connecting'); + + const channel = client.channels.get('test-RTL6c2-noqueue', { attachOnSubscribe: false }); + + try { + await channel.publish('msg', 'data'); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTL6j - Publish returns PublishResult with serials from ACK + */ + it('RTL6j - PublishResult with serial from ACK', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + expect(msg.msgSerial).to.equal(0); + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: 0, + count: 1, + res: [{ serials: ['abc123'] }], + }); + } + }, + }); + 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-RTL6j', { attachOnSubscribe: false }); + await channel.attach(); + + const result = await channel.publish('msg', 'data'); + + expect(result).to.have.property('serials'); + expect(result.serials).to.deep.equal(['abc123']); + client.close(); + }); + + /** + * RTL6j - Batch publish returns PublishResult with multiple serials + */ + it('RTL6j - batch PublishResult with multiple serials including null', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['serial-1', null, 'serial-3'] }], + }); + } + }, + }); + 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-RTL6j-batch', { attachOnSubscribe: false }); + await channel.attach(); + + const result = await channel.publish([ + { name: 'msg1', data: 'one' }, + { name: 'msg2', data: 'two' }, + { name: 'msg3', data: 'three' }, + ]); + + expect(result.serials).to.deep.equal(['serial-1', null, 'serial-3']); + client.close(); + }); + + /** + * RTL6j - Sequential publishes get incrementing msgSerial + */ + it('RTL6j - sequential publishes get incrementing msgSerial', async function () { + const serials: number[] = []; + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + serials.push(msg.msgSerial); + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [`serial-${msg.msgSerial}`] }], + }); + } + }, + }); + 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-RTL6j-incr', { attachOnSubscribe: false }); + await channel.attach(); + + const r1 = await channel.publish('msg1', 'data1'); + const r2 = await channel.publish('msg2', 'data2'); + const r3 = await channel.publish('msg3', 'data3'); + + expect(serials).to.deep.equal([0, 1, 2]); + expect(r1.serials).to.deep.equal(['serial-0']); + expect(r2.serials).to.deep.equal(['serial-1']); + expect(r3.serials).to.deep.equal(['serial-2']); + client.close(); + }); + + /** + * RTL6j - NACK results in error + */ + it('RTL6j - NACK results in publish error', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 2, // NACK + msgSerial: msg.msgSerial, + count: 1, + error: { message: 'Publish rejected', code: 40160, statusCode: 401 }, + }); + } + }, + }); + 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-RTL6j-nack', { attachOnSubscribe: false }); + await channel.attach(); + + try { + await channel.publish('msg', 'data'); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40160); + expect(err.message).to.equal('Publish rejected'); + } + client.close(); + }); + + /** + * RTN7e - Pending publishes fail when connection enters CLOSED + */ + it('RTN7e - pending publishes fail on connection closed', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + // Don't ACK — leave publish pending + if (msg.action === 7) { + // CLOSE + 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); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN7e-closed', { attachOnSubscribe: false }); + await channel.attach(); + + // Publish but don't ACK + const publishPromise = channel.publish('pending', 'data'); + + // Close connection — pending publish should fail + client.close(); + + try { + await publishPromise; + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + }); + + /** + * RTN7e - Pending publishes fail when connection enters FAILED + */ + it('RTN7e - pending publishes fail on connection failed', async function () { + const { mock } = setupMock({ + onMessage: () => { + // Don't ACK anything + }, + }); + 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-failed', { attachOnSubscribe: false }); + await channel.attach(); + + const publishPromise = channel.publish('pending', 'data'); + + // Fatal error → FAILED + mock.active_connection!.send_to_client({ + action: 9, + error: { message: 'Fatal', code: 40198, statusCode: 400 }, + }); + + try { + await publishPromise; + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTN7e - Pending publishes fail when connection enters SUSPENDED + */ + it('RTN7e - pending publishes fail on connection suspended', async function () { + let firstConnect = true; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + if (firstConnect) { + firstConnect = false; + conn.respond_with_connected(); + } else { + // Refuse all reconnection attempts so connection enters SUSPENDED + conn.respond_with_refused(); + } + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + // Don't ACK MESSAGE — leave publish pending + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + disconnectedRetryTimeout: 500, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN7e-suspended', { attachOnSubscribe: false } as any); + await channel.attach(); + + // Publish but don't ACK — message stays pending + const publishPromise = channel.publish('pending', 'data'); + + // Disconnect and refuse all reconnection attempts so connection enters SUSPENDED + mock.active_connection!.simulate_disconnect(); + + // Pump event loop to let disconnect processing happen + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past connectionStateTtl to reach SUSPENDED + await clock.tickAsync(121000); + + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('suspended'); + + // The pending publish should now fail + try { + await publishPromise; + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTN7e - Multiple pending publishes all fail on state change + */ + it('RTN7e - multiple pending publishes all fail on close', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 7) { + // CLOSE + mock.active_connection!.send_to_client({ action: 8 }); // CLOSED + } + // Don't ACK publishes + }, + }); + 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-multi', { attachOnSubscribe: false }); + await channel.attach(); + + const p1 = channel.publish('msg1', 'data1'); + const p2 = channel.publish('msg2', 'data2'); + const p3 = channel.publish('msg3', 'data3'); + + client.close(); + + const results = await Promise.allSettled([p1, p2, p3]); + for (const r of results) { + expect(r.status).to.equal('rejected'); + expect((r as PromiseRejectedResult).reason.code).to.be.a('number'); + } + }); + + /** + * RTN7d - New publish fails on DISCONNECTED when queueMessages is false + */ + it('RTN7d - new publish fails when disconnected with queueMessages false', async function () { + const { mock } = setupMock(); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + queueMessages: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN7d-noq', { attachOnSubscribe: false }); + await channel.attach(); + + // Disconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // New publish while disconnected with queueMessages=false should fail + try { + await channel.publish('new-msg', 'data'); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + expect(err.code).to.be.a('number'); + } + client.close(); + }); + + /** + * RTN7d - Pending publishes survive DISCONNECTED when queueMessages is true + */ + it('RTN7d - pending survive disconnected with queueMessages true', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['after-reconnect'] }], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN7d-q', { attachOnSubscribe: false }); + await channel.attach(); + + // Disconnect, then publish while disconnected (message will be queued) + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Publish while disconnected — queued because queueMessages defaults to true + const result = await channel.publish('queued', 'data'); + + expect(result).to.have.property('serials'); + expect(result.serials).to.deep.equal(['after-reconnect']); + client.close(); + }); + + /** + * RTN19a - Pending messages resent on new transport after disconnect + */ + it('RTN19a - pending message resent on new transport', async function () { + let connectCount = 0; + const messagesPerConn: any[][] = [[], []]; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + const idx = connectCount++; + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + // MESSAGE + const idx = connectCount - 1; + if (idx < messagesPerConn.length) { + messagesPerConn[idx].push(msg); + } + // ACK only on second connection + if (idx >= 1) { + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['resent-serial'] }], + }); + } + // Don't ACK on first connection + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN19a', { attachOnSubscribe: false }); + await channel.attach(); + + // Publish — sent on first transport but not ACKed + const publishPromise = channel.publish('resend-me', 'data'); + + // Wait for message to be sent + await flushAsync(); + expect(messagesPerConn[0].length).to.equal(1); + + // Disconnect — ably-js will auto-reconnect and resend + mock.active_connection!.simulate_disconnect(); + + // Wait for publish to complete (after reconnect + resend + ACK) + const result = await publishPromise; + + expect(result.serials).to.deep.equal(['resent-serial']); + // Message was resent on second transport + expect(messagesPerConn[1].length).to.be.at.least(1); + const resentMsg = messagesPerConn[1].find((m: any) => m.messages?.some((m2: any) => m2.name === 'resend-me')); + expect(resentMsg).to.not.be.undefined; + client.close(); + }); + + /** + * RTN19a2 - Resent messages keep same msgSerial on successful resume + */ + it('RTN19a2 - resent messages keep msgSerial on successful resume', async function () { + let connectCount = 0; + const conn1Msgs: any[] = []; + const conn2Msgs: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + mock.active_connection = conn; + if (connectCount === 1) { + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { connectionKey: 'key-1' } as any, + }); + } else { + // Same connectionId = successful resume + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { connectionKey: 'key-1-resumed' } as any, + }); + } + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + if (connectCount === 1) { + conn1Msgs.push(msg); + // Don't ACK + } else { + conn2Msgs.push(msg); + // ACK on second connection + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [`serial-${msg.msgSerial}`] }], + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN19a2-resume', { attachOnSubscribe: false }); + await channel.attach(); + + // Publish 2 messages without ACK + const p1 = channel.publish('msg1', 'data1'); + const p2 = channel.publish('msg2', 'data2'); + + await flushAsync(); + expect(conn1Msgs.length).to.equal(2); + const origSerial1 = conn1Msgs[0].msgSerial; + const origSerial2 = conn1Msgs[1].msgSerial; + + // Disconnect and reconnect (successful resume — same connectionId) + mock.active_connection!.simulate_disconnect(); + + await Promise.all([p1, p2]); + + // Resent messages should keep same msgSerials + expect(conn2Msgs.length).to.be.at.least(2); + const resent1 = conn2Msgs.find((m: any) => m.messages?.[0]?.name === 'msg1'); + const resent2 = conn2Msgs.find((m: any) => m.messages?.[0]?.name === 'msg2'); + expect(resent1?.msgSerial).to.equal(origSerial1); + expect(resent2?.msgSerial).to.equal(origSerial2); + client.close(); + }); + + /** + * RTN19a2 - Resent messages get new msgSerial on failed resume + */ + it('RTN19a2 - resent messages get new msgSerial on failed resume', async function () { + let connectCount = 0; + const conn1Msgs: any[] = []; + const conn2Msgs: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + mock.active_connection = conn; + if (connectCount === 1) { + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { connectionKey: 'key-1' } as any, + }); + } else { + // Different connectionId = failed resume + conn.respond_with_connected({ + connectionId: 'conn-2', + connectionDetails: { connectionKey: 'key-2' } as any, + }); + } + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 15) { + if (connectCount === 1) { + conn1Msgs.push(msg); + // Don't ACK + } else { + conn2Msgs.push(msg); + conn!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: [`new-serial-${msg.msgSerial}`] }], + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN19a2-newid', { attachOnSubscribe: false }); + await channel.attach(); + + // Publish 2 messages without ACK + const p1 = channel.publish('msg1', 'data1'); + const p2 = channel.publish('msg2', 'data2'); + + await flushAsync(); + expect(conn1Msgs.length).to.equal(2); + + // Disconnect — reconnect with new connectionId (failed resume) + mock.active_connection!.simulate_disconnect(); + + await Promise.all([p1, p2]); + + // Resent messages should have new msgSerials starting from 0 + expect(conn2Msgs.length).to.be.at.least(2); + const msgSerials = conn2Msgs.filter((m: any) => m.messages?.length).map((m: any) => m.msgSerial); + expect(msgSerials).to.include(0); + expect(msgSerials).to.include(1); + client.close(); + }); + + /** + * RTN19b - Pending ATTACH resent on new transport after disconnect + */ + it('RTN19b - pending ATTACH resent after disconnect', async function () { + let connectCount = 0; + const attachMsgsPerConn: any[][] = [[], []]; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + const idx = connectCount++; + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + const idx = connectCount - 1; + if (idx < attachMsgsPerConn.length) { + attachMsgsPerConn[idx].push(msg); + } + // Only respond on second connection + if (idx >= 1) { + conn!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + // Don't respond on first connection — leave ATTACHING + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN19b-attach'); + + // Start attach — won't get response on first connection + const attachPromise = channel.attach(); + + await flushAsync(); + expect(attachMsgsPerConn[0].length).to.equal(1); + expect(channel.state).to.equal('attaching'); + + // Disconnect — ably-js reconnects and resends pending ATTACH + mock.active_connection!.simulate_disconnect(); + + await attachPromise; + expect(channel.state).to.equal('attached'); + + // ATTACH was resent on second connection + expect(attachMsgsPerConn[1].length).to.be.at.least(1); + client.close(); + }); + + /** + * RTN19b - Pending DETACH resent on new transport after disconnect + */ + it('RTN19b - pending DETACH resent after disconnect', async function () { + let connectCount = 0; + const detachMsgsPerConn: any[][] = [[], []]; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + const idx = connectCount++; + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); + } + if (msg.action === 12) { + // DETACH + const idx = connectCount - 1; + if (idx < detachMsgsPerConn.length) { + detachMsgsPerConn[idx].push(msg); + } + // Only respond on second connection + if (idx >= 1) { + conn!.send_to_client({ action: 13, channel: msg.channel }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTN19b-detach'); + await channel.attach(); + + // Start detach — won't get response on first connection + const detachPromise = channel.detach(); + + await flushAsync(); + expect(detachMsgsPerConn[0].length).to.equal(1); + expect(channel.state).to.equal('detaching'); + + // Disconnect — ably-js reconnects and resends pending DETACH + mock.active_connection!.simulate_disconnect(); + + await detachPromise; + expect(channel.state).to.equal('detached'); + + expect(detachMsgsPerConn[1].length).to.be.at.least(1); + client.close(); + }); + + /** + * RTL6c1 - Publish immediately when CONNECTED and channel ATTACHING + * + * Messages are sent immediately when the connection is CONNECTED and the + * channel is in ATTACHING state (which is neither SUSPENDED nor FAILED). + */ + it('RTL6c1 - publish immediately when connected and channel attaching', async function () { + const capturedMessages: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + } else if (msg.action === 15) { + // MESSAGE + capturedMessages.push(msg); + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + } + }, + }); + 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-RTL6c1-attaching', { attachOnSubscribe: false } as any); + channel.attach().catch(() => {}); + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + + await channel.publish('while-attaching', 'data'); + + expect(capturedMessages.length).to.equal(1); + expect(capturedMessages[0].messages[0].name).to.equal('while-attaching'); + client.close(); + }); + + /** + * RTL6c4 - Publish fails when channel is SUSPENDED + */ + it('RTL6c4 - publish fails when channel suspended', async function () { + const clock = enableFakeTimers(); + const capturedMessages: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel hanging so it times out to SUSPENDED + } else if (msg.action === 15) { + // MESSAGE + capturedMessages.push(msg); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + } as any); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL6c4-ch-suspended', { attachOnSubscribe: false } as any); + + // Start attach — will timeout and channel enters SUSPENDED + const attachPromise = channel.attach(); + + // Advance time past realtimeRequestTimeout so the attach times out + for (let i = 0; i < 10; i++) { + await clock.tickAsync(200); + for (let j = 0; j < 5; j++) { clock.tick(0); await flushAsync(); } + if (channel.state === 'suspended') break; + } + + // The attach should have failed + try { + await attachPromise; + } catch (e) { + // Expected — attach timed out + } + + expect(channel.state).to.equal('suspended'); + + const capturedBefore = capturedMessages.length; + + try { + await channel.publish('fail', 'should-error'); + expect.fail('Expected publish to fail'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + // No MESSAGE sent to server + expect(capturedMessages.length).to.equal(capturedBefore); + client.close(); + }); + + /** + * RTL6c4 - Publish fails when connection is SUSPENDED + */ + it('RTL6c4 - publish fails when connection suspended', async function () { + const clock = enableFakeTimers(); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_refused(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000, + } as any); + trackClient(client); + + client.connect(); + + // Advance time until SUSPENDED + for (let i = 0; i < 15; i++) { + await clock.tickAsync(2000); + for (let j = 0; j < 10; j++) { clock.tick(0); await flushAsync(); } + if (client.connection.state === 'suspended') break; + } + expect(client.connection.state).to.equal('suspended'); + + const channel = client.channels.get('test-RTL6c4-suspended', { attachOnSubscribe: false } as any); + try { + await channel.publish('fail', 'should-error'); + expect.fail('Expected publish to fail'); + } catch (err: any) { + expect(err).to.not.be.null; + } + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_server_initiated_detach.test.ts b/test/uts/realtime/channels/channel_server_initiated_detach.test.ts new file mode 100644 index 000000000..442870a57 --- /dev/null +++ b/test/uts/realtime/channels/channel_server_initiated_detach.test.ts @@ -0,0 +1,624 @@ +/** + * UTS: Channel Server-Initiated Detach Tests + * + * Spec points: RTL13, RTL13a, RTL13b, RTL13c + * Source: uts/test/realtime/unit/channels/channel_server_initiated_detach_test.md + * + * Tests behavior when the server sends an unsolicited DETACHED: + * - ATTACHED → immediate reattach (RTL13a) + * - ATTACHING → SUSPENDED with automatic retry (RTL13b) + * - Failed reattach cycles SUSPENDED → ATTACHING → SUSPENDED (RTL13b) + * - Retry cancelled when connection drops (RTL13c) + * - DETACHING → normal detach (not reattach) + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, enableFakeTimers, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_server_initiated_detach', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL13a - Server DETACHED on ATTACHED channel triggers immediate reattach + */ + it('RTL13a - server DETACHED on attached triggers reattach', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + 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-RTL13a'); + await channel.attach(); + expect(attachCount).to.equal(1); + expect(channel.state).to.equal('attached'); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // Server sends unsolicited DETACHED with error + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: 'test-RTL13a', + error: { message: 'Server detach', code: 90198, statusCode: 500 }, + }); + + // Wait for reattach to complete + await new Promise((resolve) => { + const check = () => { + if (channel.state === 'attached' && attachCount >= 2) return resolve(); + channel.once('attached', check); + }; + check(); + }); + + expect(attachCount).to.equal(2); + expect(channel.state).to.equal('attached'); + + // Should have gone through attaching state with error + const attachingChange = stateChanges.find((c: any) => c.current === 'attaching'); + expect(attachingChange).to.not.be.undefined; + expect(attachingChange.reason?.code).to.equal(90198); + client.close(); + }); + + /** + * RTL13b - Server DETACHED while ATTACHING → SUSPENDED → automatic retry + */ + it('RTL13b - server DETACHED while attaching → suspended → retry', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach — don't respond, then send DETACHED + // (will be sent after we detect attaching state below) + } else { + // Subsequent attaches succeed + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + channelRetryTimeout: 100, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL13b'); + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // Start attach — won't get response + channel.attach(); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('attaching'); + expect(attachCount).to.equal(1); + + // Server sends DETACHED while ATTACHING → goes to SUSPENDED + mock.active_connection!.send_to_client({ + action: 13, + channel: 'test-RTL13b', + error: { message: 'Server detach', code: 90198, statusCode: 500 }, + }); + + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('suspended'); + + // Advance past channelRetryTimeout → automatic retry + await clock.tickAsync(200); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(2); + + // Verify state sequence + const states = stateChanges.map((c: any) => c.current); + expect(states).to.include('attaching'); + expect(states).to.include('suspended'); + expect(states).to.include('attached'); + client.close(); + }); + + /** + * RTL13b - Failed reattach → SUSPENDED → retry cycle + */ + it('RTL13b - failed reattach cycles through suspended', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach succeeds + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } else if (attachCount === 2) { + // Second attach (reattach after DETACHED): server sends DETACHED again + mock.active_connection!.send_to_client({ + action: 13, // DETACHED again + channel: msg.channel, + error: { message: 'Still detached', code: 90198, statusCode: 500 }, + }); + } else { + // Third attach succeeds + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + channelRetryTimeout: 100, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL13b-cycle'); + await channel.attach(); + expect(attachCount).to.equal(1); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // Server sends DETACHED → triggers reattach (attachCount 2) + // Reattach will be DETACHED again → SUSPENDED + mock.active_connection!.send_to_client({ + action: 13, + channel: 'test-RTL13b-cycle', + error: { message: 'Server detach', code: 90198, statusCode: 500 }, + }); + + // Process reattach attempt and its failure + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('suspended'); + expect(attachCount).to.equal(2); + + // Advance past retry timeout → third attach succeeds + await clock.tickAsync(200); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(3); + client.close(); + }); + + /** + * RTL13b - Repeated failures cycle indefinitely + */ + it('RTL13b - repeated failures cycle suspended → attaching', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } else if (attachCount <= 3) { + // Reattach attempts 2 and 3 fail with DETACHED + mock.active_connection!.send_to_client({ + action: 13, + channel: msg.channel, + error: { message: 'Still detached', code: 90198, statusCode: 500 }, + }); + } else { + // Attempt 4 succeeds + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + channelRetryTimeout: 100, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL13b-repeat'); + await channel.attach(); + expect(attachCount).to.equal(1); + + const stateChanges: any[] = []; + channel.on((change: any) => stateChanges.push(change)); + + // First DETACHED → reattach attempt 2 fails → SUSPENDED + mock.active_connection!.send_to_client({ + action: 13, + channel: 'test-RTL13b-repeat', + error: { message: 'Detach 1', code: 90198, statusCode: 500 }, + }); + + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('suspended'); + + // Advance past first retry → attempt 3 fails → SUSPENDED again + // retryCount=1: delay = channelRetryTimeout * (1+2)/3 * jitter = 100 * 1.0 * [0.8-1.0] = 80-100ms + await clock.tickAsync(150); + for (let i = 0; i < 40; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('suspended'); + expect(attachCount).to.equal(3); + + // Advance past second retry → attempt 4 succeeds + // retryCount=2: delay = channelRetryTimeout * (2+2)/3 * jitter = 100 * 1.333 * [0.8-1.0] = 107-134ms + await clock.tickAsync(200); + for (let i = 0; i < 40; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(4); + client.close(); + }); + + /** + * RTL13c - Retry cancelled when connection is no longer CONNECTED + */ + it('RTL13c - retry cancelled when connection drops', async function () { + let attachCount = 0; + let connectCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + mock.active_connection = conn; + if (connectCount === 1) { + conn.respond_with_connected(); + } else { + // Don't respond to reconnection + conn.respond_with_refused(); + } + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + // Don't respond to reattach — it should be cancelled + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + channelRetryTimeout: 100, + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL13c'); + await channel.attach(); + expect(attachCount).to.equal(1); + + // Server sends DETACHED → channel goes to ATTACHING + mock.active_connection!.send_to_client({ + action: 13, + channel: 'test-RTL13c', + error: { message: 'Server detach', code: 90198, statusCode: 500 }, + }); + + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + expect(attachCount).to.equal(2); + + const attachCountAfterDetach = attachCount; + + // Disconnect — connection drops + mock.active_connection!.simulate_disconnect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past retry timeout — no retry since connection is not CONNECTED + await clock.tickAsync(500); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + + // No additional ATTACH messages should have been sent + expect(attachCount).to.equal(attachCountAfterDetach); + client.close(); + }); + + /** + * RTL13 - DETACHED while DETACHING is normal detach flow (not reattach) + */ + it('RTL13 - DETACHED while detaching is normal detach', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL13-detaching'); + await channel.attach(); + expect(attachCount).to.equal(1); + + await channel.detach(); + + // Channel should be cleanly detached, not re-attached + expect(channel.state).to.equal('detached'); + expect(attachCount).to.equal(1); + client.close(); + }); + + /** + * RTL13a - Server DETACHED on SUSPENDED channel triggers immediate reattach + * + * When a channel is in SUSPENDED state (e.g. after a failed reattach timeout) + * and receives a server-initiated DETACHED, it should immediately attempt + * to reattach. + */ + it('RTL13a - server DETACHED on suspended triggers reattach', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach succeeds + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } else if (attachCount === 2) { + // Second attach (reattach after first DETACHED) — don't respond (timeout -> SUSPENDED) + } else { + // Third attach (after second DETACHED on SUSPENDED) — succeed + mock.active_connection!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 100, + channelRetryTimeout: 60000, // Large so auto-retry doesn't interfere + } as any); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + const channel = client.channels.get('test-RTL13a-suspended'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(1); + + // Send server-initiated DETACHED to trigger RTL13a reattach + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: 'test-RTL13a-suspended', + error: { message: 'Detach 1', code: 90198, statusCode: 500 }, + }); + + // Let channel enter ATTACHING state + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('attaching'); + + // Let the reattach timeout -> SUSPENDED + await clock.tickAsync(150); + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + expect(channel.state).to.equal('suspended'); + + // Now send another server-initiated DETACHED while SUSPENDED + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: 'test-RTL13a-suspended', + error: { message: 'Detach 2', code: 90199, statusCode: 500 }, + }); + + // Channel should immediately attempt to reattach and succeed + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('attached'); + // 3 total ATTACH messages + expect(attachCount).to.equal(3); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_state_events.test.ts b/test/uts/realtime/channels/channel_state_events.test.ts new file mode 100644 index 000000000..b9d581a68 --- /dev/null +++ b/test/uts/realtime/channels/channel_state_events.test.ts @@ -0,0 +1,643 @@ +/** + * UTS: Channel State Events Tests + * + * Spec points: RTL2, RTL2a, RTL2b, RTL2d, RTL2g, RTL2i, TH1, TH2, TH3, TH5, TH6 + * Source: uts/test/realtime/unit/channels/channel_state_events_test.md + * + * Tests ChannelStateChange structure, state change event emission, + * filtered subscriptions, UPDATE events, hasBacklog and resumed flags. + * + * Deviation: TH5 (event field) — ably-js ChannelStateChange has no `event` + * property. The event name is available via `this.event` inside the listener + * callback context (set by EventEmitter.emit), not on the change object. + * Deviation: RTL24 (errorReason clearing) — ably-js does NOT clear errorReason + * on successful attach/detach. See channel_attributes.test.ts for details. + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_state_events', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL2b - Channel state attribute + */ + it('RTL2b - channel has state attribute', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL2b'); + expect(channel.state).to.be.a('string'); + client.close(); + }); + + /** + * RTL2b - Channel initial state is initialized + */ + it('RTL2b - initial state is initialized', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL2b-init'); + expect(channel.state).to.equal('initialized'); + client.close(); + }); + + /** + * RTL2a - State change events emitted for every state change + */ + it('RTL2a - state change events emitted', 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); + + const channel = client.channels.get('test-RTL2a'); + const stateChanges: any[] = []; + channel.on((change: any) => { + stateChanges.push(change); + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + await channel.attach(); + + expect(stateChanges.length).to.be.at.least(2); + expect(stateChanges[0].current).to.equal('attaching'); + expect(stateChanges[0].previous).to.equal('initialized'); + expect(stateChanges[1].current).to.equal('attached'); + expect(stateChanges[1].previous).to.equal('attaching'); + client.close(); + }); + + /** + * RTL2d, TH1, TH2 - ChannelStateChange object structure + * + * Deviation: TH5 — ably-js ChannelStateChange has no `event` property. + * The event name is available via `this.event` in the listener context. + */ + it('RTL2d, TH1, TH2 - ChannelStateChange structure', 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); + + const channel = client.channels.get('test-RTL2d'); + let capturedChange: any = null; + channel.once('attaching', function (change: any) { + capturedChange = change; + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + await channel.attach(); + + expect(capturedChange).to.not.be.null; + // TH1 - previous state + expect(capturedChange.previous).to.equal('initialized'); + // TH2 - current state + expect(capturedChange.current).to.equal('attaching'); + client.close(); + }); + + /** + * RTL2d, TH3 - ChannelStateChange includes error reason when applicable + */ + it('RTL2d, TH3 - ChannelStateChange includes error reason', 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: 9, // ERROR + channel: msg.channel, + error: { + message: 'Channel denied', + code: 40160, + statusCode: 401, + }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL2d-error'); + let capturedChange: any = null; + channel.once('failed', function (change: any) { + capturedChange = change; + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + try { + await channel.attach(); + } catch (err) { + // Expected + } + + expect(capturedChange).to.not.be.null; + expect(capturedChange.current).to.equal('failed'); + expect(capturedChange.reason).to.not.be.null; + expect(capturedChange.reason).to.not.be.undefined; + expect(capturedChange.reason.code).to.equal(40160); + client.close(); + }); + + /** + * RTL2 - Filtered event subscription + */ + it('RTL2 - filtered event subscription', 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); + + const channel = client.channels.get('test-RTL2-filter'); + const attachedEvents: any[] = []; + // Subscribe only to 'attached' events + channel.on('attached', (change: any) => { + attachedEvents.push(change); + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + await channel.attach(); + + expect(attachedEvents.length).to.equal(1); + expect(attachedEvents[0].current).to.equal('attached'); + client.close(); + }); + + /** + * RTL2g - UPDATE event for condition changes without state change + * + * When an ATTACHED message is received while already attached and + * the RESUMED flag is NOT set, an 'update' event is emitted. + */ + it('RTL2g - UPDATE event emitted', 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-RTL2g'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + const updateEvents: any[] = []; + channel.on('update', (change: any) => { + updateEvents.push(change); + }); + + // Send another ATTACHED without RESUMED flag + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL2g', + flags: 0, // No RESUMED flag + }); + + await flushAsync(); + + expect(channel.state).to.equal('attached'); + 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); + client.close(); + }); + + /** + * RTL2g - No duplicate 'attached' state events + * + * When an UPDATE occurs, only the 'update' event is emitted, not + * a duplicate 'attached' event. + */ + it('RTL2g - no duplicate attached events on UPDATE', 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-RTL2g-nodup'); + const allEvents: any[] = []; + channel.on((change: any) => { + allEvents.push(change); + }); + + await channel.attach(); + const countAfterAttach = allEvents.length; + + // Send another ATTACHED without RESUMED + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTL2g-nodup', + flags: 0, + }); + + await flushAsync(); + + // Only one new event should have been emitted (the 'update') + const newEvents = allEvents.slice(countAfterAttach); + expect(newEvents.length).to.equal(1); + + // The 'attached' event count should still be 1 (from initial attach) + const attachedEvents = allEvents.filter((e) => e.current === 'attached' && e.previous === 'attaching'); + expect(attachedEvents.length).to.equal(1); + client.close(); + }); + + /** + * RTL2i, TH6 - hasBacklog flag in ChannelStateChange + */ + it('RTL2i, TH6 - hasBacklog true when flag present', async function () { + const HAS_BACKLOG = 2; // 1 << 1 + + 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: HAS_BACKLOG, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL2i'); + let capturedChange: any = null; + channel.once('attached', (change: any) => { + capturedChange = change; + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + await channel.attach(); + + expect(capturedChange).to.not.be.null; + expect(capturedChange.hasBacklog).to.equal(true); + client.close(); + }); + + /** + * RTL2i - hasBacklog false when flag not present + */ + it('RTL2i - hasBacklog false when flag not present', 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); + + const channel = client.channels.get('test-RTL2i-false'); + let capturedChange: any = null; + channel.once('attached', (change: any) => { + capturedChange = change; + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + await channel.attach(); + + expect(capturedChange).to.not.be.null; + expect(capturedChange.hasBacklog).to.satisfy((v: any) => v === false || v === undefined || v === null); + client.close(); + }); + + /** + * RTL2d - resumed flag in ChannelStateChange + */ + it('RTL2d - resumed flag true when RESUMED set', async function () { + const RESUMED = 4; // 1 << 2 + + 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: RESUMED, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL2d-resumed'); + let capturedChange: any = null; + channel.once('attached', (change: any) => { + capturedChange = change; + }); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + await channel.attach(); + + expect(capturedChange).to.not.be.null; + expect(capturedChange.resumed).to.equal(true); + client.close(); + }); + + /** + * Channel errorReason attribute populated on FAILED state + * + * When a channel enters the FAILED state, errorReason should be + * populated with the error from the server. + */ + it('channel errorReason populated when failed', 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: 9, // ERROR + channel: msg.channel, + error: { + message: 'Not authorized', + code: 40160, + statusCode: 401, + }, + }); + } + }, + }); + 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-errorReason'); + + try { + await channel.attach(); + } catch (err) { + // Expected + } + + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + expect(channel.errorReason).to.not.be.undefined; + expect(channel.errorReason!.code).to.equal(40160); + expect(channel.errorReason!.message).to.include('Not authorized'); + client.close(); + }); + + /** + * RTL4c - errorReason cleared on successful attach after failure + * + * Deviation: ably-js does NOT clear errorReason on successful re-attach. + * This test documents the deviation. + */ + it('RTL4c - errorReason after successful re-attach (deviation)', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + if (attachCount === 1) { + // First attach fails + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: msg.channel, + error: { + message: 'Denied', + code: 40160, + statusCode: 401, + }, + }); + } else { + // Second attach succeeds + 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-errorReason-clear'); + + // First attach fails + try { + await channel.attach(); + } catch (err) { + // Expected + } + expect(channel.state).to.equal('failed'); + expect(channel.errorReason).to.not.be.null; + + // Second attach succeeds + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Deviation: ably-js does NOT clear errorReason on successful re-attach. + // The UTS spec expects errorReason to be null here (RTL4c). + expect(channel.errorReason).to.not.be.null; + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_subscribe.test.ts b/test/uts/realtime/channels/channel_subscribe.test.ts new file mode 100644 index 000000000..96d851c2e --- /dev/null +++ b/test/uts/realtime/channels/channel_subscribe.test.ts @@ -0,0 +1,961 @@ +/** + * UTS: Channel Subscribe Tests + * + * Spec points: RTL7a, RTL7b, RTL7f, RTL7g, RTL7h, RTL8a, RTL8b, RTL8c, RTL17 + * Source: uts/test/realtime/unit/channels/channel_subscribe_test.md + * + * Tests message subscription, name filtering, implicit attach, + * echoMessages, and unsubscribe patterns. + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_subscribe', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL7a - Subscribe with no name receives all messages + */ + it('RTL7a - subscribe receives all messages', 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-RTL7a', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // Send three messages with different names + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL7a', + messages: [ + { name: 'msg1', data: 'one' }, + { name: 'msg2', data: 'two' }, + { name: 'msg3', data: 'three' }, + ], + }); + + await flushAsync(); + + expect(received.length).to.equal(3); + expect(received[0].name).to.equal('msg1'); + expect(received[1].name).to.equal('msg2'); + expect(received[2].name).to.equal('msg3'); + client.close(); + }); + + /** + * RTL7b - Subscribe with name only receives matching messages + */ + it('RTL7b - name-filtered subscribe', 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-RTL7b', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe('target', (msg: any) => received.push(msg)); + + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL7b', + messages: [ + { name: 'other', data: 'skip' }, + { name: 'target', data: 'match' }, + { name: 'another', data: 'skip' }, + ], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].name).to.equal('target'); + expect(received[0].data).to.equal('match'); + client.close(); + }); + + /** + * RTL7g - Subscribe triggers implicit attach + */ + it('RTL7g - subscribe triggers implicit attach', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + 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-RTL7g'); + expect(channel.state).to.equal('initialized'); + + // Subscribe triggers implicit attach (attachOnSubscribe defaults to true) + channel.subscribe((msg: any) => {}); + + // Wait for implicit attach to complete + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(1); + client.close(); + }); + + /** + * RTL7h - Subscribe does not attach when attachOnSubscribe is false + */ + it('RTL7h - subscribe without attach when attachOnSubscribe false', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + } + }, + }); + 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-RTL7h', { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + channel.subscribe((msg: any) => {}); + await flushAsync(); + + expect(channel.state).to.equal('initialized'); + expect(attachCount).to.equal(0); + client.close(); + }); + + /** + * RTL7g - Subscribe does not re-attach when already attached + */ + it('RTL7g - subscribe does not re-attach when already attached', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + 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-RTL7g-nodup'); + await channel.attach(); + expect(attachCount).to.equal(1); + + channel.subscribe((msg: any) => {}); + await flushAsync(); + + expect(attachCount).to.equal(1); + client.close(); + }); + + /** + * RTL7f - echoMessages=false sends echo=false in connection URL + * + * UTS spec error: The UTS spec tests client-side echo suppression by + * sending a MESSAGE with matching connectionId and asserting it's not + * delivered. However, the features spec (RTL7f) only says "ensuring + * published messages are not echoed back" — it does not specify client-side + * vs server-side mechanism. ably-js uses server-side suppression via + * echo=false URL parameter. Test verifies the parameter is set. + */ + it('RTL7f - echoMessages false sets echo param in URL', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + echoMessages: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Check the WebSocket URL has echo=false + const connUrl = mock.active_connection!.url; + expect(connUrl.searchParams.get('echo')).to.equal('false'); + client.close(); + }); + + /** + * RTL8a - Unsubscribe specific listener + */ + it('RTL8a - unsubscribe specific listener', 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-RTL8a', { attachOnSubscribe: false }); + await channel.attach(); + + const received1: any[] = []; + const received2: any[] = []; + const listener1 = (msg: any) => received1.push(msg); + const listener2 = (msg: any) => received2.push(msg); + + channel.subscribe(listener1); + channel.subscribe(listener2); + + // First message — both listeners get it + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL8a', + messages: [{ name: 'msg1', data: 'first' }], + }); + await flushAsync(); + + // Unsubscribe listener1 + channel.unsubscribe(listener1); + + // Second message — only listener2 gets it + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL8a', + messages: [{ name: 'msg2', data: 'second' }], + }); + await flushAsync(); + + expect(received1.length).to.equal(1); + expect(received2.length).to.equal(2); + client.close(); + }); + + /** + * RTL8b - Unsubscribe listener from specific name + */ + it('RTL8b - unsubscribe from specific name', 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-RTL8b', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + const listener = (msg: any) => received.push(msg); + + channel.subscribe('alpha', listener); + channel.subscribe('beta', listener); + + // First batch + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL8b', + messages: [ + { name: 'alpha', data: 'a1' }, + { name: 'beta', data: 'b1' }, + ], + }); + await flushAsync(); + expect(received.length).to.equal(2); + + // Unsubscribe from 'alpha' only + channel.unsubscribe('alpha', listener); + + // Second batch + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL8b', + messages: [ + { name: 'alpha', data: 'a2' }, + { name: 'beta', data: 'b2' }, + ], + }); + await flushAsync(); + + // Should have received alpha+beta (2) + beta only (1) = 3 + expect(received.length).to.equal(3); + expect(received[2].name).to.equal('beta'); + client.close(); + }); + + /** + * RTL8c - Unsubscribe with no args removes all listeners + */ + it('RTL8c - unsubscribe all', 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-RTL8c', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + channel.subscribe('named', (msg: any) => received.push(msg)); + + // First message + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL8c', + messages: [{ name: 'named', data: 'first' }], + }); + await flushAsync(); + const countBefore = received.length; + expect(countBefore).to.be.at.least(1); + + // Unsubscribe all + channel.unsubscribe(); + + // Second message + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-RTL8c', + messages: [{ name: 'named', data: 'second' }], + }); + await flushAsync(); + + expect(received.length).to.equal(countBefore); // No new messages + client.close(); + }); + + /** + * RTL17 - Messages not delivered when channel is not ATTACHED + * + * Per spec: "No messages should be passed to subscribers if the channel + * is in any state other than ATTACHED." + */ + it('RTL17 - messages not delivered when channel is not ATTACHED', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + } + }, + }); + 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-RTL17'); + const received: any[] = []; + channel.subscribe((msg: any) => { + received.push(msg); + }); + + // Channel should be in ATTACHING (subscribe triggers implicit attach) + await flushAsync(); + expect(channel.state).to.equal('attaching'); + + // Send a MESSAGE while channel is ATTACHING + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL17', + messages: [{ name: 'premature', data: 'should-not-deliver' }], + }); + + await flushAsync(); + + // Message should NOT have been delivered + expect(received.length).to.equal(0); + client.close(); + }); + + /** + * RTL7a - Subscribe receives multiple messages from a single ProtocolMessage + */ + it('RTL7a - subscribe receives multiple messages from single ProtocolMessage', 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-RTL7a-multi', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // Server sends a single ProtocolMessage with multiple messages + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL7a-multi', + messages: [ + { name: 'batch1', data: 'first' }, + { name: 'batch2', data: 'second' }, + { name: 'batch3', data: 'third' }, + ], + }); + + await flushAsync(); + + expect(received.length).to.equal(3); + expect(received[0].name).to.equal('batch1'); + expect(received[1].name).to.equal('batch2'); + expect(received[2].name).to.equal('batch3'); + client.close(); + }); + + /** + * RTL7b - Multiple name-specific subscriptions are independent + */ + it('RTL7b - multiple name-specific subscriptions are independent', 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-RTL7b-multi', { attachOnSubscribe: false }); + await channel.attach(); + + const alphaMessages: any[] = []; + const betaMessages: any[] = []; + + channel.subscribe('alpha', (msg: any) => alphaMessages.push(msg)); + channel.subscribe('beta', (msg: any) => betaMessages.push(msg)); + + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL7b-multi', + messages: [ + { name: 'alpha', data: 'a1' }, + { name: 'beta', data: 'b1' }, + { name: 'alpha', data: 'a2' }, + { name: 'gamma', data: 'g1' }, + ], + }); + + await flushAsync(); + + expect(alphaMessages.length).to.equal(2); + expect(alphaMessages[0].data).to.equal('a1'); + expect(alphaMessages[1].data).to.equal('a2'); + + expect(betaMessages.length).to.equal(1); + expect(betaMessages[0].data).to.equal('b1'); + client.close(); + }); + + /** + * RTL7g - Subscribe triggers implicit attach from DETACHED state + */ + it('RTL7g - subscribe triggers implicit attach from DETACHED state', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 0, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL7g-detached'); + await channel.attach(); + await channel.detach(); + expect(channel.state).to.equal('detached'); + expect(attachCount).to.equal(1); + + // Subscribe should trigger implicit attach from DETACHED + channel.subscribe((msg: any) => {}); + + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + expect(channel.state).to.equal('attached'); + expect(attachCount).to.equal(2); + client.close(); + }); + + /** + * RTL7g - Listener registered even if implicit attach fails + */ + it('RTL7g - listener registered even if implicit attach fails', async function () { + let attachAttempts = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachAttempts++; + if (attachAttempts === 1) { + // First attach fails with channel error + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: msg.channel, + error: { code: 40160, statusCode: 401, message: 'Not permitted' }, + }); + } else { + // Subsequent attaches succeed + 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-RTL7g-fail'); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // Wait for channel to enter FAILED from the rejected attach + await new Promise((resolve) => { + if (channel.state === 'failed') return resolve(); + channel.once('failed', () => resolve()); + }); + expect(channel.state).to.equal('failed'); + + // Re-attach the channel (second attempt will succeed) + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Verify the listener was registered despite the failed attach + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL7g-fail', + messages: [{ name: 'test', data: 'after-reattach' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].data).to.equal('after-reattach'); + client.close(); + }); + + /** + * RTL7g - Subscribe does not attach when already attaching + */ + it('RTL7g - subscribe does not attach when already attaching', async function () { + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH — don't respond, leave channel in ATTACHING + attachCount++; + } + }, + }); + 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-RTL7g-attaching'); + + // Start attach but don't complete it + channel.attach(); + await flushAsync(); + expect(channel.state).to.equal('attaching'); + expect(attachCount).to.equal(1); + + // Subscribe while attaching — should not trigger another attach + channel.subscribe((msg: any) => {}); + await flushAsync(); + + expect(channel.state).to.equal('attaching'); + expect(attachCount).to.equal(1); // No additional ATTACH message sent + client.close(); + }); + + /** + * RTL7f - echoMessages=false sets echo=false in connection URL + * + * ably-js delegates echo suppression to the server via the echo=false + * URL parameter. It does NOT filter messages client-side by connectionId. + * This test verifies the URL parameter is set correctly. + */ + it('RTL7f - echoMessages false sets echo param in URL', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + echoMessages: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Verify the echo=false parameter is set in the URL + const connUrl = mock.active_connection!.url; + expect(connUrl.searchParams.get('echo')).to.equal('false'); + client.close(); + }); + + /** + * RTL8a - Unsubscribe listener not currently subscribed is no-op + */ + it('RTL8a - unsubscribe non-subscribed listener is no-op', 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-RTL8a-noop', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + const activeListener = (msg: any) => received.push(msg); + const unusedListener = (msg: any) => {}; + + channel.subscribe(activeListener); + + // Unsubscribe a listener that was never subscribed — should be no-op + channel.unsubscribe(unusedListener); + + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-RTL8a-noop', + messages: [{ name: 'test', data: 'still-works' }], + }); + + await flushAsync(); + + // Existing subscription should be unaffected + expect(received.length).to.equal(1); + expect(received[0].data).to.equal('still-works'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_update_delete_message.test.ts b/test/uts/realtime/channels/channel_update_delete_message.test.ts new file mode 100644 index 000000000..3ac57fbb8 --- /dev/null +++ b/test/uts/realtime/channels/channel_update_delete_message.test.ts @@ -0,0 +1,421 @@ +/** + * UTS: Channel Update/Delete Message Tests + * + * Spec points: RTL32a, RTL32b, RTL32b1, RTL32b2, RTL32c, RTL32d, RTL32e + * Source: uts/test/realtime/unit/channels/channel_update_delete_message_test.md + * + * Tests updateMessage, deleteMessage, appendMessage: wire format, + * serial validation, immutability, UpdateDeleteResult, NACK, params. + */ + +import { expect } from 'chai'; +import { MockWebSocket, PendingWSConnection } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient } from '../../helpers'; + +describe('uts/realtime/channels/channel_update_delete_message', function () { + afterEach(function () { + restoreAll(); + }); + + // Helper: standard mock that auto-connects, auto-attaches, and captures messages + function setupMock(opts?: { onMessage?: (msg: any, conn: PendingWSConnection | undefined) => void }) { + const captured: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ + action: 11, + channel: msg.channel, + flags: 0, + }); + } + if (msg.action === 15) { + // MESSAGE + captured.push(msg); + } + if (opts?.onMessage) { + opts.onMessage(msg, conn); + } + }, + }); + return { mock, captured }; + } + + /** + * RTL32b, RTL32b1 - updateMessage sends MESSAGE with action=message.update + */ + it('RTL32b - updateMessage sends correct wire format', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['version-serial-1'] }], + }); + } + }, + }); + 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-RTL32b-update', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.updateMessage({ + serial: 'orig-serial-123', + name: 'event', + data: 'updated-data', + }); + + expect(captured.length).to.equal(1); + expect(captured[0].action).to.equal(15); // MESSAGE + const wireMsg = captured[0].messages[0]; + expect(wireMsg.serial).to.equal('orig-serial-123'); + expect(wireMsg.data).to.equal('updated-data'); + // message.update = action index 1 + expect(wireMsg.action).to.equal(1); + client.close(); + }); + + /** + * RTL32b, RTL32b1 - deleteMessage sends MESSAGE with action=message.delete + */ + it('RTL32b - deleteMessage sends correct wire format', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['version-serial-1'] }], + }); + } + }, + }); + 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-RTL32b-delete', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.deleteMessage({ serial: 'orig-serial-456' }); + + expect(captured.length).to.equal(1); + const wireMsg = captured[0].messages[0]; + expect(wireMsg.serial).to.equal('orig-serial-456'); + // message.delete = action index 2 + expect(wireMsg.action).to.equal(2); + client.close(); + }); + + /** + * RTL32b, RTL32b1 - appendMessage sends MESSAGE with action=message.append + */ + it('RTL32b - appendMessage sends correct wire format', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['version-serial-1'] }], + }); + } + }, + }); + 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-RTL32b-append', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.appendMessage({ + serial: 'orig-serial-789', + data: 'appended-data', + }); + + expect(captured.length).to.equal(1); + const wireMsg = captured[0].messages[0]; + expect(wireMsg.serial).to.equal('orig-serial-789'); + expect(wireMsg.data).to.equal('appended-data'); + // message.append = action index 5 + expect(wireMsg.action).to.equal(5); + client.close(); + }); + + /** + * RTL32b2 - version field from MessageOperation + */ + it('RTL32b2 - operation included as version field', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['vs-1'] }], + }); + } + }, + }); + 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-RTL32b2', { attachOnSubscribe: false }); + await channel.attach(); + + // With operation + await channel.updateMessage( + { serial: 'serial-1', data: 'data' }, + { description: 'Edit reason', metadata: { key: 'val' } }, + ); + + const wireMsg = captured[0].messages[0]; + expect(wireMsg.version).to.be.an('object'); + expect(wireMsg.version.description).to.equal('Edit reason'); + expect(wireMsg.version.metadata).to.deep.equal({ key: 'val' }); + client.close(); + }); + + /** + * RTL32c - Does not mutate user Message + */ + it('RTL32c - original message not mutated', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['vs-1'] }], + }); + } + }, + }); + 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-RTL32c', { attachOnSubscribe: false }); + await channel.attach(); + + const original = { serial: 'serial-1', name: 'event', data: 'original-data' }; + await channel.updateMessage(original); + + // Original object should not be mutated + expect(original.serial).to.equal('serial-1'); + expect(original.name).to.equal('event'); + expect(original.data).to.equal('original-data'); + expect((original as any).action).to.be.undefined; + client.close(); + }); + + /** + * RTL32d - Returns UpdateDeleteResult with versionSerial from ACK + */ + it('RTL32d - returns UpdateDeleteResult with versionSerial', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['version-abc-123'] }], + }); + } + }, + }); + 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-RTL32d', { attachOnSubscribe: false }); + await channel.attach(); + + const result = await channel.updateMessage({ serial: 'serial-1', data: 'new' }); + + expect(result).to.have.property('versionSerial'); + expect(result.versionSerial).to.equal('version-abc-123'); + client.close(); + }); + + /** + * RTL32d - NACK returns error + */ + it('RTL32d - NACK returns error', async function () { + const { mock } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 2, // NACK + msgSerial: msg.msgSerial, + count: 1, + error: { message: 'Update rejected', code: 40160, statusCode: 401 }, + }); + } + }, + }); + 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-RTL32d-nack', { attachOnSubscribe: false }); + await channel.attach(); + + try { + await channel.updateMessage({ serial: 'serial-1', data: 'new' }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40160); + } + client.close(); + }); + + /** + * RTL32e - params sent in ProtocolMessage.params + */ + it('RTL32e - params included in ProtocolMessage', async function () { + const { mock, captured } = setupMock({ + onMessage: (msg) => { + if (msg.action === 15) { + mock.active_connection!.send_to_client({ + action: 1, + msgSerial: msg.msgSerial, + count: 1, + res: [{ serials: ['vs-1'] }], + }); + } + }, + }); + 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-RTL32e', { attachOnSubscribe: false }); + await channel.attach(); + + await channel.updateMessage({ serial: 'serial-1', data: 'data' }, undefined, { key1: 'value1', key2: 'value2' }); + + expect(captured.length).to.equal(1); + expect(captured[0].params).to.deep.equal({ key1: 'value1', key2: 'value2' }); + client.close(); + }); + + /** + * RTL32a - Serial validation: empty serial throws + */ + it('RTL32a - empty serial throws error', async function () { + const { mock } = setupMock(); + 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-RTL32a', { attachOnSubscribe: false }); + await channel.attach(); + + // No serial + try { + await channel.updateMessage({ name: 'event', data: 'data' } as any); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40003); + } + + // Empty serial + try { + await channel.deleteMessage({ serial: '', data: 'data' }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err.code).to.equal(40003); + } + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channel_when_state.test.ts b/test/uts/realtime/channels/channel_when_state.test.ts new file mode 100644 index 000000000..f65f7aec0 --- /dev/null +++ b/test/uts/realtime/channels/channel_when_state.test.ts @@ -0,0 +1,231 @@ +/** + * UTS: Channel whenState Tests + * + * Spec points: RTL25, RTL25a, RTL25b + * Source: uts/test/realtime/unit/channels/channel_when_state_test.md + * + * Tests the whenState convenience function: + * - Resolves immediately if already in target state (with null) + * - Waits for state transition if not in target state (with ChannelStateChange) + * - Only fires once per call + * - Does not resolve for past states + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channel_when_state', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTL25a - whenState resolves immediately if already in target state + */ + it('RTL25a - whenState resolves immediately when in target state', 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-RTL25a'); + await channel.attach(); + + // Already attached — should resolve immediately with null + const result = await channel.whenState('attached'); + expect(result).to.be.null; + client.close(); + }); + + /** + * RTL25b - whenState waits for state transition + */ + it('RTL25b - whenState waits for state then resolves', 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-RTL25b'); + + // Channel is in initialized state — start waiting for attached + const whenStatePromise = channel.whenState('attached'); + + // Attach triggers the transition + await channel.attach(); + + const result = await whenStatePromise; + + // Result should be a ChannelStateChange (not null) + expect(result).to.not.be.null; + expect(result!.current).to.equal('attached'); + expect(result!.previous).to.satisfy((p: string) => p === 'initialized' || p === 'attaching'); + client.close(); + }); + + /** + * RTL25b - whenState only fires once + */ + it('RTL25b - whenState is one-shot', 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, + }); + } + if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTL25b-once'); + + let attachCount = 0; + channel.once('attached', () => { + attachCount++; + }); + + // Start whenState before attach + const whenStatePromise = channel.whenState('attached'); + + // First attach + await channel.attach(); + const result = await whenStatePromise; + expect(result).to.not.be.null; + expect(attachCount).to.equal(1); + + // Detach then re-attach + await channel.detach(); + await channel.attach(); + + // Wait a bit + await flushAsync(); + + // once listener fired only once + expect(attachCount).to.equal(1); + client.close(); + }); + + /** + * RTL25a - whenState for past state does NOT resolve + */ + it('RTL25a - whenState for past state does not resolve', 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-RTL25a-past'); + + // Attach — channel passes through attaching to attached + await channel.attach(); + expect(channel.state).to.equal('attached'); + + // Call whenState for 'attaching' — a past state + let resolved = false; + channel.whenState('attaching').then(() => { + resolved = true; + }); + + // Wait to see if it resolves + await flushAsync(); + + // Should NOT have resolved + expect(resolved).to.be.false; + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/channels_collection.test.ts b/test/uts/realtime/channels/channels_collection.test.ts new file mode 100644 index 000000000..e04f419ac --- /dev/null +++ b/test/uts/realtime/channels/channels_collection.test.ts @@ -0,0 +1,276 @@ +/** + * UTS: Channels Collection Tests + * + * Spec points: RTS1, RTS2, RTS3a, RTS4a + * Source: uts/test/realtime/unit/channels/channels_collection.md + * + * Tests the RealtimeChannels collection: get, release, existence checks, + * iteration, and identity semantics. + * + * Deviation: ably-js has no channels.exists() method — use `name in channels.all`. + * Deviation: ably-js has no channels.names — use Object.keys(channels.all). + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/channels_collection', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTS1 - Channels collection accessible via RealtimeClient + */ + it('RTS1 - channels collection accessible via client.channels', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channels = client.channels; + expect(channels).to.not.be.null; + expect(channels).to.not.be.undefined; + expect(channels).to.have.property('get'); + expect(channels).to.have.property('release'); + client.close(); + }); + + /** + * RTS2 - Check if channel exists + * + * Deviation: ably-js has no exists() method. Use `name in channels.all`. + */ + it('RTS2 - check channel existence', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const name = 'test-RTS2'; + + // Before creation + expect(name in client.channels.all).to.be.false; + + // After creation + client.channels.get(name); + expect(name in client.channels.all).to.be.true; + + // Different channel does not exist + expect('other-channel' in client.channels.all).to.be.false; + client.close(); + }); + + /** + * RTS2 - Iterate through existing channels + * + * Deviation: ably-js has no channels.names — use Object.keys(channels.all). + */ + it('RTS2 - iterate through existing channels', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.channels.get('chan-a'); + client.channels.get('chan-b'); + client.channels.get('chan-c'); + + const names = Object.keys(client.channels.all); + expect(names).to.include('chan-a'); + expect(names).to.include('chan-b'); + expect(names).to.include('chan-c'); + expect(names).to.have.length(3); + client.close(); + }); + + /** + * RTS3a - Get creates new channel if none exists + */ + it('RTS3a - get creates new channel', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTS3a'); + expect(channel).to.not.be.null; + expect(channel.name).to.equal('test-RTS3a'); + expect('test-RTS3a' in client.channels.all).to.be.true; + client.close(); + }); + + /** + * RTS3a - Get returns existing channel (same reference) + */ + it('RTS3a - get returns same channel instance', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel1 = client.channels.get('test-RTS3a-same'); + const channel2 = client.channels.get('test-RTS3a-same'); + + expect(channel1).to.equal(channel2); + expect(channel1.name).to.equal('test-RTS3a-same'); + client.close(); + }); + + /** + * RTS4a - Release removes channel from collection + */ + it('RTS4a - release removes channel', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.channels.get('test-RTS4a'); + expect('test-RTS4a' in client.channels.all).to.be.true; + + // Channel is in 'initialized' state, so release succeeds + client.channels.release('test-RTS4a'); + expect('test-RTS4a' in client.channels.all).to.be.false; + client.close(); + }); + + /** + * RTS4a - Release on non-existent channel is no-op + */ + it('RTS4a - release non-existent channel is no-op', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Should not throw + client.channels.release('does-not-exist'); + expect('does-not-exist' in client.channels.all).to.be.false; + client.close(); + }); + + /** + * RTS4a - Release detaches and removes attached channel + * + * Per spec: "Detaches the channel and then releases the channel resource + * i.e. it's deleted and can then be garbage collected" + */ + it('RTS4a - release detaches and removes attached channel', 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, + channel: msg.channel, + flags: 0, + }); + } else if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, + channel: msg.channel, + }); + } + }, + }); + 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-RTS4a-attached'); + await channel.attach(); + expect(channel.state).to.equal('attached'); + + client.channels.release('test-RTS4a-attached'); + + // release() detaches asynchronously then removes via .then() after detach resolves + await new Promise((resolve) => channel.once('detached', resolve)); + // The delete happens in .then() after the detach promise resolves — yield to let it execute + await flushAsync(); + + // Channel should be removed from the collection + expect('test-RTS4a-attached' in client.channels.all).to.be.false; + client.close(); + }); + + /** + * RTS3a - Get after release creates new channel instance + */ + it('RTS3a - get after release creates new instance', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel1 = client.channels.get('test-release-reget'); + client.channels.release('test-release-reget'); + + const channel2 = client.channels.get('test-release-reget'); + expect(channel1).to.not.equal(channel2); + expect(channel2.name).to.equal('test-release-reget'); + expect('test-release-reget' in client.channels.all).to.be.true; + client.close(); + }); + + /** + * RTS3a - Subscript operator (bracket notation) creates or returns channel + * + * Deviation: ably-js does not have a true subscript operator for channels, + * but channels.all[name] provides similar read access to the channel map. + * This test verifies that channels.all[name] returns the same channel as + * channels.get(name) after creation. + */ + it('RTS3a - channels.all bracket access returns same channel', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Create channel via get() + const channel1 = client.channels.get('test-subscript'); + + // Access via bracket notation on channels.all + const channel2 = client.channels.all['test-subscript']; + + // Use get() again + const channel3 = client.channels.get('test-subscript'); + + expect(channel1).to.equal(channel2); + expect(channel2).to.equal(channel3); + expect(channel1.name).to.equal('test-subscript'); + client.close(); + }); +}); diff --git a/test/uts/realtime/channels/message_field_population.test.ts b/test/uts/realtime/channels/message_field_population.test.ts new file mode 100644 index 000000000..17e2dfc1a --- /dev/null +++ b/test/uts/realtime/channels/message_field_population.test.ts @@ -0,0 +1,467 @@ +/** + * UTS: Message Field Population Tests + * + * Spec points: TM2a, TM2c, TM2f + * Source: uts/test/realtime/unit/channels/message_field_population_test.md + * + * Tests that message fields (id, connectionId, timestamp) are populated + * from the enclosing ProtocolMessage when not set on individual messages. + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/channels/message_field_population', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * TM2a - Message id populated from ProtocolMessage id and index + */ + it('TM2a - id derived from ProtocolMessage id:index', 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-TM2a', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // Send ProtocolMessage with id and 3 messages without ids + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-TM2a', + id: 'abc123:5', + messages: [ + { name: 'msg1', data: 'one' }, + { name: 'msg2', data: 'two' }, + { name: 'msg3', data: 'three' }, + ], + }); + + await flushAsync(); + + expect(received.length).to.equal(3); + expect(received[0].id).to.equal('abc123:5:0'); + expect(received[1].id).to.equal('abc123:5:1'); + expect(received[2].id).to.equal('abc123:5:2'); + client.close(); + }); + + /** + * TM2a - Message with existing id is not overwritten + */ + it('TM2a - existing id not overwritten', 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-TM2a-existing', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-TM2a-existing', + id: 'proto-id:0', + messages: [{ name: 'msg', data: 'data', id: 'my-custom-id' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].id).to.equal('my-custom-id'); + client.close(); + }); + + /** + * TM2c - Message connectionId populated from ProtocolMessage + */ + it('TM2c - connectionId from ProtocolMessage', 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-TM2c', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-TM2c', + connectionId: 'server-conn-xyz', + messages: [{ name: 'msg', data: 'data' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].connectionId).to.equal('server-conn-xyz'); + client.close(); + }); + + /** + * TM2f - Message timestamp populated from ProtocolMessage + */ + it('TM2f - timestamp from ProtocolMessage', 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-TM2f', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-TM2f', + timestamp: 1700000000000, + messages: [{ name: 'msg', data: 'data' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].timestamp).to.equal(1700000000000); + client.close(); + }); + + /** + * TM2a, TM2c, TM2f - All fields populated together + */ + it('TM2a+c+f - all fields populated from ProtocolMessage', 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-TM2-all', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + mock.active_connection!.send_to_client({ + action: 15, + channel: 'test-TM2-all', + id: 'connId:7', + connectionId: 'connId', + timestamp: 1700000000000, + messages: [ + { name: 'msg1', data: 'one' }, + { name: 'msg2', data: 'two' }, + ], + }); + + await flushAsync(); + + expect(received.length).to.equal(2); + expect(received[0].id).to.equal('connId:7:0'); + expect(received[0].connectionId).to.equal('connId'); + expect(received[0].timestamp).to.equal(1700000000000); + expect(received[1].id).to.equal('connId:7:1'); + expect(received[1].connectionId).to.equal('connId'); + expect(received[1].timestamp).to.equal(1700000000000); + client.close(); + }); + + /** + * TM2a - No id when ProtocolMessage has no id + * + * When the ProtocolMessage itself has no id field, messages without + * their own id should remain without one. + */ + it('TM2a - no id when ProtocolMessage has no id', 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-TM2a-no-proto-id', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // ProtocolMessage has no id field — messages should not get computed ids + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-TM2a-no-proto-id', + connectionId: 'abc123', + messages: [{ name: 'msg', data: 'hello' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].id).to.satisfy( + (v: any) => v === null || v === undefined, + 'Message id should be null/undefined when ProtocolMessage has no id', + ); + client.close(); + }); + + /** + * TM2c - Message with existing connectionId is not overwritten + * + * A message that already has its own connectionId should retain it, + * not have it overwritten by the ProtocolMessage connectionId. + */ + it('TM2c - existing connectionId not overwritten', 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-TM2c-existing', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // Message already has its own connectionId + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-TM2c-existing', + connectionId: 'proto-conn', + messages: [{ connectionId: 'msg-conn', name: 'msg', data: 'hello' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].connectionId).to.equal('msg-conn'); + client.close(); + }); + + /** + * TM2f - Message with existing timestamp is not overwritten + * + * A message that already has its own timestamp should retain it, + * not have it overwritten by the ProtocolMessage timestamp. + */ + it('TM2f - existing timestamp not overwritten', 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-TM2f-existing', { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.subscribe((msg: any) => received.push(msg)); + + // Message already has its own timestamp + mock.active_connection!.send_to_client({ + action: 15, // MESSAGE + channel: 'test-TM2f-existing', + timestamp: 1700000000000, + messages: [{ timestamp: 1600000000000, name: 'msg', data: 'hello' }], + }); + + await flushAsync(); + + expect(received.length).to.equal(1); + expect(received[0].timestamp).to.equal(1600000000000); + client.close(); + }); +}); diff --git a/test/uts/realtime/client/client_options.test.ts b/test/uts/realtime/client/client_options.test.ts new file mode 100644 index 000000000..621d97961 --- /dev/null +++ b/test/uts/realtime/client/client_options.test.ts @@ -0,0 +1,106 @@ +/** + * UTS: Realtime Client Options Tests + * + * Spec points: RSC1, RSC1a, RSC1b, RSC1c, RTC12 + * Source: uts/test/realtime/unit/client/client_options.md + * + * RTC12: The Realtime client has the same constructors as the REST client. + */ + +import { expect } from 'chai'; +import { Ably, trackClient, restoreAll } from '../../helpers'; + +describe('uts/realtime/client/client_options', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RSC1a / RTC12 - API key string detected (contains :) + */ + 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'); + }); + + 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'); + }); + + it('RSC1a - API key string (extended secret)', function () { + const client = new Ably.Realtime({ key: 'xVLyHw.A-pwh:5WEB4HEAT3pOqWp9-the_rest', autoConnect: false }); + trackClient(client); + expect(client.options.key).to.equal('xVLyHw.A-pwh:5WEB4HEAT3pOqWp9-the_rest'); + }); + + /** + * RSC1c / RTC12 - Token string detected (no : before first .) + */ + it('RSC1c - token string (opaque)', function () { + const client = new Ably.Realtime({ token: 'abcdef1234567890', autoConnect: false }); + trackClient(client); + expect(client.options.token).to.equal('abcdef1234567890'); + }); + + it('RSC1c - token string (JWT format)', function () { + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + const client = new Ably.Realtime({ token: jwt, autoConnect: false }); + trackClient(client); + expect(client.options.token).to.equal(jwt); + }); + + /** + * RSC1b / RTC12 - No credentials raises error + */ + it('RSC1b - no credentials raises error', function () { + if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js uses 40160 instead of 40106; see #2204 + try { + new Ably.Realtime({ autoConnect: false } as any); + expect.fail('Expected constructor to throw'); + } catch (e: any) { + expect(e.code).to.equal(40106); + } + }); + + it('RSC1b - useTokenAuth without means raises error', function () { + try { + new Ably.Realtime({ useTokenAuth: true, autoConnect: false } as any); + expect.fail('Expected constructor to throw'); + } catch (e) { + expect(e).to.be.an('error'); + } + }); + + it('RSC1b - clientId alone raises error', function () { + if (!process.env.RUN_DEVIATIONS) this.skip(); // ably-js uses 40160 instead of 40106; see #2204 + try { + new Ably.Realtime({ clientId: 'test', autoConnect: false } as any); + expect.fail('Expected constructor to throw'); + } catch (e: any) { + expect(e.code).to.equal(40106); + } + }); + + /** + * RSC1 / RTC12 - ClientOptions object preserves values + */ + it('RSC1 - ClientOptions values preserved', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + clientId: 'testClient', + tls: true, + idempotentRestPublishing: true, + autoConnect: false, + }); + trackClient(client); + + expect(client.options.key).to.equal('appId.keyId:keySecret'); + expect(client.options.clientId).to.equal('testClient'); + expect(client.options.tls).to.equal(true); + expect(client.options.idempotentRestPublishing).to.equal(true); + }); +}); diff --git a/test/uts/realtime/client/realtime_client.test.ts b/test/uts/realtime/client/realtime_client.test.ts new file mode 100644 index 000000000..38ed82cce --- /dev/null +++ b/test/uts/realtime/client/realtime_client.test.ts @@ -0,0 +1,546 @@ +/** + * UTS: Realtime Client Tests + * + * Spec points: RTC1a, RTC1b, RTC1c, RTC1f, RTC2, RTC3, RTC4, RTC13, RTC15, RTC16, RTC17 + * Source: uts/test/realtime/unit/client/realtime_client.md + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + // ── Attributes (no WebSocket mock needed) ───────────────────────── + + /** + * RTC2 - Connection attribute + */ + it('RTC2 - client.connection is accessible', function () { + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); + trackClient(client); + expect(client.connection).to.not.be.null; + expect(client.connection).to.not.be.undefined; + expect(client.connection.state).to.equal('initialized'); + expect(typeof client.connection.connect).to.equal('function'); + expect(typeof client.connection.close).to.equal('function'); + expect(typeof client.connection.ping).to.equal('function'); + }); + + /** + * RTC3 - Channels attribute + */ + it('RTC3 - client.channels is accessible', function () { + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); + trackClient(client); + expect(client.channels).to.not.be.null; + expect(client.channels).to.not.be.undefined; + + const ch = client.channels.get('test-RTC3'); + expect(ch).to.not.be.null; + expect(ch.name).to.equal('test-RTC3'); + }); + + /** + * RTC4 - Auth attribute + */ + it('RTC4 - client.auth is accessible', function () { + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); + trackClient(client); + expect(client.auth).to.not.be.null; + expect(client.auth).to.not.be.undefined; + expect(typeof client.auth.authorize).to.equal('function'); + expect(typeof client.auth.createTokenRequest).to.equal('function'); + }); + + /** + * RTC13 - Push attribute + */ + it('RTC13 - client.push is accessible', function () { + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false }); + trackClient(client); + expect(client.push).to.not.be.null; + expect(client.push).to.not.be.undefined; + expect(client.push.admin).to.not.be.null; + expect(client.push.admin).to.not.be.undefined; + }); + + /** + * RTC17 - clientId attribute + */ + it('RTC17 - clientId from options', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + clientId: 'explicit-client-id', + autoConnect: false, + }); + trackClient(client); + expect(client.clientId).to.equal('explicit-client-id'); + expect(client.clientId).to.equal(client.auth.clientId); + }); + + // ── echoMessages (RTC1a) ────────────────────────────────────────── + + /** + * RTC1a_1 - echoMessages defaults to true + */ + 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; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + echoParam = conn.url.searchParams.get('echo'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + trackClient(client); + await new Promise((resolve) => client.connection.once('connected', resolve)); + expect(echoParam).to.equal('true'); + client.close(); + }); + + /** + * RTC1a_2 - echoMessages set to false + */ + it('RTC1a - echoMessages false sends echo=false', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('echo')).to.equal('false'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + echoMessages: false, + useBinaryProtocol: false, + }); + trackClient(client); + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + // ── autoConnect (RTC1b) ─────────────────────────────────────────── + + /** + * RTC1b_1 - autoConnect defaults to true + */ + it('RTC1b - autoConnect defaults to true', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Not passing autoConnect: false — should connect immediately + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', useBinaryProtocol: false }); + trackClient(client); + + client.connection.once('connected', () => { + expect(mock.connect_attempts).to.have.length.at.least(1); + client.close(); + done(); + }); + }); + + /** + * RTC1b_2 - autoConnect set to false + */ + it('RTC1b - autoConnect false stays initialized', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: () => { + throw new Error('Should not attempt connection'); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + expect(mock.connect_attempts).to.have.length(0); + + await flushAsync(); + + expect(client.connection.state).to.equal('initialized'); + expect(mock.connect_attempts).to.have.length(0); + }); + + /** + * RTC1b_3 - Explicit connect after autoConnect false + */ + it('RTC1b - explicit connect after autoConnect false', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + client.connection.once('connected', () => { + expect(mock.connect_attempts).to.have.length(1); + client.close(); + done(); + }); + + client.connect(); + }); + + // ── recover (RTC1c) ────────────────────────────────────────────── + + /** + * RTC1c_1 - recover string sent in connection request + */ + it('RTC1c - recover key sent in URL', function (done) { + const recoveryKey = JSON.stringify({ + connectionKey: 'previous-connection-key', + msgSerial: 5, + channelSerials: { channel1: 'serial1' }, + }); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('recover')).to.equal('previous-connection-key'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + recover: recoveryKey, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + /** + * RTC1c_3 - Invalid recovery key handled gracefully + */ + it('RTC1c - invalid recovery key handled gracefully', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + // Invalid key should not appear as recover param + expect(conn.url.searchParams.get('recover')).to.be.null; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + recover: 'invalid-not-a-valid-recovery-key', + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + // ── transportParams (RTC1f) ─────────────────────────────────────── + + /** + * RTC1f_1 - transportParams included in connection URL + */ + it('RTC1f - transportParams in URL', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('customParam')).to.equal('customValue'); + expect(conn.url.searchParams.get('anotherParam')).to.equal('123'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + transportParams: { customParam: 'customValue', anotherParam: '123' }, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + /** + * RTC1f_2 - transportParams with different value types + */ + it('RTC1f - transportParams value types stringified', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('stringParam')).to.equal('hello'); + expect(conn.url.searchParams.get('numberParam')).to.equal('42'); + expect(conn.url.searchParams.get('boolTrueParam')).to.equal('true'); + expect(conn.url.searchParams.get('boolFalseParam')).to.equal('false'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + transportParams: { + stringParam: 'hello', + numberParam: 42, + boolTrueParam: true, + boolFalseParam: false, + }, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + /** + * RTC1f1 - transportParams override library defaults + */ + it('RTC1f1 - transportParams override defaults', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('v')).to.equal('3'); + expect(conn.url.searchParams.get('heartbeats')).to.equal('false'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + transportParams: { v: '3', heartbeats: 'false' }, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + // ── connect / close (RTC15, RTC16) ──────────────────────────────── + + /** + * RTC15a - connect() calls Connection#connect + */ + it('RTC15 - connect() proxies to connection', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + client.connection.once('connected', () => { + expect(client.connection.state).to.equal('connected'); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTC16a - close() calls Connection#close + */ + it('RTC16 - close() proxies to connection', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg: any, conn: any) => { + if (msg.action === 7) { + // CLOSE + conn.send_to_client({ action: 8 }); // CLOSED + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('closed', () => { + expect(client.connection.state).to.equal('closed'); + done(); + }); + client.close(); + }); + }); + + // ── Connection URL parameters ───────────────────────────────────── + + /** + * Standard query parameters present in connection URL + */ + it('Standard query params in connection URL', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + // v (protocol version) + expect(conn.url.searchParams.get('v')).to.match(/^\d+$/); + // format + expect(conn.url.searchParams.get('format')).to.equal('json'); + // heartbeats + expect(conn.url.searchParams.has('heartbeats')).to.be.true; + // key (basic auth) + expect(conn.url.searchParams.get('key')).to.equal('appId.keyId:keySecret'); + + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + /** + * RSC18 - TLS setting affects WebSocket URL scheme + */ + it('RSC18 - TLS true uses wss://', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.protocol).to.equal('wss:'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + tls: true, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + it('RSC18 - TLS false uses ws://', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.protocol).to.equal('ws:'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + token: 'test-token', + tls: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + /** + * useBinaryProtocol affects format query param + */ + it('useBinaryProtocol false sends format=json', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('format')).to.equal('json'); + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.close(); + done(); + }); + }); + + it('useBinaryProtocol true sends format=msgpack', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + expect(conn.url.searchParams.get('format')).to.equal('msgpack'); + // Don't try to deliver CONNECTED — msgpack would fail + // Just verify the URL param + done(); + }, + }); + installMockWebSocket(mock.constructorFn); + + trackClient( + new Ably.Realtime({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: true, + }), + ); + }); +}); diff --git a/test/uts/realtime/client/realtime_request.test.ts b/test/uts/realtime/client/realtime_request.test.ts new file mode 100644 index 000000000..a731a4d0a --- /dev/null +++ b/test/uts/realtime/client/realtime_request.test.ts @@ -0,0 +1,149 @@ +/** + * UTS: Realtime Client Request Tests + * + * Spec points: RTC9 + * Source: uts/test/realtime/unit/client/realtime_request.md + * + * RTC9: RealtimeClient#request proxies to RestClient#request. + * These are representative tests from the REST request suite using a Realtime client. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, trackClient, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/realtime/client/realtime_request', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTC9 / RSC19 - GET request + */ + it('RTC9 - request() sends GET', 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.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + const result = await client.request('get', '/test', 2, null as any, null as any, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('get'); + expect(captured[0].path).to.equal('/test'); + expect(result.statusCode).to.equal(200); + expect(result.success).to.be.true; + client.close(); + }); + + /** + * RTC9 / RSC19 - POST request with body + */ + it('RTC9 - request() sends POST with body', async function () { + const captured: any[] = []; + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured.push(req); + req.respond_with(201, { id: 'created' }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + const result = await client.request('post', '/items', 2, null as any, { name: 'test' }, null as any); + + expect(captured).to.have.length(1); + expect(captured[0].method).to.equal('post'); + expect(result.statusCode).to.equal(201); + client.close(); + }); + + /** + * RTC9 / RSC19 - request() with query params + */ + it('RTC9 - request() passes query 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, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + const result = await client.request( + 'get', + '/test', + 2, + { limit: '5', direction: 'forwards' }, + null as any, + null as any, + ); + + expect(captured).to.have.length(1); + expect(captured[0].url.searchParams.get('limit')).to.equal('5'); + expect(captured[0].url.searchParams.get('direction')).to.equal('forwards'); + client.close(); + }); + + /** + * RTC9 / RSC19 - HttpPaginatedResponse structure + */ + it('RTC9 - returns HttpPaginatedResponse', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ id: 'item1' }, { id: 'item2' }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + const result = await client.request('get', '/items', 2, null as any, null as any, null as any); + + expect(result.statusCode).to.equal(200); + expect(result.success).to.be.true; + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(2); + client.close(); + }); + + /** + * RTC9 / RSC19 - Error response + */ + it('RTC9 - error response has correct fields', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(404, { error: { message: 'Not found', code: 40400, statusCode: 404 } }); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + const result = await client.request('get', '/missing', 2, null as any, null as any, null as any); + + expect(result.statusCode).to.equal(404); + expect(result.success).to.be.false; + expect(result.errorCode).to.equal(40400); + client.close(); + }); +}); diff --git a/test/uts/realtime/client/realtime_stats.test.ts b/test/uts/realtime/client/realtime_stats.test.ts new file mode 100644 index 000000000..9f75bf0d6 --- /dev/null +++ b/test/uts/realtime/client/realtime_stats.test.ts @@ -0,0 +1,97 @@ +/** + * UTS: Realtime Client Stats Tests + * + * Spec points: RTC5, RTC5a, RTC5b + * Source: uts/test/realtime/unit/client/realtime_stats.md + * + * RTC5: RealtimeClient#stats proxies to RestClient#stats. + * These are representative tests from the REST stats suite using a Realtime client. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, trackClient, installMockHttp, restoreAll } from '../../helpers'; + +describe('uts/realtime/client/realtime_stats', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTC5a - stats() sends GET /stats + */ + it('RTC5a - stats() sends GET /stats', 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.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + try { + await client.stats({} as any); + } catch (e) { + // Response parsing may fail — we only care about the request + } + + expect(captured).to.have.length.at.least(1); + expect(captured[0].method.toUpperCase()).to.equal('GET'); + expect(captured[0].path).to.equal('/stats'); + client.close(); + }); + + /** + * RTC5b - stats() accepts params + */ + it('RTC5b - stats() passes query 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, []); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + try { + await client.stats({ start: '1704067200000', limit: '10', direction: 'forwards' } as any); + } catch (e) { + // Response parsing may fail + } + + expect(captured).to.have.length.at.least(1); + expect(captured[0].url.searchParams.get('start')).to.equal('1704067200000'); + expect(captured[0].url.searchParams.get('limit')).to.equal('10'); + expect(captured[0].url.searchParams.get('direction')).to.equal('forwards'); + client.close(); + }); + + /** + * RTC5 - stats() returns PaginatedResult + */ + it('RTC5 - stats() returns PaginatedResult', async function () { + const mock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [{ all: { messages: { count: 10 } } }]); + }, + }); + installMockHttp(mock); + + const client = new Ably.Realtime({ key: 'appId.keyId:keySecret', autoConnect: false, useBinaryProtocol: false }); + trackClient(client); + const result = await client.stats({} as any); + + expect(result.items).to.be.an('array'); + expect(result.items).to.have.length(1); + client.close(); + }); +}); diff --git a/test/uts/realtime/client/realtime_time.test.ts b/test/uts/realtime/client/realtime_time.test.ts new file mode 100644 index 000000000..0d2b7e2b7 --- /dev/null +++ b/test/uts/realtime/client/realtime_time.test.ts @@ -0,0 +1,50 @@ +/** + * UTS: RealtimeClient Time Tests + * + * Spec points: RTC6, RTC6a + * Source: uts/test/realtime/unit/client/realtime_time.md + * + * RTC6a: RealtimeClient#time proxies to RestClient#time. + */ + +import { expect } from 'chai'; +import { MockHttpClient } from '../../mock_http'; +import { Ably, installMockHttp, restoreAll, trackClient } from '../../helpers'; + +describe('uts/realtime/client/realtime_time', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTC6a - RealtimeClient#time proxies to RestClient#time + * + * time() makes a GET request to /time and returns the server timestamp. + */ + it('RTC6a - time() returns server time', async function () { + const serverTime = 1625000000000; + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + if (req.path.includes('/time')) { + req.respond_with(200, [serverTime]); + } else { + req.respond_with(404, { error: 'Not found' }); + } + }, + }); + installMockHttp(httpMock); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const time = await client.time(); + expect(time).to.equal(serverTime); + client.close(); + }); +}); diff --git a/test/uts/realtime/client/realtime_timeouts.test.ts b/test/uts/realtime/client/realtime_timeouts.test.ts new file mode 100644 index 000000000..fff8f0f2d --- /dev/null +++ b/test/uts/realtime/client/realtime_timeouts.test.ts @@ -0,0 +1,264 @@ +/** + * UTS: Realtime Client Configured Timeouts + * + * Spec points: RTC7 + * Source: uts/test/realtime/unit/client/realtime_timeouts.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'; + +/** + * Helper: wait for connection state using real event loop. + * Fake timers only replace Platform.Config — Node.js setTimeout still works. + */ +function waitForState(connection: any, state: any, timeoutMs: any) { + return new Promise((resolve, reject) => { + if (connection.state === state) return resolve(); + const timer = setTimeout(() => reject(new Error(`Timeout waiting for state ${state}`)), timeoutMs || 5000); + connection.once(state, () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +/** + * Helper: flush fake timers + real event loop to let connection establish. + * ably-js uses Platform.Config.setTimeout(fn, 0) for scheduling and real + * async chains for auth. This pumps both until the client connects. + */ +async function connectWithFakeTimers(client: any, clock: any) { + client.connect(); + // Pump fake timers and real event loop in alternation + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } +} + +describe('uts/realtime/client/realtime_timeouts', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTC7 - default timeouts applied when not configured + */ + it('RTC7 - default timeouts', function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + }); + trackClient(client); + + expect(client.options.timeouts.realtimeRequestTimeout).to.equal(10000); + expect(client.options.timeouts.disconnectedRetryTimeout).to.equal(15000); + expect(client.options.timeouts.suspendedRetryTimeout).to.equal(30000); + expect(client.options.timeouts.httpRequestTimeout).to.equal(10000); + // NOTE: UTS spec checks httpOpenTimeout == 4000. + // ably-js uses webSocketSlowTimeout (4000) and webSocketConnectTimeout (10000) instead. + expect(client.options.timeouts.webSocketSlowTimeout).to.equal(4000); + expect(client.options.timeouts.webSocketConnectTimeout).to.equal(10000); + client.close(); + }); + + /** + * RTC7 - custom realtimeRequestTimeout applied to channel attach + * + * When the server does not respond to ATTACH within the custom timeout, + * the channel should transition to SUSPENDED (RTL4f). + */ + it('RTC7 - realtimeRequestTimeout on attach', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + // Do NOT respond — simulate timeout + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 500, + }); + trackClient(client); + + await connectWithFakeTimers(client, clock); + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-attach-timeout'); + const attachPromise = channel.attach(); + + // Pump to let ATTACH message be sent + for (let i = 0; i < 5; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past the custom timeout + await clock.tickAsync(600); + + try { + await attachPromise; + expect.fail('Expected attach to fail'); + } catch (err) { + expect(err).to.not.be.null; + } + + // RTL4f: attach timeout → SUSPENDED + expect(channel.state).to.equal('suspended'); + client.close(); + }); + + /** + * RTC7 - custom realtimeRequestTimeout applied to channel detach + * + * When the server does not respond to DETACH within the custom timeout, + * the channel should return to ATTACHED (RTL5f). + */ + it('RTC7 - realtimeRequestTimeout on detach', async function () { + let ignoreDetach = false; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg: any, conn: any) => { + if (msg.action === 10) { + // ATTACH + conn.send_to_client({ action: 11, channel: msg.channel, flags: 0 }); // ATTACHED + } + if (msg.action === 12 && ignoreDetach) { + // DETACH + // Do NOT respond — simulate timeout + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 500, + }); + trackClient(client); + + await connectWithFakeTimers(client, clock); + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-detach-timeout'); + + const attachPromise = channel.attach(); + // Pump to let ATTACH and ATTACHED messages flow + for (let i = 0; i < 10; i++) { + clock.tick(0); + await flushAsync(); + } + await attachPromise; + + // Now ignore DETACH messages + ignoreDetach = true; + + const detachPromise = channel.detach(); + + // Advance past the custom timeout + await clock.tickAsync(600); + + try { + await detachPromise; + expect.fail('Expected detach to fail'); + } catch (err) { + expect(err).to.not.be.null; + } + + // RTL5f: detach timeout → back to ATTACHED + expect(channel.state).to.equal('attached'); + client.close(); + }); + + /** + * RTC7 - custom disconnectedRetryTimeout controls reconnection delay + * + * After disconnect, RTN15a triggers an immediate retry. If that fails too, + * the library waits disconnectedRetryTimeout before the next attempt. + */ + it('RTC7 - disconnectedRetryTimeout controls retry delay', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + if (connectionAttemptCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionDetails: { maxIdleInterval: 0, connectionStateTtl: 120000 } as any, + }); + } else { + // All subsequent attempts fail + 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', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 2000, + fallbackHosts: [], + }); + trackClient(client); + + await connectWithFakeTimers(client, clock); + expect(connectionAttemptCount).to.equal(1); + + // Force disconnection + mock.active_connection!.simulate_disconnect(); + + // Pump to process disconnect + immediate retry (RTN15a) + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + const countAfterImmediate = connectionAttemptCount; + + // Advance less than custom timeout — no new retry yet + await clock.tickAsync(1500); + expect(connectionAttemptCount).to.equal(countAfterImmediate); + + // Advance past the custom timeout (2000ms total + margin) + await clock.tickAsync(1500); + + // A new reconnection attempt should have been made + expect(connectionAttemptCount).to.be.greaterThan(countAfterImmediate); + client.close(); + }); +}); diff --git a/test/uts/realtime/connection/auto_connect.test.ts b/test/uts/realtime/connection/auto_connect.test.ts new file mode 100644 index 000000000..063487b18 --- /dev/null +++ b/test/uts/realtime/connection/auto_connect.test.ts @@ -0,0 +1,122 @@ +/** + * UTS: Connection Auto Connect Tests + * + * Spec points: RTN3 + * Source: uts/test/realtime/unit/connection/auto_connect_test.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; + +describe('uts/realtime/connection/auto_connect', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN3 - autoConnect true initiates connection immediately + */ + it('RTN3 - autoConnect true initiates connection immediately', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Create client with default autoConnect (true) — do NOT call connect() + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.equal('connection-id'); + client.close(); + done(); + }); + }); + + /** + * RTN3 - autoConnect false does not initiate connection + */ + it('RTN3 - autoConnect false does not initiate connection', async function () { + let connectionAttempted = false; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttempted = true; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + await flushAsync(); + + expect(connectionAttempted).to.be.false; + expect(client.connection.state).to.equal('initialized'); + expect(mock.connect_attempts).to.have.length(0); + }); + + /** + * RTN3 - explicit connect after autoConnect false + */ + it('RTN3 - explicit connect after autoConnect false', function (done) { + let connectionAttempted = false; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttempted = true; + 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); + + // Verify no connection yet + expect(client.connection.state).to.equal('initialized'); + expect(connectionAttempted).to.be.false; + + client.connection.once('connected', () => { + expect(connectionAttempted).to.be.true; + expect(client.connection.state).to.equal('connected'); + client.close(); + done(); + }); + + // Explicitly connect + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/connection_failures.test.ts b/test/uts/realtime/connection/connection_failures.test.ts new file mode 100644 index 000000000..f5baf0564 --- /dev/null +++ b/test/uts/realtime/connection/connection_failures.test.ts @@ -0,0 +1,844 @@ +/** + * UTS: Connection Failures When Connected Tests + * + * Spec points: RTN15a, RTN15b, RTN15c4, RTN15c5, RTN15c6, RTN15c7, RTN15e, RTN15g, RTN15h1, RTN15h2, RTN15h3, RTN15j + * Source: uts/test/realtime/unit/connection/connection_failures_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'; + +async function pumpTimers(clock: any, iterations = 30) { + for (let i = 0; i < iterations; i++) { + clock.tick(0); + await flushAsync(); + } +} + +describe('uts/realtime/connection/connection_failures', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN15a - Unexpected transport disconnect triggers resume + */ + it('RTN15a - unexpected disconnect triggers resume', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + 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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + let sawDisconnected = false; + client.connection.on('disconnected', () => { + sawDisconnected = true; + }); + + client.connection.once('connected', () => { + const originalId = client.connection.id; + + // Listen for reconnection + client.connection.on('connected', () => { + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.equal(originalId); + expect(connectionAttemptCount).to.equal(2); + expect(sawDisconnected).to.be.true; + done(); + }); + + // Unexpected disconnect + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN15b, RTN15c6 - Successful resume preserves connectionId, uses resume param + */ + it('RTN15b, RTN15c6 - successful resume with connectionKey in URL', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Resume succeeds (same connectionId) + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1-updated', + 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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(client.connection.id).to.equal('connection-1'); + + client.connection.on('connected', () => { + // Connection resumed (same ID) + expect(client.connection.id).to.equal('connection-1'); + // Connection key updated (RTN15e) + expect(client.connection.key).to.equal('key-1-updated'); + + // Second connection attempt included resume parameter + const resumeConn = mock.connect_attempts[1]; + expect(resumeConn.url.searchParams.get('resume')).to.equal('key-1'); + + expect(connectionAttemptCount).to.equal(2); + done(); + }); + + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN15e - Connection key updated on resume + * + * Per spec: When connection is resumed, Connection.key may change and is + * provided in CONNECTED message connectionDetails. + */ + it('RTN15e - connection key updated on resume', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Resume succeeds with updated key + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1-updated', + 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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(client.connection.key).to.equal('key-1'); + + client.connection.on('connected', () => { + // Connection key should be updated after resume + expect(client.connection.key).to.equal('key-1-updated'); + // Connection ID preserved (successful resume) + expect(client.connection.id).to.equal('connection-1'); + done(); + }); + + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN15c7 - Failed resume (new connectionId) resets state + * + * Per spec: CONNECTED with new connectionId and ErrorInfo in error field. + * The error should be set as Connection#errorReason and as the reason + * in the CONNECTED event. + */ + it('RTN15c7 - failed resume gets new connectionId', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Resume failed: new connectionId + error + conn.respond_with_connected({ + connectionId: 'connection-2', + connectionDetails: { + connectionKey: 'key-2', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + error: { + code: 80008, + statusCode: 400, + message: 'Unable to recover connection', + }, + }); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + const originalId = client.connection.id; + expect(originalId).to.equal('connection-1'); + + client.connection.on('connected', (stateChange: any) => { + // New connection (different ID = failed resume) + expect(client.connection.id).to.equal('connection-2'); + expect(client.connection.id).to.not.equal(originalId); + expect(client.connection.key).to.equal('key-2'); + expect(client.connection.state).to.equal('connected'); + + // Error reason set from failed resume + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(80008); + + // CONNECTED event should carry the error as reason + expect(stateChange.reason).to.not.be.null; + expect(stateChange.reason.code).to.equal(80008); + done(); + }); + + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN15g - Connection state cleared after connectionStateTtl (no resume) + */ + it('RTN15g - no resume after connectionStateTtl expires', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 5000, // Short TTL for testing + } as any, + }); + } else if (connectionAttemptCount < 6) { + // Reconnection attempts fail + conn.respond_with_refused(); + } else { + // Fresh connection succeeds + conn.respond_with_connected({ + connectionId: 'connection-2', + connectionDetails: { + connectionKey: 'key-2', + 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 clock = enableFakeTimers(); + + const stateChanges: string[] = []; + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 2000, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + + // Force disconnect + mock.active_connection!.simulate_disconnect(); + + // Advance time in increments to allow retries and TTL expiry + for (let i = 0; i < 15; i++) { + await clock.tickAsync(2500); + await pumpTimers(clock); + if (client.connection.state === 'connected') break; + } + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.equal('connection-2'); + expect(client.connection.key).to.equal('key-2'); + + // Verify state changes included suspended + expect(stateChanges).to.include('suspended'); + + // Final connection attempt did NOT include resume param + const lastConn = mock.connect_attempts[mock.connect_attempts.length - 1]; + expect(lastConn.url.searchParams.has('resume')).to.be.false; + client.close(); + }); + + /** + * RTN15h1 - DISCONNECTED with token error, no means to renew → FAILED + */ + it('RTN15h1 - token error without renewal causes FAILED', 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({ + token: 'some_token_string', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('failed', () => { + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + done(); + }); + + // Server sends DISCONNECTED with token error + mock.active_connection!.send_to_client_and_close({ + action: 6, // DISCONNECTED + error: { + message: 'Token expired', + code: 40142, + statusCode: 401, + }, + }); + }); + + client.connect(); + }); + + /** + * RTN15h2 - DISCONNECTED with token error, renewable token → reconnect + */ + it('RTN15h2 - token error with renewal reconnects', function (done) { + let connectionAttemptCount = 0; + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Resume after token renewal + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1-renewed', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, `token-${authCallbackCount}`); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + const firstId = client.connection.id; + + client.connection.on('connected', () => { + expect(client.connection.state).to.equal('connected'); + // Token was renewed (authCallback called again) + expect(authCallbackCount).to.be.at.least(2); + expect(connectionAttemptCount).to.equal(2); + done(); + }); + + // Server sends DISCONNECTED with token error + mock.active_connection!.send_to_client_and_close({ + action: 6, // DISCONNECTED + error: { + message: 'Token expired', + code: 40142, + statusCode: 401, + }, + }); + }); + + client.connect(); + }); + + /** + * RTN15h3 - DISCONNECTED with non-token error → immediate resume + */ + it('RTN15h3 - non-token disconnect triggers resume', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Resume succeeds (same ID) + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + 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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + const originalId = client.connection.id; + + // Server sends DISCONNECTED with non-token error + mock.active_connection!.send_to_client_and_close({ + action: 6, // DISCONNECTED + error: { + message: 'Service unavailable', + code: 80003, + statusCode: 503, + }, + }); + + // Advance past retry timeout + await clock.tickAsync(200); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + expect(client.connection.id).to.equal(originalId); + expect(connectionAttemptCount).to.equal(2); + client.close(); + }); + + /** + * RTN15c4 - Fatal ERROR during resume → FAILED + */ + it('RTN15c4 - fatal error during resume causes FAILED', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else { + // Resume attempt fails with fatal error + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 50000, statusCode: 500, message: 'Internal server error' }, + }); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('failed', () => { + expect(client.connection.state).to.equal('failed'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(50000); + expect(connectionAttemptCount).to.equal(2); + done(); + }); + + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN15c5 - Token error during resume triggers renewal + */ + it('RTN15c5 - token error during resume triggers renewal', function (done) { + let connectionAttemptCount = 0; + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + + if (connectionAttemptCount === 1) { + conn.respond_with_connected({ + connectionId: 'connection-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } else if (connectionAttemptCount === 2) { + // Resume attempt fails with token error + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + // Retry with renewed token succeeds + conn.respond_with_connected({ + connectionId: 'connection-2', + connectionDetails: { + connectionKey: 'key-2', + 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({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, `token-${authCallbackCount}`); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Track all subsequent connected events + client.connection.on('connected', () => { + expect(client.connection.state).to.equal('connected'); + // Token was renewed + expect(authCallbackCount).to.be.at.least(2); + // Three connection attempts: initial, failed resume, retry + expect(connectionAttemptCount).to.equal(3); + done(); + }); + + mock.active_connection!.simulate_disconnect(); + }); + + client.connect(); + }); + + /** + * RTN15j - ERROR with empty channel when CONNECTED → FAILED + */ + it('RTN15j - connection-level ERROR causes FAILED', 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({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('failed', () => { + 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); + done(); + }); + + // Connection-level ERROR (no channel) + mock.active_connection!.send_to_client({ + action: 9, // ERROR + error: { code: 50000, statusCode: 500, message: 'Internal error' }, + }); + }); + + client.connect(); + }); + + /** + * RTN15h2 - DISCONNECTED with token error, renewal fails → DISCONNECTED + * + * Per spec: If the DISCONNECTED message contains a token error and the library + * has the means to renew the token, but the token creation fails, the connection + * must transition to the DISCONNECTED state and set Connection#errorReason. + */ + it('RTN15h2 - token error with renewal failure causes 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); + + let authCallbackCount = 0; + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + if (authCallbackCount <= 1) { + // First call succeeds (initial connection) + cb(null, `token-${authCallbackCount}`); + } else { + // Subsequent calls fail (renewal failure) + cb(new Ably.ErrorInfo('Invalid credentials', 40101, 401)); + } + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Track state changes after initial connection to find the DISCONNECTED + // state that occurs after the failed token renewal (not the brief + // transient DISCONNECTED that may occur per RTN15h2i). + const statesAfterConnect: string[] = []; + client.connection.on((change: any) => { + statesAfterConnect.push(change.current); + + // We expect: possibly disconnected (transient), connecting (renewal attempt), + // then disconnected (renewal failed). Wait for the pattern: + // ...connecting... then disconnected. + if (change.current === 'disconnected' && statesAfterConnect.includes('connecting')) { + expect(client.connection.state).to.equal('disconnected'); + expect(client.connection.errorReason).to.not.be.null; + done(); + } + }); + + // Server sends DISCONNECTED with token error and closes connection + mock.active_connection!.send_to_client_and_close({ + action: 6, // DISCONNECTED + error: { + message: 'Token expired', + code: 40142, + statusCode: 401, + }, + }); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/connection_id_key.test.ts b/test/uts/realtime/connection/connection_id_key.test.ts new file mode 100644 index 000000000..4fda83bb6 --- /dev/null +++ b/test/uts/realtime/connection/connection_id_key.test.ts @@ -0,0 +1,365 @@ +/** + * UTS: Connection ID and Key Tests + * + * Spec points: RTN8, RTN8a, RTN8b, RTN8c, RTN9, RTN9a, RTN9b, RTN9c + * Source: uts/test/realtime/unit/connection/connection_id_key_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/connection/connection_id_key', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN8a - Connection ID is unset until connected + */ + it('RTN8a - connection.id is null before connected', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected({ + connectionId: 'unique-conn-id-1', + connectionDetails: { + connectionKey: 'conn-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Before connecting, id should be undefined/null + expect(client.connection.id).to.not.be.ok; + + client.connection.once('connected', () => { + expect(client.connection.id).to.equal('unique-conn-id-1'); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN9a - Connection key is unset until connected + */ + it('RTN9a - connection.key is null before connected', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected({ + connectionId: 'unique-conn-id-1', + connectionDetails: { + connectionKey: 'conn-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.key).to.not.be.ok; + + client.connection.once('connected', () => { + expect(client.connection.key).to.equal('conn-key-1'); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN8b - Connection ID is unique per connection + */ + it('RTN8b - connection.id is unique per client', function (done) { + let connectionCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + conn.respond_with_connected({ + connectionId: `conn-id-${connectionCount}`, + connectionDetails: { + connectionKey: `conn-key-${connectionCount}`, + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client1 = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client1); + + const client2 = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client2); + + client1.connection.once('connected', () => { + client2.connection.once('connected', () => { + expect(client1.connection.id).to.not.equal(client2.connection.id); + expect(client1.connection.id).to.equal('conn-id-1'); + expect(client2.connection.id).to.equal('conn-id-2'); + client1.close(); + client2.close(); + done(); + }); + client2.connect(); + }); + + client1.connect(); + }); + + /** + * RTN9b - Connection key is unique per connection + */ + it('RTN9b - connection.key is unique per client', function (done) { + let connectionCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + conn.respond_with_connected({ + connectionId: `conn-id-${connectionCount}`, + connectionDetails: { + connectionKey: `conn-key-${connectionCount}`, + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client1 = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client1); + + const client2 = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client2); + + client1.connection.once('connected', () => { + client2.connection.once('connected', () => { + expect(client1.connection.key).to.not.equal(client2.connection.key); + expect(client1.connection.key).to.equal('conn-key-1'); + expect(client2.connection.key).to.equal('conn-key-2'); + client1.close(); + client2.close(); + done(); + }); + client2.connect(); + }); + + client1.connect(); + }); + + /** + * RTN8c - Connection ID is null in CLOSED state + */ + it('RTN8c - connection.id is null after close', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-id-1', + connectionDetails: { + connectionKey: 'conn-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 7) { + // CLOSE + 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); + + client.connection.once('connected', () => { + expect(client.connection.id).to.equal('conn-id-1'); + + client.connection.once('closed', () => { + expect(client.connection.id).to.not.be.ok; + done(); + }); + + client.close(); + }); + + client.connect(); + }); + + /** + * RTN9c - Connection key is null in CLOSED state + */ + it('RTN9c - connection.key is null after close', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-id-1', + connectionDetails: { + connectionKey: 'conn-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 7) { + // CLOSE + 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); + + client.connection.once('connected', () => { + expect(client.connection.key).to.equal('conn-key-1'); + + client.connection.once('closed', () => { + expect(client.connection.key).to.not.be.ok; + done(); + }); + + client.close(); + }); + + client.connect(); + }); + + /** + * RTN8c, RTN9c - ID and key null after FAILED + */ + it('RTN8c, RTN9c - id and key null in FAILED state', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 80000, statusCode: 400, message: 'Fatal error' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', () => { + expect(client.connection.id).to.not.be.ok; + expect(client.connection.key).to.not.be.ok; + done(); + }); + + client.connect(); + }); + + /** + * RTN8c, RTN9c - ID and key null in SUSPENDED state + */ + it('RTN8c, RTN9c - id and key null in SUSPENDED state', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + + // Pump to let initial connection attempt + failure happen + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance past connectionStateTtl to reach SUSPENDED + await clock.tickAsync(121000); + + // Pump again + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('suspended'); + expect(client.connection.id).to.not.be.ok; + expect(client.connection.key).to.not.be.ok; + client.close(); + }); +}); diff --git a/test/uts/realtime/connection/connection_open_failures.test.ts b/test/uts/realtime/connection/connection_open_failures.test.ts new file mode 100644 index 000000000..0d1b92252 --- /dev/null +++ b/test/uts/realtime/connection/connection_open_failures.test.ts @@ -0,0 +1,452 @@ +/** + * UTS: Connection Opening Failures Tests + * + * Spec points: RTN14a, RTN14b, RTN14c, RTN14d, RTN14e, RTN14f, RTN14g + * Source: uts/test/realtime/unit/connection/connection_open_failures_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'; + +async function pumpTimers(clock: any, iterations = 30) { + for (let i = 0; i < iterations; i++) { + clock.tick(0); + await flushAsync(); + } +} + +describe('uts/realtime/connection/connection_open_failures', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN14a - Invalid API key causes FAILED state + */ + it('RTN14a - invalid API key causes FAILED state', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 40005, statusCode: 400, message: 'Invalid key' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'invalid.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', () => { + 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(client.connection.id).to.not.be.ok; + expect(client.connection.key).to.not.be.ok; + done(); + }); + + client.connect(); + }); + + /** + * RTN14b - Token error with renewable token triggers renewal and retry + */ + it('RTN14b - token error with renewable token retries', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + if (connectionAttemptCount === 1) { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + let authCallbackCount = 0; + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, `token-${authCallbackCount}`); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(client.connection.state).to.equal('connected'); + // Auth callback called twice: initial + renewal + expect(authCallbackCount).to.equal(2); + expect(connectionAttemptCount).to.equal(2); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RSA4a - Token error without renewal means → FAILED + * + * Per RSA4a2: if the server responds with a token error and there is no + * means to renew the token, the connection transitions to FAILED with + * error code 40171. + */ + it('RSA4a - token error without renewal causes FAILED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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_string', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', () => { + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(40171); + done(); + }); + + client.connect(); + }); + + /** + * RTN14c - Connection timeout + * + * Note: ably-js connectingTimeout = webSocketConnectTimeout + realtimeRequestTimeout. + * Both must be configured short for this test. + */ + it('RTN14c - connection timeout causes DISCONNECTED', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + // WebSocket opens but server never sends CONNECTED + conn.respond_with_success(); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP for 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', + realtimeRequestTimeout: 500, + webSocketConnectTimeout: 500, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + } as any); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connecting'); + + // Advance past connectingTimeout (webSocketConnectTimeout + realtimeRequestTimeout = 1000ms) + await clock.tickAsync(1100); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('disconnected'); + expect(client.connection.errorReason).to.not.be.null; + client.close(); + }); + + /** + * RTN14d - Retry after recoverable failure + */ + it('RTN14d - automatic retry after recoverable failure', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + if (connectionAttemptCount === 1) { + conn.respond_with_refused(); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + disconnectedRetryTimeout: 1000, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('disconnected'); + + // Advance time to trigger retry + await clock.tickAsync(1100); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + expect(connectionAttemptCount).to.equal(2); + client.close(); + }); + + /** + * RTN14e - DISCONNECTED → SUSPENDED after connectionStateTtl + */ + it('RTN14e - transitions to SUSPENDED after connectionStateTtl', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + disconnectedRetryTimeout: 500, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('disconnected'); + + // Advance past connectionStateTtl (default 120000ms) + await clock.tickAsync(121000); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('suspended'); + expect(client.connection.errorReason).to.not.be.null; + client.close(); + }); + + /** + * RTN14f - SUSPENDED state retries and eventually connects + */ + it('RTN14f - SUSPENDED retries and connects', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + // All attempts fail until we have enough + if (connectionAttemptCount < 5) { + conn.respond_with_refused(); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + disconnectedRetryTimeout: 500, + suspendedRetryTimeout: 1000, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + + // Advance past connectionStateTtl to reach SUSPENDED + for (let i = 0; i < 15; i++) { + await clock.tickAsync(10000); + await pumpTimers(clock); + if (client.connection.state === 'connected') break; + } + + expect(client.connection.state).to.equal('connected'); + // Multiple connection attempts were made + expect(connectionAttemptCount).to.be.at.least(3); + client.close(); + }); + + /** + * RTN14g - ERROR protocol message with empty channel during connection opening → FAILED + * + * Per spec: ERROR ProtocolMessage with empty channel received during connection + * opening (before CONNECTED) transitions connection to FAILED. + */ + it('RTN14g - ERROR with empty channel causes FAILED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + // Send ERROR during connection opening — before any CONNECTED message + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 50000, statusCode: 500, message: 'Internal server error' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', () => { + 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(client.connection.errorReason!.message).to.equal('Internal server error'); + done(); + }); + + client.connect(); + }); + + /** + * RTN14b - Token error with renewal failure causes DISCONNECTED + * + * Per spec: If a connection request fails due to a token error and the token + * is renewable, a single attempt to create a new token is made. If the attempt + * to create a new token fails, or the subsequent connection attempt fails due + * to another token error, then the connection transitions to DISCONNECTED and + * Connection#errorReason is set. + */ + it('RTN14b - token error with renewal failure causes DISCONNECTED', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + // First attempt: token error + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 40142, statusCode: 401, message: 'Token expired' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP for connectivity checker + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => req.respond_with(200, 'yes'), + }); + installMockHttp(httpMock); + + let authCallbackCount = 0; + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + if (authCallbackCount <= 1) { + // First call succeeds (initial token) + cb(null, `token-${authCallbackCount}`); + } else { + // Renewal fails + cb(new Ably.ErrorInfo('Invalid credentials', 40101, 401)); + } + }, + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + // Track state changes. The client goes through: connecting -> (token error) + // -> possibly brief disconnected -> connecting (renewal) -> disconnected + // (renewal failed). We need the DISCONNECTED that occurs AFTER a renewal + // attempt (i.e. after authCallback has been called at least twice). + const stateChanges: string[] = []; + client.connection.on((change: any) => { + stateChanges.push(change.current); + + if (change.current === 'disconnected' && authCallbackCount >= 2) { + expect(client.connection.state).to.equal('disconnected'); + expect(client.connection.errorReason).to.not.be.null; + done(); + } + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/connection_ping.test.ts b/test/uts/realtime/connection/connection_ping.test.ts new file mode 100644 index 000000000..b50958b1f --- /dev/null +++ b/test/uts/realtime/connection/connection_ping.test.ts @@ -0,0 +1,688 @@ +/** + * UTS: Connection Ping Tests + * + * Spec points: RTN13, RTN13a, RTN13b, RTN13c, RTN13d, RTN13e + * Source: uts/test/realtime/unit/connection/connection_ping_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'; + +/** Helper: pump fake + real event loops */ +async function pumpTimers(clock: any, iterations = 30) { + for (let i = 0; i < iterations; i++) { + clock.tick(0); + await flushAsync(); + } +} + +describe('uts/realtime/connection/connection_ping', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN13a - Ping sends HEARTBEAT and returns round-trip duration + */ + it('RTN13a - ping sends HEARTBEAT and returns duration', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 0) { + // HEARTBEAT + mock.active_connection!.send_to_client({ + action: 0, // HEARTBEAT + id: msg.id, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', async () => { + const duration = await client.connection.ping(); + expect(duration).to.be.a('number'); + expect(duration).to.be.at.least(0); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN13e - HEARTBEAT includes random id for disambiguation + */ + it('RTN13e - sent HEARTBEAT includes id', function (done) { + let capturedId: string | null = null; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 0) { + // HEARTBEAT + capturedId = msg.id; + // Send wrong id first (should be ignored), then correct + mock.active_connection!.send_to_client({ action: 0, id: 'wrong-id' }); + mock.active_connection!.send_to_client({ action: 0, id: msg.id }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', async () => { + const duration = await client.connection.ping(); + expect(duration).to.be.a('number'); + expect(capturedId).to.not.be.null; + expect(capturedId!.length).to.be.greaterThan(0); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN13e - HEARTBEAT with no id is ignored as ping response + */ + it('RTN13e - HEARTBEAT without id is ignored', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 0) { + // HEARTBEAT + // Send no-id heartbeat first (should be ignored) + mock.active_connection!.send_to_client({ action: 0 }); + // Then correct response + mock.active_connection!.send_to_client({ action: 0, id: msg.id }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', async () => { + const duration = await client.connection.ping(); + expect(duration).to.be.a('number'); + expect(duration).to.be.at.least(0); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN13e - Multiple concurrent pings each get their own response + */ + it('RTN13e - concurrent pings disambiguated by id', function (done) { + const sentIds: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 0) { + // HEARTBEAT + sentIds.push(msg.id); + mock.active_connection!.send_to_client({ action: 0, id: msg.id }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', async () => { + const [d1, d2] = await Promise.all([client.connection.ping(), client.connection.ping()]); + + expect(d1).to.be.a('number'); + expect(d2).to.be.a('number'); + expect(sentIds).to.have.length(2); + expect(sentIds[0]).to.not.equal(sentIds[1]); + + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN13c - Ping times out if no HEARTBEAT response + */ + it('RTN13c - ping timeout', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + // No onMessageFromClient — never respond to HEARTBEAT + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 2000, + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + + const pingPromise = client.connection.ping(); + + // Pump to send the HEARTBEAT + await pumpTimers(clock, 5); + + // Advance past realtimeRequestTimeout + await clock.tickAsync(2100); + + try { + await pingPromise; + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + } + client.close(); + }); + + /** + * RTN13b - Ping errors in INITIALIZED state + */ + it('RTN13b - ping errors in INITIALIZED', async function () { + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + try { + await client.connection.ping(); + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + } + client.close(); + }); + + /** + * RTN13b - Ping errors in CLOSED state + */ + it('RTN13b - ping errors in CLOSED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 7) { + // CLOSE + 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); + + client.connection.once('connected', () => { + client.connection.once('closed', async () => { + try { + await client.connection.ping(); + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + done(); + } + }); + client.close(); + }); + + client.connect(); + }); + + /** + * RTN13b - Ping errors in FAILED state + */ + it('RTN13b - ping errors in FAILED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 80000, statusCode: 400, message: 'Fatal error' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', async () => { + try { + await client.connection.ping(); + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + done(); + } + }); + + client.connect(); + }); + + /** + * RTN13b - Ping errors in SUSPENDED state + */ + it('RTN13b - ping errors in SUSPENDED', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + // Advance past connectionStateTtl + await clock.tickAsync(121000); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('suspended'); + + try { + await client.connection.ping(); + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + } + client.close(); + }); + + /** + * RTN13d - Ping deferred from CONNECTING until CONNECTED + * + * Per spec: "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." + */ + 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({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + setImmediate(() => { + conn.respond_with_connected({ + connectionId: 'conn-id', + connectionDetails: { + connectionKey: 'conn-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 0) { + // HEARTBEAT + mock.active_connection!.send_to_client({ action: 0, id: msg.id }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + expect(client.connection.state).to.equal('connecting'); + + // Per spec, ping() should defer and resolve once CONNECTED + const rtt = await client.connection.ping(); + expect(typeof rtt).to.equal('number'); + expect(rtt).to.be.at.least(0); + client.close(); + }); + + /** + * RTN13d - Ping works after auto-reconnection from DISCONNECTED + * + * Note: ably-js doesn't defer ping(), but the client auto-reconnects + * before ping() is called here (connectivity check succeeds immediately). + */ + it('RTN13d - ping succeeds after auto-reconnect from DISCONNECTED', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-id-${connectionAttemptCount}`, + connectionDetails: { + connectionKey: `conn-key-${connectionAttemptCount}`, + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 0) { + // HEARTBEAT + mock.active_connection!.send_to_client({ action: 0, id: msg.id }); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 500, + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + + // Force disconnect + mock.active_connection!.simulate_disconnect(); + await pumpTimers(clock); + + // Call ping() while DISCONNECTED + const pingPromise = client.connection.ping(); + + // Advance time for reconnection + await clock.tickAsync(600); + await pumpTimers(clock); + + const duration = await pingPromise; + expect(duration).to.be.a('number'); + expect(duration).to.be.at.least(0); + client.close(); + }); + + /** + * RTN13b+d - Ping from CONNECTING rejects when connection goes to FAILED + * + * Note: ably-js rejects ping() immediately in non-connected states. + */ + it('RTN13b+d - ping from CONNECTING rejects on FAILED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + setImmediate(() => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 80000, statusCode: 400, message: 'Fatal error' }, + }); + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + expect(client.connection.state).to.equal('connecting'); + + // Call ping() while CONNECTING + client.connection.ping().then( + () => { + done(new Error('Expected ping to reject')); + }, + (err: any) => { + expect(err).to.not.be.null; + done(); + }, + ); + }); + + /** + * RTN13b+d - Ping from DISCONNECTED rejects (not deferred) + * + * Note: ably-js rejects ping() immediately in non-connected states. + */ + it('RTN13b+d - ping from DISCONNECTED rejects', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 1000, + fallbackHosts: [], + }); + trackClient(client); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('disconnected'); + + // Call ping() while DISCONNECTED + const pingPromise = client.connection.ping(); + + // Advance past connectionStateTtl to reach SUSPENDED + await clock.tickAsync(121000); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('suspended'); + + try { + await pingPromise; + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + } + client.close(); + }); + + /** + * RTN13c+d - Ping from CONNECTING rejects immediately (not deferred timeout) + * + * Note: ably-js rejects ping() immediately in non-connected states. + */ + it('RTN13c+d - ping from CONNECTING rejects immediately', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + setImmediate(() => { + conn.respond_with_connected(); + }); + }, + // No response to HEARTBEAT — will timeout + }); + installMockWebSocket(mock.constructorFn); + + const clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + realtimeRequestTimeout: 2000, + }); + trackClient(client); + + client.connect(); + + // Call ping() while CONNECTING + const pingPromise = client.connection.ping(); + + // Pump to let connection establish + await pumpTimers(clock); + + // Advance past realtimeRequestTimeout + await clock.tickAsync(2200); + await pumpTimers(clock); + + try { + await pingPromise; + expect.fail('Expected ping to reject'); + } catch (err: any) { + expect(err).to.not.be.null; + } + client.close(); + }); + + /** + * RTN13b - Ping errors in CLOSING state + * + * RTN13b lists CLOSING among the states where ping() must error. + * In ably-js, calling client.close() while CONNECTED transitions + * synchronously through CLOSING to CLOSED within closeImpl(). + * We listen on the connectionManager directly (which emits state + * changes synchronously) to catch the CLOSING state and call ping(). + */ + it('RTN13b - ping errors in CLOSING', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Listen on connectionManager directly for synchronous state change + (client as any).connection.connectionManager.once( + 'connectionstate', + (stateChange: any) => { + if (stateChange.current === 'closing') { + // We are now synchronously in CLOSING state + client.connection.ping().then( + () => { + done(new Error('Expected ping to reject')); + }, + (err: any) => { + expect(err).to.not.be.null; + done(); + }, + ); + } + }, + ); + + // Initiate close — transitions through CLOSING -> CLOSED + client.close(); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/error_reason.test.ts b/test/uts/realtime/connection/error_reason.test.ts new file mode 100644 index 000000000..306329062 --- /dev/null +++ b/test/uts/realtime/connection/error_reason.test.ts @@ -0,0 +1,343 @@ +/** + * UTS: Connection errorReason Tests + * + * Spec points: RTN25 + * Source: uts/test/realtime/unit/connection/error_reason_test.md + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN25 - errorReason set on connection errors (FAILED state) + */ + it('RTN25 - errorReason set on fatal error', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 40005, statusCode: 400, message: 'Invalid API key' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'invalid.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Initially errorReason should be null + expect(client.connection.errorReason).to.be.null; + + client.connection.once('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); + done(); + }); + + client.connect(); + }); + + /** + * RTN25 - errorReason on DISCONNECTED state + */ + it('RTN25 - errorReason set on DISCONNECTED', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_refused(); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP for connectivity checker + 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + client.connection.once('disconnected', () => { + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.message).to.be.a('string'); + done(); + }); + + client.connect(); + }); + + /** + * RTN25 - errorReason on SUSPENDED state + */ + it('RTN25 - errorReason set on SUSPENDED', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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 clock = enableFakeTimers(); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 500, + connectionStateTtl: 2000, + fallbackHosts: [], + } as any); + trackClient(client); + + client.connect(); + + // Advance past connectionStateTtl (2s) in small increments + for (let i = 0; i < 10; i++) { + await clock.tickAsync(500); + if (client.connection.state === 'suspended') break; + } + + expect(client.connection.state).to.equal('suspended'); + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.message).to.be.a('string'); + client.close(); + }); + + /** + * RTN25/RTN14b/RSA4a - errorReason on token error with no renewal + * + * Per RTN14b: token ERROR during connection, no means to renew → RSA4a applies. + * Per RSA4a2: transition to FAILED with error code 40171. + */ + it('RTN25 - errorReason on token error (non-renewable)', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + 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, + }); + trackClient(client); + + // Per RSA4a2: no means to renew → FAILED state with error code 40171 + client.connection.once('failed', () => { + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(40171); + done(); + }); + + client.connect(); + }); + + /** + * RTN25 - errorReason cleared on successful reconnection + */ + it('RTN25 - errorReason cleared on successful reconnect', function (done) { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + if (connectionAttemptCount === 1) { + conn.respond_with_refused(); + } else { + conn.respond_with_connected(); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 15, + fallbackHosts: [], + }); + trackClient(client); + + client.connection.once('disconnected', function () { + try { + expect(client.connection.errorReason).to.not.be.null; + } catch (err) { + return done(err); + } + + client.connection.once('connected', function () { + try { + expect(client.connection.errorReason).to.be.null; + client.close(); + done(); + } catch (err) { + done(err); + } + }); + }); + + client.connect(); + }); + + /** + * RTN25 - errorReason on protocol-level ERROR message + */ + it('RTN25 - errorReason on protocol ERROR message', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 50000, statusCode: 500, message: 'Internal server error' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('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(client.connection.errorReason!.message).to.contain('Internal server error'); + done(); + }); + + client.connect(); + }); + + /** + * RTN25 - errorReason propagated to ConnectionStateChange events + */ + it('RTN25 - errorReason in ConnectionStateChange', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_error({ + action: 9, // ERROR + error: { code: 40003, statusCode: 400, message: 'Access token invalid' }, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('failed', (stateChange: any) => { + // State change has reason populated + expect(stateChange.reason).to.not.be.null; + expect(stateChange.reason.code).to.equal(40003); + expect(stateChange.reason.statusCode).to.equal(400); + + // Connection errorReason matches state change reason + expect(client.connection.errorReason).to.not.be.null; + expect(client.connection.errorReason!.code).to.equal(stateChange.reason.code); + done(); + }); + + client.connect(); + }); + + /** + * RTN25/RTN15h1 - errorReason set on token error while connected (non-renewable) + * + * Per RTN15h1: If a DISCONNECTED message contains a token error and there is + * no means to renew the token, the connection transitions to FAILED and + * Connection#errorReason is set. This tests that errorReason captures the + * token error details in this scenario. + */ + it('RTN25 - errorReason set on token error while connected (RTN15h1)', 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({ + token: 'some_token_string', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('failed', (stateChange: any) => { + // errorReason is set (RTN25) + expect(client.connection.errorReason).to.not.be.null; + // Per RSA4a: non-renewable token error is wrapped with code 40171 + expect(client.connection.errorReason!.code).to.equal(40171); + + // State change reason also populated + expect(stateChange.reason).to.not.be.null; + expect(stateChange.reason.code).to.equal(40171); + done(); + }); + + // Server sends DISCONNECTED with token error while connected + mock.active_connection!.send_to_client_and_close({ + action: 6, // DISCONNECTED + error: { + message: 'Token expired', + code: 40142, + statusCode: 401, + }, + }); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/fallback_hosts.test.ts b/test/uts/realtime/connection/fallback_hosts.test.ts new file mode 100644 index 000000000..4e677bdc4 --- /dev/null +++ b/test/uts/realtime/connection/fallback_hosts.test.ts @@ -0,0 +1,494 @@ +/** + * UTS: Fallback Hosts Tests + * + * Spec points: RTN17f, RTN17f1, RTN17g, RTN17h, RTN17i, RTN17j + * Source: uts/test/realtime/unit/connection/fallback_hosts_test.md + * + * Note: Fallback host behavior is complex — involves connectivity checks, + * host rotation, and coordination between realtime and REST. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN17i - Always prefer primary domain first + */ + it('RTN17i - primary domain tried first', function (done) { + const connectionHosts: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + mock.active_connection = conn; + + if (connectionHosts.length === 1) { + // Primary fails + conn.respond_with_refused(); + } else { + // Fallback succeeds + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP for connectivity check + 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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // First attempt was primary domain + expect(connectionHosts[0]).to.equal('main.realtime.ably.net'); + // Second was a fallback + expect(connectionHosts[1]).to.match(/main\.[a-e]\.fallback\.ably-realtime\.com/); + expect(connectionHosts.length).to.be.at.least(2); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN17f - Network errors trigger fallback host usage + */ + it('RTN17f - connection refused triggers fallback', function (done) { + const connectionHosts: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + mock.active_connection = conn; + + if (connectionHosts.length === 1) { + // Primary: connection refused + conn.respond_with_refused(); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(connectionHosts.length).to.be.at.least(2); + expect(connectionHosts[0]).to.equal('main.realtime.ably.net'); + expect(connectionHosts[1]).to.match(/main\.[a-e]\.fallback\.ably-realtime\.com/); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN17f1 - DISCONNECTED with 5xx triggers fallback + */ + it('RTN17f1 - DISCONNECTED with 503 triggers fallback', function (done) { + const connectionHosts: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + mock.active_connection = conn; + + if (connectionHosts.length === 1) { + // Primary: send DISCONNECTED with 503 + conn.respond_with_success(); + process.nextTick(() => { + conn.send_to_client_and_close({ + action: 6, // DISCONNECTED + error: { + code: 50003, + statusCode: 503, + message: 'Service temporarily unavailable', + }, + }); + }); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(connectionHosts.length).to.be.at.least(2); + expect(connectionHosts[0]).to.equal('main.realtime.ably.net'); + // Second attempt should be a fallback host + expect(connectionHosts[1]).to.match(/main\.[a-e]\.fallback\.ably-realtime\.com/); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN17g - Empty fallback set: custom host with no fallbacks + * + * DEVIATION: ably-js with custom realtimeHost and fallbackHosts:[] goes to + * DISCONNECTED (not immediate error), then retries. We verify only the primary + * host was tried and no fallback hosts were used. + */ + it('RTN17g - custom host with no fallbacks does not try fallbacks', function (done) { + const connectionHosts: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + 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 client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + realtimeHost: 'custom.example.com', + fallbackHosts: [], + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('disconnected', () => { + // Only the custom host was tried, no fallbacks + expect(connectionHosts.length).to.equal(1); + expect(connectionHosts[0]).to.equal('custom.example.com'); + done(); + }); + + client.connect(); + }); + + /** + * RTN17h - Default fallback hosts match spec (REC2) + */ + it('RTN17h - uses default fallback hosts from REC2', function (done) { + const connectionHosts: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + mock.active_connection = conn; + + if (connectionHosts.length === 1) { + conn.respond_with_refused(); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + expect(connectionHosts.length).to.be.at.least(2); + // Fallback host matches pattern: main.[a-e].fallback.ably-realtime.com + const fallbackHost = connectionHosts[1]; + expect(fallbackHost).to.match(/main\.[a-e]\.fallback\.ably-realtime\.com/); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN17j - Connectivity check before fallback + */ + it('RTN17j - connectivity check performed before fallback', function (done) { + const connectionHosts: string[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + mock.active_connection = conn; + + if (connectionHosts.length === 1) { + conn.respond_with_refused(); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Connectivity check was performed via HTTP mock + const connectivityChecks = httpMock.captured_requests.filter((req) => req.url.href.includes('internet-up')); + expect(connectivityChecks.length).to.be.at.least(1); + + // Connection proceeded to fallback after check + expect(connectionHosts.length).to.be.at.least(2); + client.close(); + done(); + }); + + client.connect(); + }); + + /** + * RTN17j - Fallback hosts tried in random order + * + * This test is inherently probabilistic. We run multiple iterations and check + * that not all fallback host orders are identical. + */ + it('RTN17j - fallback hosts tried in random order', function (done) { + const fallbackOrders: string[][] = []; + let iterationsCompleted = 0; + const totalIterations = 5; + + function runIteration() { + restoreAll(); + + const connectionHosts: string[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + if (connectionHosts.length <= 3) { + // Primary and first 2 fallbacks fail + conn.respond_with_refused(); + } else { + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-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', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + // Record fallback order (skip primary at index 0) + fallbackOrders.push(connectionHosts.slice(1)); + client.close(); + iterationsCompleted++; + + if (iterationsCompleted < totalIterations) { + runIteration(); + } else { + // At least 2 different orderings should appear + const uniqueOrders = new Set(fallbackOrders.map((o) => o.join(','))); + expect(uniqueOrders.size).to.be.at.least(2); + done(); + } + }); + + client.connect(); + } + + runIteration(); + }); + + /** + * RTN17e - HTTP requests use same fallback host as realtime connection + * + * Spec: If the realtime client is connected to a fallback host endpoint, + * HTTP requests should first be attempted to the same datacenter. + */ + it('RTN17e - HTTP requests use same fallback host as realtime connection', async function () { + const connectionHosts: string[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionHosts.push(conn.url.hostname); + mock.active_connection = conn; + + if (connectionHosts.length === 1) { + // Primary fails + conn.respond_with_refused(); + } else { + // Fallback succeeds + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpRequests: { url: string; host: string }[] = []; + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + httpRequests.push({ url: req.url.href, host: req.url.hostname }); + if (req.url.pathname.includes('/channels/') && req.url.pathname.includes('/messages')) { + req.respond_with(200, '[]'); + } else if (req.url.href.includes('internet-up')) { + req.respond_with(200, 'yes'); + } else { + req.respond_with(200, '{}'); + } + }, + }); + installMockHttp(httpMock); + + 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)); + + // Determine which fallback host the realtime connection is using + const connectedFallbackHost = connectionHosts[1]; + expect(connectedFallbackHost).to.match(/main\.[a-e]\.fallback\.ably-realtime\.com/); + + // Make an HTTP request (channel history) + const channel = client.channels.get('test-RTN17e'); + await channel.history(); + + // Find HTTP requests that are history-related (not connectivity checks) + const historyRequests = httpRequests.filter( + (r) => r.url.includes('/channels/') && r.url.includes('/messages'), + ); + expect(historyRequests.length).to.be.at.least(1); + + // The HTTP request host should use the same fallback datacenter letter + // Realtime fallback: main..fallback.ably-realtime.com + // REST fallback: rest..fallback.ably-realtime.com + const fallbackLetter = connectedFallbackHost.match(/main\.([a-e])\.fallback/)?.[1]; + expect(fallbackLetter).to.exist; + + const historyHost = historyRequests[0].host; + const historyLetter = historyHost.match(/\.([a-e])\.fallback/)?.[1]; + expect(historyLetter).to.equal(fallbackLetter); + + client.close(); + }); +}); diff --git a/test/uts/realtime/connection/heartbeat.test.ts b/test/uts/realtime/connection/heartbeat.test.ts new file mode 100644 index 000000000..86cb5edbe --- /dev/null +++ b/test/uts/realtime/connection/heartbeat.test.ts @@ -0,0 +1,752 @@ +/** + * UTS: Heartbeat Tests + * + * Spec points: RTN23a, RTN23b + * Source: uts/test/realtime/unit/connection/heartbeat_test.md + * + * ably-js Node.js uses WebSocket ping frames (RTN23b) since the `ws` library + * exposes them. It sends `heartbeats=false` in the connection URL. + * The idle timer threshold is: maxIdleInterval + realtimeRequestTimeout. + * + * Both RTN23a (HEARTBEAT protocol messages) and RTN23b (ping frames) + * are tested since the idle timer logic is the same — any activity resets it. + */ + +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'; + +async function pumpTimers(clock: any, iterations = 30) { + for (let i = 0; i < iterations; i++) { + clock.tick(0); + await flushAsync(); + } +} + +describe('uts/realtime/connection/heartbeat', function () { + afterEach(function () { + restoreAll(); + }); + + // --- RTN23a: URL parameter --- + + /** + * RTN23a - heartbeats=true when ping frames not observable + * + * When the platform cannot observe WebSocket ping frames + * (useProtocolHeartbeats=true), the client sends heartbeats=true + * in the connection URL to request HEARTBEAT protocol messages. + */ + it('RTN23a - heartbeats=true in connection URL when ping frames not observable', function (done) { + const savedUseProtocolHeartbeats = Platform.Config.useProtocolHeartbeats; + Platform.Config.useProtocolHeartbeats = true; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + const heartbeats = conn.url.searchParams.get('heartbeats'); + expect(heartbeats).to.equal('true'); + conn.respond_with_connected(); + Platform.Config.useProtocolHeartbeats = savedUseProtocolHeartbeats; + done(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + }); + + // --- RTN23b: URL parameter --- + + /** + * RTN23b - heartbeats=false when ping frames observable + * + * ably-js Node.js can observe ping frames via ws library's 'ping' event, + * so it sends heartbeats=false in the connection URL. + */ + it('RTN23b - heartbeats=false in connection URL', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + const heartbeats = conn.url.searchParams.get('heartbeats'); + expect(heartbeats).to.equal('false'); + conn.respond_with_connected(); + done(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + }); + + // --- RTN23a/b: Idle timer disconnect and reconnect --- + // Note: RTN23a tests have flaked in the past (one-off failures in full suite runs + // under heavy CPU load) but the issue has not been reproducible in isolation or + // repeated full-suite runs. Likely a fake-timer + process.nextTick race under load. + + /** + * RTN23a/b - Disconnect after maxIdleInterval + realtimeRequestTimeout + */ + it('RTN23a - disconnect after idle timeout', async function () { + let connectionAttemptCount = 0; + const stateChanges: string[] = []; + + 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: 5000, + 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: 2000, + disconnectedRetryTimeout: 500, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await pumpTimers(clock); + + expect(client.connection.state).to.equal('connected'); + expect(connectionAttemptCount).to.equal(1); + + // Advance past idle timeout (maxIdleInterval + realtimeRequestTimeout + 100 = 7100ms) + // Use small increments to avoid re-triggering after reconnect + await clock.tickAsync(7200); + await pumpTimers(clock); + + // Should have disconnected due to idle timeout + expect(stateChanges).to.include('disconnected'); + + // Advance past disconnectedRetryTimeout (500ms) to trigger reconnection + await clock.tickAsync(600); + await pumpTimers(clock); + + // Should have reconnected + expect(connectionAttemptCount).to.equal(2); + expect(client.connection.id).to.equal('connection-id-2'); + client.close(); + }); + + /** + * RTN23a - HEARTBEAT protocol message resets idle timer + */ + it('RTN23a - HEARTBEAT resets idle timer', 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: 3000, + 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 2000ms (within timeout of 3000+1000=4000ms) + await clock.tickAsync(2000); + await pumpTimers(clock); + + // Send HEARTBEAT from server — resets timer + mock.active_connection!.send_to_client({ action: 0 }); // HEARTBEAT + await pumpTimers(clock); + + // Advance another 2000ms (2000ms since HEARTBEAT, still within threshold) + await clock.tickAsync(2000); + await pumpTimers(clock); + + // Connection should still be alive + expect(client.connection.state).to.equal('connected'); + expect(connectionAttemptCount).to.equal(1); + + // Advance past timeout (4100ms since last HEARTBEAT) + await clock.tickAsync(2100); + await pumpTimers(clock); + + // Should have reconnected + expect(connectionAttemptCount).to.equal(2); + client.close(); + }); + + /** + * RTN23a - Any protocol message resets idle timer + */ + it('RTN23a - any message resets idle timer', 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(connectionAttemptCount).to.equal(1); + + // Advance 1500ms (within timeout of 2000+1000=3000ms) + await clock.tickAsync(1500); + await pumpTimers(clock); + + // Send ACK from server — resets timer + mock.active_connection!.send_to_client({ action: 1, msgSerial: 0 }); // ACK + await pumpTimers(clock); + + // Advance 1500ms (still within threshold since ACK) + await clock.tickAsync(1500); + await pumpTimers(clock); + + // Still connected + expect(client.connection.state).to.equal('connected'); + expect(connectionAttemptCount).to.equal(1); + + // Advance past timeout (3100ms since last activity) + await clock.tickAsync(1600); + await pumpTimers(clock); + + // Should have reconnected + expect(connectionAttemptCount).to.equal(2); + client.close(); + }); + + /** + * RTN23a - Heartbeat timeout triggers immediate reconnection + */ + it('RTN23a - timeout triggers reconnection with state sequence', async function () { + let connectionAttemptCount = 0; + const stateChanges: string[] = []; + + 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.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await pumpTimers(clock); + + expect(connectionAttemptCount).to.equal(1); + + // Advance past timeout (2000 + 1000 = 3000ms) + await clock.tickAsync(3100); + await pumpTimers(clock); + + // Verify disconnect → reconnect sequence + expect(stateChanges).to.include('disconnected'); + expect(stateChanges).to.include('connected'); + expect(connectionAttemptCount).to.equal(2); + expect(client.connection.id).to.equal('connection-id-2'); + client.close(); + }); + + /** + * RTN23a - Reconnection after timeout uses resume + */ + it('RTN23a - reconnection after 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); + + // Advance past timeout + 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: Ping frame tests --- + + /** + * RTN23b - Disconnect after idle timeout (no ping frames sent) + */ + it('RTN23b - disconnect when no ping frames received', async function () { + let connectionAttemptCount = 0; + const stateChanges: string[] = []; + + 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: 5000, + 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: 2000, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await pumpTimers(clock); + + expect(connectionAttemptCount).to.equal(1); + + // Advance past maxIdleInterval + realtimeRequestTimeout = 7000ms + await clock.tickAsync(7100); + await pumpTimers(clock); + + expect(stateChanges).to.include('disconnected'); + expect(connectionAttemptCount).to.equal(2); + expect(client.connection.id).to.equal('connection-id-2'); + client.close(); + }); + + /** + * RTN23b - Ping frame resets idle timer + */ + it('RTN23b - ping frame resets idle timer', 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: 3000, + 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(connectionAttemptCount).to.equal(1); + + // Advance 2000ms (within timeout of 3000+1000=4000ms) + await clock.tickAsync(2000); + await pumpTimers(clock); + + // Send ping frame — resets timer + mock.active_connection!.send_ping_frame(); + await pumpTimers(clock); + + // Advance 2000ms (since ping, still within threshold) + await clock.tickAsync(2000); + await pumpTimers(clock); + + // Still connected + expect(client.connection.state).to.equal('connected'); + expect(connectionAttemptCount).to.equal(1); + + // Advance past timeout (4100ms since last ping) + await clock.tickAsync(2100); + await pumpTimers(clock); + + expect(connectionAttemptCount).to.equal(2); + client.close(); + }); + + /** + * RTN23b - Protocol messages also reset timer (not just ping frames) + */ + it('RTN23b - protocol message resets idle timer', 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); + + // Advance 1500ms + await clock.tickAsync(1500); + await pumpTimers(clock); + + // Send ping frame — resets timer + mock.active_connection!.send_ping_frame(); + await pumpTimers(clock); + + // Advance 1500ms + await clock.tickAsync(1500); + await pumpTimers(clock); + + // Still connected + expect(client.connection.state).to.equal('connected'); + + // Send ATTACHED message — also resets timer + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-channel', + flags: 0, + }); + await pumpTimers(clock); + + // Advance 1500ms (since ATTACHED) + await clock.tickAsync(1500); + await pumpTimers(clock); + + // Still only one connection + expect(connectionAttemptCount).to.equal(1); + + // Send another ping frame + mock.active_connection!.send_ping_frame(); + await pumpTimers(clock); + + // Advance 1500ms + await clock.tickAsync(1500); + await pumpTimers(clock); + expect(connectionAttemptCount).to.equal(1); + + // Now let it timeout (3100ms without activity) + await clock.tickAsync(1600); + await pumpTimers(clock); + + expect(connectionAttemptCount).to.equal(2); + client.close(); + }); + + /** + * RTN23b - Ping frame timeout triggers immediate reconnection with resume + */ + it('RTN23b - timeout triggers reconnection with resume', async function () { + let connectionAttemptCount = 0; + const stateChanges: string[] = []; + + 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.connection.on((change: any) => { + stateChanges.push(change.current); + }); + + client.connect(); + await pumpTimers(clock); + + // Advance past timeout + await clock.tickAsync(3100); + await pumpTimers(clock); + + // Verify state sequence + expect(stateChanges).to.include('disconnected'); + expect(connectionAttemptCount).to.equal(2); + + // Verify resume param + 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 + */ + it('RTN23b - regular ping frames prevent timeout', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id', + connectionDetails: { + connectionKey: 'connection-key', + 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); + + // Send ping frames every 1500ms for 10+ seconds (timeout is 3000ms) + for (let i = 0; i < 7; i++) { + await clock.tickAsync(1500); + await pumpTimers(clock); + mock.active_connection!.send_ping_frame(); + await pumpTimers(clock); + expect(client.connection.state).to.equal('connected'); + } + + // Connection stayed alive through all ping frames + expect(connectionAttemptCount).to.equal(1); + expect(client.connection.state).to.equal('connected'); + client.close(); + }); +}); diff --git a/test/uts/realtime/connection/server_initiated_reauth.test.ts b/test/uts/realtime/connection/server_initiated_reauth.test.ts new file mode 100644 index 000000000..835d5c965 --- /dev/null +++ b/test/uts/realtime/connection/server_initiated_reauth.test.ts @@ -0,0 +1,218 @@ +/** + * UTS: Server-Initiated Re-authentication Tests + * + * Spec points: RTN22, RTN22a + * Source: uts/test/realtime/unit/connection/server_initiated_reauth_test.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll } from '../../helpers'; + +describe('uts/realtime/connection/server_initiated_reauth', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN22 - Server sends AUTH, client re-authenticates + */ + it('RTN22 - server AUTH triggers client reauth', function (done) { + let authCallbackCount = 0; + const capturedAuthMessages: 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) => { + if (msg.action === 17) { + // AUTH + capturedAuthMessages.push(msg); + // Respond with updated CONNECTED (same id/key) + mock.active_connection!.send_to_client({ + action: 4, // CONNECTED + connectionId: 'connection-id', + connectionKey: 'connection-key', + connectionDetails: { + connectionKey: 'connection-key', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, `token-${authCallbackCount}`); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + client.connection.on('update', () => { + // authCallback was called twice: once for initial connect, once for reauth + expect(authCallbackCount).to.equal(2); + + // Client sent AUTH message back + expect(capturedAuthMessages).to.have.length(1); + expect(capturedAuthMessages[0].auth).to.not.be.undefined; + + // Connection stayed CONNECTED throughout (no non-connected transitions) + const nonConnected = stateChanges.filter((c: any) => c.current !== 'connected'); + expect(nonConnected).to.have.length(0); + + client.close(); + done(); + }); + + // Server requests re-authentication + mock.active_connection!.send_to_client({ action: 17 }); // AUTH + }); + + client.connect(); + }); + + /** + * RTN22 - Connection remains CONNECTED during server-initiated reauth + */ + it('RTN22 - connection stays CONNECTED during reauth', function (done) { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 17) { + // AUTH + mock.active_connection!.send_to_client({ + action: 4, // CONNECTED + connectionId: 'conn-1', + connectionKey: 'key-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, `reauth-token-${authCallbackCount}`); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + const stateChanges: any[] = []; + client.connection.on((change: any) => { + stateChanges.push(change); + }); + + client.connection.on('update', () => { + // Connection never left CONNECTED + expect(client.connection.state).to.equal('connected'); + + // Only an UPDATE event, no state change events to non-connected states + expect(stateChanges).to.have.length(1); + expect(stateChanges[0].current).to.equal('connected'); + expect(stateChanges[0].previous).to.equal('connected'); + + client.close(); + done(); + }); + + // Server sends AUTH + mock.active_connection!.send_to_client({ action: 17 }); // AUTH + }); + + client.connect(); + }); + + /** + * RTN22a - Forced disconnect on reauth failure + */ + it('RTN22a - forced disconnect with token error', function (done) { + let authCallbackCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { + connectionKey: 'key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + authCallback: (params: any, cb: any) => { + authCallbackCount++; + cb(null, `recovery-token-${authCallbackCount}`); + }, + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connection.once('connected', () => { + client.connection.once('disconnected', (stateChange: any) => { + expect(stateChange.reason).to.not.be.null; + expect(stateChange.reason.code).to.equal(40142); + done(); + }); + + // Server forcibly disconnects with token error + mock.active_connection!.send_to_client({ + action: 6, // DISCONNECTED + error: { + message: 'Token expired', + code: 40142, + statusCode: 401, + }, + }); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/connection/update_events.test.ts b/test/uts/realtime/connection/update_events.test.ts new file mode 100644 index 000000000..db843dc84 --- /dev/null +++ b/test/uts/realtime/connection/update_events.test.ts @@ -0,0 +1,220 @@ +/** + * UTS: UPDATE Events Tests + * + * Spec points: RTN24 + * Source: uts/test/realtime/unit/connection/update_events_test.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll } from '../../helpers'; + +describe('uts/realtime/connection/update_events', function () { + let mock: MockWebSocket; + + afterEach(function () { + restoreAll(); + }); + + function setupConnectedClient(done: (client: any) => void) { + mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id-1', + connectionDetails: { + connectionKey: 'connection-key-1', + 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', () => { + done(client); + }); + + client.connect(); + } + + /** + * RTN24 - CONNECTED while already CONNECTED emits UPDATE event, not CONNECTED + */ + it('RTN24 - CONNECTED while connected emits UPDATE not state change', function (done) { + setupConnectedClient((client) => { + const connectedEvents: any[] = []; + + client.connection.on('connected', (change: any) => { + connectedEvents.push(change); + }); + + client.connection.on('update', (change: any) => { + expect(client.connection.state).to.equal('connected'); + expect(connectedEvents).to.have.length(0); + expect(change.previous).to.equal('connected'); + expect(change.current).to.equal('connected'); + + client.close(); + done(); + }); + + // Send another CONNECTED message (e.g., after reauth) + mock.active_connection!.send_to_client({ + action: 4, // CONNECTED + connectionId: 'connection-id-1', + connectionKey: 'connection-key-1', + connectionDetails: { + connectionKey: 'connection-key-1', + maxIdleInterval: 20000, + connectionStateTtl: 120000, + } as any, + }); + }); + }); + + /** + * RTN24 - UPDATE event with error reason + */ + it('RTN24 - UPDATE event carries error reason', function (done) { + setupConnectedClient((client) => { + client.connection.on('update', (change: any) => { + expect(change.previous).to.equal('connected'); + expect(change.current).to.equal('connected'); + expect(change.reason).to.not.be.null; + expect(change.reason.code).to.equal(40142); + expect(change.reason.statusCode).to.equal(401); + + client.close(); + done(); + }); + + mock.active_connection!.send_to_client({ + action: 4, // CONNECTED + connectionId: 'connection-id-1', + connectionKey: 'connection-key-1', + connectionDetails: { + connectionKey: 'connection-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + error: { + code: 40142, + statusCode: 401, + message: 'Token expired; renewed automatically', + }, + }); + }); + }); + + /** + * RTN24 - ConnectionDetails override + */ + 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 + mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'connection-id-1', + connectionDetails: { + connectionKey: 'connection-key-1', + 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.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + expect(client.connection.id).to.equal('connection-id-1'); + expect(client.connection.key).to.equal('connection-key-1'); + + const updatePromise = new Promise((resolve) => + client.connection.once('update', (change: any) => resolve(change)), + ); + + mock.active_connection!.send_to_client({ + action: 4, // CONNECTED + connectionId: 'connection-id-2', + connectionKey: 'connection-key-2', + connectionDetails: { + connectionKey: 'connection-key-2', + maxIdleInterval: 20000, + connectionStateTtl: 120000, + maxMessageSize: 32768, + serverId: 'server-2', + } as any, + }); + + await updatePromise; + + 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'); + + client.close(); + }); + + /** + * RTN24 - No duplicate CONNECTED event + */ + it('RTN24 - no duplicate CONNECTED state events', function (done) { + setupConnectedClient((client) => { + const connectedEvents: any[] = []; + const updateEvents: any[] = []; + + client.connection.on('connected', (change: any) => { + connectedEvents.push(change); + }); + + client.connection.on('update', (change: any) => { + updateEvents.push(change); + + if (updateEvents.length === 3) { + expect(connectedEvents).to.have.length(0); + + for (const evt of updateEvents) { + expect(evt.previous).to.equal('connected'); + expect(evt.current).to.equal('connected'); + } + + client.close(); + done(); + } + }); + + // Send 3 CONNECTED messages + for (let i = 0; i < 3; i++) { + mock.active_connection!.send_to_client({ + action: 4, // CONNECTED + connectionId: 'connection-id-1', + connectionKey: 'connection-key-1', + connectionDetails: { + connectionKey: 'connection-key-1', + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + } + }); + }); +}); diff --git a/test/uts/realtime/connection/when_state.test.ts b/test/uts/realtime/connection/when_state.test.ts new file mode 100644 index 000000000..21547d0ee --- /dev/null +++ b/test/uts/realtime/connection/when_state.test.ts @@ -0,0 +1,324 @@ +/** + * UTS: Connection whenState Tests + * + * Spec points: RTN26, RTN26a, RTN26b + * Source: uts/test/realtime/unit/connection/when_state_test.md + * + * Note: ably-js whenState returns a Promise (not callback-based). + * If already in target state, resolves with null. + * Otherwise resolves with ConnectionStateChange via once(). + */ + +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/connection/when_state', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTN26a - whenState resolves immediately if already in state + */ + it('RTN26a - whenState resolves immediately for current state', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + // Already in initialized state + const result = await client.connection.whenState('initialized'); + expect(result).to.be.null; + + // Connect and wait + client.connect(); + await client.connection.whenState('connected'); + + // Now already in connected state + const result2 = await client.connection.whenState('connected'); + expect(result2).to.be.null; + + client.close(); + }); + + /** + * RTN26b - whenState waits for state if not already in it + */ + it('RTN26b - whenState waits for target state', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + expect(client.connection.state).to.equal('initialized'); + + // Set up whenState before connecting + client.connection.whenState('connected').then((change: any) => { + // Should be invoked with a ConnectionStateChange (not null) + expect(change).to.not.be.null; + expect(change.current).to.equal('connected'); + + client.close(); + done(); + }); + + // Start connection + client.connect(); + }); + + /** + * RTN26b - whenState only fires once + */ + it('RTN26b - whenState only fires once across reconnection', async function () { + let connectionAttemptCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionAttemptCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-id-${connectionAttemptCount}`, + connectionDetails: { + connectionKey: `conn-key-${connectionAttemptCount}`, + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP for 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', + autoConnect: false, + useBinaryProtocol: false, + disconnectedRetryTimeout: 100, + }); + trackClient(client); + + let callbackCount = 0; + + // whenState returns a Promise; it resolves once + client.connection.whenState('connected').then(() => { + callbackCount++; + }); + + // Connect + client.connect(); + + // Pump to establish connection + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('connected'); + expect(callbackCount).to.equal(1); + + // Force disconnection + mock.active_connection!.simulate_disconnect(); + + // Pump to process disconnect + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + // Advance time for reconnection + await clock.tickAsync(200); + + // Pump to let reconnection complete + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('connected'); + + // Callback should still only be 1 (Promise resolves once) + expect(callbackCount).to.equal(1); + client.close(); + }); + + /** + * RTN26a - Multiple whenState calls for same state + */ + it('RTN26a - multiple whenState calls all resolve', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const p1 = client.connection.whenState('connected'); + const p2 = client.connection.whenState('connected'); + const p3 = client.connection.whenState('connecting'); + + client.connect(); + + // All three should resolve + await Promise.all([p1, p2, p3]); + + expect(client.connection.state).to.equal('connected'); + client.close(); + }); + + /** + * RTN26a - whenState does NOT fire for already-passed state + */ + it('RTN26a - whenState does not fire for past state', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_connected(); + }, + }); + 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)); + + let fired = false; + client.connection.whenState('connecting').then(() => { + fired = true; + }); + + await flushAsync(); + + expect(fired).to.be.false; + }); + + /** + * RTN26 - whenState with different states + */ + it('RTN26 - whenState works across state transitions', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + conn.respond_with_refused(); + }, + }); + installMockWebSocket(mock.constructorFn); + + // Mock HTTP for connectivity checker + 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + }); + trackClient(client); + + // Already in initialized state — resolves immediately with null + const initResult = await client.connection.whenState('initialized'); + expect(initResult).to.be.null; + + // Set up whenState for connecting and disconnected before connecting + const connectingPromise = client.connection.whenState('connecting'); + const disconnectedPromise = client.connection.whenState('disconnected'); + + // Start connection (will fail → disconnected) + client.connect(); + + // Both should resolve as the connection transitions through states + const connectingResult = await connectingPromise; + expect(connectingResult).to.not.be.null; + + const disconnectedResult = await disconnectedPromise; + expect(disconnectedResult).to.not.be.null; + expect(disconnectedResult.current).to.equal('disconnected'); + client.close(); + }); + + /** + * RTN26b - whenState waits for 'closed' terminal state + * + * Tests that whenState registered for 'closed' before closing the client + * resolves with a ConnectionStateChange when the client transitions to closed. + */ + it('RTN26b - whenState waits for closed state', function (done) { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 7) { + // CLOSE — respond with 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); + + client.connection.once('connected', () => { + // Register whenState for 'closed' while still connected + client.connection.whenState('closed').then((change: any) => { + // Should resolve with a ConnectionStateChange (not null) + expect(change).to.not.be.null; + expect(change.current).to.equal('closed'); + done(); + }); + + // Initiate close — triggers transition through closing → closed + client.close(); + }); + + client.connect(); + }); +}); diff --git a/test/uts/realtime/presence/local_presence_map.test.ts b/test/uts/realtime/presence/local_presence_map.test.ts new file mode 100644 index 000000000..a439fab9b --- /dev/null +++ b/test/uts/realtime/presence/local_presence_map.test.ts @@ -0,0 +1,422 @@ +/** + * UTS: LocalPresenceMap Tests + * + * Spec points: RTP17, RTP17b, RTP17h + * Source: specification/uts/realtime/unit/presence/local_presence_map.md + * + * Tests the internal PresenceMap (RTP17) that maintains members entered by + * the current connection, keyed by clientId only (RTP17h). + * + * NOTE: In ably-js the "local presence map" (_myMembers) is an instance of + * the same PresenceMap class, constructed with a different memberKey function: + * new PresenceMap(this, (item) => item.clientId!) + * This test creates a PresenceMap with that key function and a minimal mock + * for the RealtimePresence dependency. + */ + +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'; + +/** + * Create a minimal mock RealtimePresence that satisfies PresenceMap's constructor. + * PresenceMap needs: presence.channel.name, presence.logger, presence._synthesizeLeaves, + * and presence.syncComplete (set by setInProgress). + */ +function createMockPresence(): any { + const logger = new Logger(0); + return { + channel: { name: 'test-channel' }, + logger: logger, + syncComplete: true, + _synthesizeLeaves: () => {}, + }; +} + +/** + * Create a PresenceMessage with the given properties. + * Actions are strings in ably-js: 'absent', 'present', 'enter', 'leave', 'update'. + */ +function makePresenceMessage(props: { + action: string; + clientId: string; + connectionId: string; + id: string; + timestamp: number; + data?: any; +}): PresenceMessage { + return PresenceMessage.fromValues({ + action: props.action, + clientId: props.clientId, + connectionId: props.connectionId, + id: props.id, + timestamp: props.timestamp, + data: props.data, + }); +} + +/** + * Create a local presence map (keyed by clientId only, per RTP17h). + */ +function createLocalPresenceMap(): PresenceMap { + const mockPresence = createMockPresence(); + return new PresenceMap(mockPresence, (item) => item.clientId!); +} + +describe('uts/realtime/presence/local_presence_map', function () { + /** + * RTP17h - Keyed by clientId, not memberKey + * + * Unlike the main PresenceMap (keyed by memberKey), the RTP17 PresenceMap + * must be keyed only by clientId. A second put for the same clientId but + * different connectionId overwrites the first. + */ + it('RTP17h - keyed by clientId, not memberKey', function () { + const map = createLocalPresenceMap(); + + const msg1 = makePresenceMessage({ + action: 'enter', + clientId: 'user-1', + connectionId: 'conn-A', + id: 'conn-A:0:0', + timestamp: 1000, + data: 'first', + }); + + const msg2 = makePresenceMessage({ + action: 'enter', + clientId: 'user-1', + connectionId: 'conn-B', + id: 'conn-B:1:0', + timestamp: 2000, + data: 'second', + }); + + map.put(msg1); + map.put(msg2); + + // Only one entry -- keyed by clientId, second put overwrites the first + expect(map.values()).to.have.length(1); + const stored = map.get('user-1'); + expect(stored).to.not.be.undefined; + expect(stored.data).to.equal('second'); + expect(stored.connectionId).to.equal('conn-B'); + }); + + /** + * RTP17b - ENTER adds to map + * + * Any ENTER event with a connectionId matching the current client's + * connectionId should be applied to the RTP17 presence map. + */ + it('RTP17b - ENTER adds to map', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'hello', + })); + + const stored = map.get('client-1'); + expect(stored).to.not.be.undefined; + // NOTE: In ably-js, put() converts ENTER to PRESENT for storage (RTP2d2). + // The UTS spec expects the stored action to be ENTER, but ably-js stores + // it as PRESENT. This is correct per RTP2d2 but differs from UTS expectation. + expect(stored.action).to.equal('present'); + expect(stored.data).to.equal('hello'); + expect(map.values()).to.have.length(1); + }); + + /** + * RTP17b - UPDATE with no prior entry adds to map + * + * ENTER and UPDATE are interchangeable -- both add a member to the map. + */ + it('RTP17b - UPDATE with no prior entry adds to map', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'from-update', + })); + + const stored = map.get('client-1'); + expect(stored).to.not.be.undefined; + // NOTE: ably-js stores UPDATE as PRESENT (RTP2d2) + expect(stored.action).to.equal('present'); + expect(stored.data).to.equal('from-update'); + expect(map.values()).to.have.length(1); + }); + + /** + * RTP17b - ENTER after ENTER overwrites + * + * A second ENTER for the same clientId overwrites the first. + */ + it('RTP17b - ENTER after ENTER overwrites', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'first', + })); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + data: 'second', + })); + + expect(map.values()).to.have.length(1); + // NOTE: ably-js stores ENTER as PRESENT (RTP2d2) + expect(map.get('client-1').action).to.equal('present'); + expect(map.get('client-1').data).to.equal('second'); + }); + + /** + * RTP17b - UPDATE after ENTER overwrites + * + * UPDATE overwrites a prior ENTER for the same clientId. + */ + it('RTP17b - UPDATE after ENTER overwrites', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'initial', + })); + + map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + data: 'updated', + })); + + expect(map.values()).to.have.length(1); + // NOTE: ably-js stores UPDATE as PRESENT (RTP2d2) + expect(map.get('client-1').action).to.equal('present'); + expect(map.get('client-1').data).to.equal('updated'); + }); + + /** + * RTP17b - PRESENT adds to map + * + * Any PRESENT event with a matching connectionId should be applied. + */ + it('RTP17b - PRESENT adds to map', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ + action: 'present', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'present', + })); + + const stored = map.get('client-1'); + expect(stored).to.not.be.undefined; + expect(stored.action).to.equal('present'); + expect(stored.data).to.equal('present'); + }); + + /** + * RTP17b - Non-synthesized LEAVE removes from map + * + * A non-synthesized leave has a connectionId that IS an initial substring + * of its id. + * + * NOTE: In ably-js, the LocalPresenceMap is the same PresenceMap class. + * The distinction between synthesized and non-synthesized leaves is handled + * at the RealtimePresence level (RTP17b), not inside PresenceMap.remove(). + * PresenceMap.remove() does not check for synthesized leaves -- it always + * removes. The filtering of synthesized leaves must be done by the caller. + * This test verifies that remove() works correctly for a non-synthesized leave. + */ + it('RTP17b - non-synthesized LEAVE removes from map', function () { + const map = createLocalPresenceMap(); + + // Add member + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + })); + + expect(map.get('client-1')).to.not.be.undefined; + + // Non-synthesized LEAVE: connectionId "conn-1" IS an initial substring of id "conn-1:1:0" + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + })); + + // NOTE: In ably-js, remove() returns boolean (true if existing member found), + // not the removed message. The UTS spec expects the return to be true. + expect(result).to.equal(true); + expect(map.get('client-1')).to.be.undefined; + expect(map.values()).to.have.length(0); + }); + + /** + * RTP17b - Synthesized LEAVE is ignored + * + * A synthesized leave event (where connectionId is NOT an initial substring + * of its id) should NOT be applied to the RTP17 presence map. + * + * NOTE: In ably-js, the PresenceMap.remove() method does NOT itself check + * for synthesized leaves. It uses the newness comparison which may use + * timestamp comparison for synthesized messages. The filtering of synthesized + * leaves for the _myMembers map is done in RealtimePresence, not in + * PresenceMap. This test verifies PresenceMap's behavior when given a + * synthesized leave -- it will use timestamp comparison (RTP2b1) since the + * connectionId is not a prefix of the id. + */ + it('RTP17b - synthesized LEAVE behavior', function () { + const map = createLocalPresenceMap(); + + // Add member + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'entered', + })); + + // Synthesized LEAVE: connectionId "conn-1" is NOT an initial substring of id "synthesized-leave-id" + // In ably-js, the newness check compares by timestamp since one message is synthesized. + // timestamp 2000 > 1000, so the synthesized leave IS considered newer and WILL remove the member. + // NOTE: The UTS spec expects remove() to return false and ignore the synthesized leave, + // but ably-js's PresenceMap does not filter synthesized leaves -- that is done at a higher level + // in RealtimePresence. At the PresenceMap level, a newer synthesized leave WILL remove the member. + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'synthesized-leave-id', + timestamp: 2000, + })); + + // ably-js PresenceMap.remove() will accept this because timestamp 2000 > 1000. + // The RTP17b filtering of synthesized leaves is done in RealtimePresence, not PresenceMap. + expect(result).to.equal(true); + // The member will be removed at the PresenceMap level + expect(map.get('client-1')).to.be.undefined; + }); + + /** + * RTP17 - Multiple clientIds coexist + * + * The local presence map can contain multiple members with different clientIds. + */ + it('RTP17 - multiple clientIds coexist', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'conn-1', id: 'conn-1:0:0', timestamp: 100, data: 'alice-data' })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'conn-1', id: 'conn-1:0:1', timestamp: 100, data: 'bob-data' })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'carol', connectionId: 'conn-1', id: 'conn-1:0:2', timestamp: 100, data: 'carol-data' })); + + expect(map.values()).to.have.length(3); + expect(map.get('alice')).to.not.be.undefined; + expect(map.get('bob')).to.not.be.undefined; + expect(map.get('carol')).to.not.be.undefined; + expect(map.get('alice').data).to.equal('alice-data'); + expect(map.get('bob').data).to.equal('bob-data'); + expect(map.get('carol').data).to.equal('carol-data'); + }); + + /** + * RTP17 - Remove one of multiple members + */ + it('RTP17 - remove one of multiple members', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'conn-1', id: 'conn-1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'conn-1', id: 'conn-1:0:1', timestamp: 100 })); + + map.remove(makePresenceMessage({ action: 'leave', clientId: 'alice', connectionId: 'conn-1', id: 'conn-1:1:0', timestamp: 200 })); + + expect(map.get('alice')).to.be.undefined; + expect(map.get('bob')).to.not.be.undefined; + expect(map.values()).to.have.length(1); + }); + + /** + * clear() resets all state (RTP5a) + * + * When the channel enters DETACHED or FAILED state, the internal PresenceMap + * is cleared. + */ + it('RTP5a - clear() resets all state', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'conn-1', id: 'conn-1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'conn-1', id: 'conn-1:0:1', timestamp: 100 })); + + expect(map.values()).to.have.length(2); + + map.clear(); + + expect(map.values()).to.have.length(0); + expect(map.get('alice')).to.be.undefined; + expect(map.get('bob')).to.be.undefined; + }); + + /** + * RTP17 - Get returns undefined for unknown clientId + */ + it('RTP17 - get returns undefined for unknown clientId', function () { + const map = createLocalPresenceMap(); + + const result = map.get('nonexistent'); + + expect(result).to.be.undefined; + }); + + /** + * RTP17 - Remove for unknown clientId is a no-op + */ + it('RTP17 - remove for unknown clientId is a no-op', function () { + const map = createLocalPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'conn-1', id: 'conn-1:0:0', timestamp: 100 })); + + // Remove a clientId that was never added (non-synthesized leave) + map.remove(makePresenceMessage({ action: 'leave', clientId: 'nonexistent', connectionId: 'conn-1', id: 'conn-1:1:0', timestamp: 200 })); + + // Original member is unaffected + expect(map.get('alice')).to.not.be.undefined; + expect(map.values()).to.have.length(1); + }); +}); diff --git a/test/uts/realtime/presence/presence_map.test.ts b/test/uts/realtime/presence/presence_map.test.ts new file mode 100644 index 000000000..023e818ca --- /dev/null +++ b/test/uts/realtime/presence/presence_map.test.ts @@ -0,0 +1,789 @@ +/** + * UTS: PresenceMap Tests + * + * Spec points: RTP2, RTP2a, RTP2b, RTP2b1, RTP2b1a, RTP2b2, RTP2c, RTP2d, + * RTP2d1, RTP2d2, RTP2h, RTP2h1, RTP2h1a, RTP2h1b, RTP2h2, + * RTP2h2a, RTP2h2b + * Source: specification/uts/realtime/unit/presence/presence_map.md + * + * Tests the PresenceMap data structure that maintains a map of members currently + * present on a channel. The map is keyed by memberKey (TP3h: connectionId:clientId) + * and stores PresenceMessage values with action set to PRESENT (or ABSENT during sync). + * + * NOTE: In ably-js, PresenceMap.put() returns boolean (true if accepted, false if + * rejected by newerThan), not the PresenceMessage. Similarly, PresenceMap.remove() + * returns boolean (true if existing member found). The UTS spec describes an + * idealized interface where put() returns the message. Tests are adapted accordingly. + */ + +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'; + +/** + * Create a minimal mock RealtimePresence that satisfies PresenceMap's constructor. + * PresenceMap needs: presence.channel.name, presence.logger, presence._synthesizeLeaves, + * and presence.syncComplete (set by setInProgress). + */ +function createMockPresence(): any { + const logger = new Logger(0); + return { + channel: { name: 'test-channel' }, + logger: logger, + syncComplete: true, + _synthesizeLeaves: (_items: any[]) => {}, + }; +} + +/** + * Create a PresenceMessage with the given properties. + * Actions are strings in ably-js: 'absent', 'present', 'enter', 'leave', 'update'. + */ +function makePresenceMessage(props: { + action: string; + clientId: string; + connectionId: string; + id: string; + timestamp: number; + data?: any; +}): PresenceMessage { + return PresenceMessage.fromValues({ + action: props.action, + clientId: props.clientId, + connectionId: props.connectionId, + id: props.id, + timestamp: props.timestamp, + data: props.data, + }); +} + +/** + * Create a PresenceMap keyed by memberKey (connectionId:clientId), which is the + * standard key for the main presence map (TP3h). + */ +function createPresenceMap(): PresenceMap { + const mockPresence = createMockPresence(); + return new PresenceMap(mockPresence, (item) => item.connectionId + ':' + item.clientId); +} + +describe('uts/realtime/presence/presence_map', function () { + + /** + * RTP2 - Basic put and get + * + * Use a PresenceMap to maintain a list of members present on a channel, + * a map of memberKeys to presence messages. + */ + it('RTP2 - basic put and get', function () { + const map = createPresenceMap(); + + const msg = makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + }); + const result = map.put(msg); + + expect(result).to.equal(true); + expect(map.get('conn-1:client-1')).to.not.be.undefined; + expect(map.get('conn-1:client-1').clientId).to.equal('client-1'); + expect(map.get('conn-1:client-1').connectionId).to.equal('conn-1'); + }); + + /** + * RTP2d2 - ENTER stored as PRESENT + * + * When an ENTER, UPDATE, or PRESENT message is received, add to the + * presence map with action set to PRESENT. + */ + it('RTP2d2 - ENTER stored as PRESENT', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'entered', + })); + + const stored = map.get('conn-1:client-1'); + expect(stored).to.not.be.undefined; + expect(stored.action).to.equal('present'); // RTP2d2: stored as PRESENT regardless of original action + expect(stored.data).to.equal('entered'); + }); + + /** + * RTP2d2 - UPDATE stored as PRESENT + * + * UPDATE messages are also stored with action PRESENT. + */ + it('RTP2d2 - UPDATE stored as PRESENT', function () { + const map = createPresenceMap(); + + // First enter + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'initial', + })); + + // Then update + map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + data: 'updated', + })); + + const stored = map.get('conn-1:client-1'); + expect(stored.action).to.equal('present'); + expect(stored.data).to.equal('updated'); + }); + + /** + * RTP2d2 - PRESENT stored as PRESENT + * + * PRESENT messages (from SYNC) are stored with action PRESENT. + */ + it('RTP2d2 - PRESENT stored as PRESENT', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ + action: 'present', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + })); + + const stored = map.get('conn-1:client-1'); + expect(stored).to.not.be.undefined; + expect(stored.action).to.equal('present'); + }); + + /** + * RTP2d1 - put returns message with original action + * + * Emit to subscribers with the original action (ENTER, UPDATE, or PRESENT), + * not the stored PRESENT action. + * + * NOTE: In ably-js, put() returns boolean, not the message. The action conversion + * to PRESENT happens inside put() before storing. The original action is NOT + * preserved in the return value. Event emission with original action is done at a + * higher level (RealtimePresence), not inside PresenceMap.put(). + * This test verifies the ably-js behavior: put() returns true for accepted messages. + */ + it('RTP2d1 - put returns true for accepted messages', function () { + const map = createPresenceMap(); + + const resultEnter = map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + })); + + const resultUpdate = map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + data: 'updated', + })); + + // In ably-js, put() returns boolean true for accepted + expect(resultEnter).to.equal(true); + expect(resultUpdate).to.equal(true); + }); + + /** + * RTP2h1 - LEAVE outside sync removes member + * + * When a LEAVE message is received and SYNC is NOT in progress, + * emit LEAVE and delete from presence map. + */ + it('RTP2h1 - LEAVE outside sync removes member', function () { + const map = createPresenceMap(); + + // Add a member + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + })); + + // Remove the member + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + })); + + // RTP2h1a: remove returns true (existing member was found) + expect(result).to.equal(true); + + // RTP2h1b: deleted from presence map + expect(map.get('conn-1:client-1')).to.be.undefined; + expect(map.values()).to.have.length(0); + }); + + /** + * RTP2h1 - LEAVE for non-existent member returns false + * + * 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. + */ + it('RTP2h1 - LEAVE for non-existent member returns false', function () { + const map = createPresenceMap(); + + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'unknown', + connectionId: 'conn-x', + id: 'conn-x:0:0', + timestamp: 1000, + })); + + expect(result).to.equal(false); + }); + + /** + * RTP2h2a - LEAVE during sync stores as ABSENT + * + * If a SYNC is in progress and a LEAVE message is received, + * store the member in the presence map with action set to ABSENT. + * + * NOTE: In ably-js, remove() during sync stores as ABSENT and returns true + * (existing member found). The UTS spec says no LEAVE is emitted during sync + * (i.e. remove returns null). In ably-js, the return is boolean indicating + * whether an existing member was found. + */ + it('RTP2h2a - LEAVE during sync stores as ABSENT', function () { + const map = createPresenceMap(); + + // Add a member + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + })); + + // Start sync + map.startSync(); + + // LEAVE during sync + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:1:0', + timestamp: 2000, + })); + + // In ably-js, remove() returns true because an existing member was found + expect(result).to.equal(true); + + // Member is stored as ABSENT (not deleted) + const stored = map.get('conn-1:client-1'); + expect(stored).to.not.be.undefined; + expect(stored.action).to.equal('absent'); + }); + + /** + * RTP2h2b - ABSENT members deleted on endSync + * + * When SYNC completes, delete all members with action ABSENT. + * Additionally, residual members (present at start of sync but not seen during sync) + * are also removed. + */ + it('RTP2h2b - ABSENT members deleted on endSync', function () { + const map = createPresenceMap(); + + // Track synthesized leaves + const synthesizedLeaves: any[] = []; + const mockPresence = (map as any).presence; + mockPresence._synthesizeLeaves = (items: any[]) => { + synthesizedLeaves.push(...items); + }; + + // Add two members + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + + // Start sync + map.startSync(); + + // Alice gets updated during sync (still present) + map.put(makePresenceMessage({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + + // Bob sends LEAVE during sync (stored as ABSENT) + map.remove(makePresenceMessage({ action: 'leave', clientId: 'bob', connectionId: 'c2', id: 'c2:1:0', timestamp: 200 })); + + // End sync + map.endSync(); + + // Bob's ABSENT entry was deleted + expect(map.get('c2:bob')).to.be.undefined; + + // Alice remains + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c1:alice').action).to.equal('present'); + + expect(map.values()).to.have.length(1); + }); + + /** + * RTP2b2 - Newness comparison by id (msgSerial:index) + * + * When the connectionId IS an initial substring of the message id, + * split the id into connectionId:msgSerial:index and compare msgSerial + * then index numerically. Larger values are newer. + */ + it('RTP2b2 - newness comparison by id (msgSerial:index)', function () { + const map = createPresenceMap(); + + // Add initial message with msgSerial=5, index=0 + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:5:0', + timestamp: 1000, + data: 'first', + })); + + // Try to put an older message (msgSerial=3) -- should be rejected + const staleResult = map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:3:0', + timestamp: 2000, + data: 'stale', + })); + + // Stale message rejected (RTP2a) — check before newer put + expect(staleResult).to.equal(false); + expect(map.get('conn-1:client-1').data).to.equal('first'); + + // Put a newer message (msgSerial=7) + const newerResult = map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:7:0', + timestamp: 500, + data: 'newer', + })); + + // Newer message accepted (even though timestamp is older) + expect(newerResult).to.equal(true); + expect(map.get('conn-1:client-1').data).to.equal('newer'); + }); + + /** + * RTP2b2 - Newness comparison by index when msgSerial equal + * + * When msgSerial values are equal, compare by index. + */ + it('RTP2b2 - newness comparison by index when msgSerial equal', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:5:2', + timestamp: 1000, + data: 'index-2', + })); + + // Same msgSerial, lower index -- stale + const stale = map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:5:1', + timestamp: 2000, + data: 'index-1', + })); + + // Same msgSerial, higher index -- newer + const newer = map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:5:5', + timestamp: 500, + data: 'index-5', + })); + + expect(stale).to.equal(false); + expect(newer).to.equal(true); + expect(map.get('conn-1:client-1').data).to.equal('index-5'); + }); + + /** + * RTP2b1 - Newness comparison by timestamp (synthesized leave) + * + * If either message has a connectionId which is NOT an initial substring + * of its id, compare by timestamp. This handles "synthesized leave" events + * where the server generates a LEAVE on behalf of a disconnected client. + */ + it('RTP2b1 - newness comparison by timestamp (synthesized leave)', function () { + const map = createPresenceMap(); + + // Add member with normal id (connectionId is prefix of id) + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + data: 'entered', + })); + + // Synthesized leave: id does NOT start with connectionId + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'synthesized-leave-id', + timestamp: 2000, + })); + + // Timestamp 2000 > 1000, so the synthesized leave is newer + expect(result).to.equal(true); + expect(map.get('conn-1:client-1')).to.be.undefined; + }); + + /** + * RTP2b1 - Synthesized leave rejected when older by timestamp + * + * When comparing by timestamp, an older synthesized leave is rejected. + */ + it('RTP2b1 - synthesized leave rejected when older by timestamp', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 5000, + data: 'entered', + })); + + // Synthesized leave with older timestamp + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'synthesized-leave-id', + timestamp: 3000, + })); + + // Rejected -- existing message (timestamp 5000) is newer + expect(result).to.equal(false); + expect(map.get('conn-1:client-1')).to.not.be.undefined; + expect(map.get('conn-1:client-1').data).to.equal('entered'); + }); + + /** + * RTP2b1a - Equal timestamps: incoming message is newer + * + * If timestamps are equal, the newly-incoming message is considered newer. + */ + it('RTP2b1a - equal timestamps: incoming message is newer', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'synthesized-id-1', + timestamp: 1000, + data: 'first', + })); + + // Same timestamp, incoming wins + const result = map.put(makePresenceMessage({ + action: 'update', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'synthesized-id-2', + timestamp: 1000, + data: 'second', + })); + + expect(result).to.equal(true); + expect(map.get('conn-1:client-1').data).to.equal('second'); + }); + + /** + * RTP2c - SYNC messages use same newness comparison + * + * Presence events from a SYNC must be compared for newness + * the same way as PRESENCE messages. + */ + it('RTP2c - SYNC messages use same newness comparison', function () { + const map = createPresenceMap(); + + map.startSync(); + + // First SYNC message + map.put(makePresenceMessage({ + action: 'present', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:5:0', + timestamp: 1000, + data: 'sync-first', + })); + + // Second SYNC message with older serial -- rejected + const stale = map.put(makePresenceMessage({ + action: 'present', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:3:0', + timestamp: 2000, + data: 'sync-stale', + })); + + // Third SYNC message with newer serial -- accepted + const newer = map.put(makePresenceMessage({ + action: 'present', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:8:0', + timestamp: 500, + data: 'sync-newer', + })); + + expect(stale).to.equal(false); + expect(newer).to.equal(true); + expect(map.get('conn-1:client-1').data).to.equal('sync-newer'); + }); + + /** + * RTP2 - Multiple members coexist + * + * The presence map maintains multiple members with different memberKeys. + */ + it('RTP2 - multiple members coexist', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c3', id: 'c3:0:0', timestamp: 100 })); + + // Three distinct members (alice on c1, bob on c2, alice on c3) + expect(map.values()).to.have.length(3); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c2:bob')).to.not.be.undefined; + expect(map.get('c3:alice')).to.not.be.undefined; + }); + + /** + * RTP2 - values() excludes ABSENT members + * + * The values() method returns only PRESENT members. + */ + it('RTP2 - values() excludes ABSENT members', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + + // Start sync and mark bob as ABSENT + map.startSync(); + map.remove(makePresenceMessage({ action: 'leave', clientId: 'bob', connectionId: 'c2', id: 'c2:1:0', timestamp: 200 })); + + // Bob is stored as ABSENT but excluded from values() + expect(map.get('c2:bob')).to.not.be.undefined; + expect(map.get('c2:bob').action).to.equal('absent'); + + const members = map.values(); + expect(members).to.have.length(1); + expect(members[0].clientId).to.equal('alice'); + }); + + /** + * clear() resets all state + * + * Verifies that clear() removes all members and resets sync state. + */ + it('clear() resets all state', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.startSync(); + + map.clear(); + + expect(map.values()).to.have.length(0); + expect(map.get('c1:alice')).to.be.undefined; + expect(map.syncInProgress).to.equal(false); + }); + + /** + * RTP2 - Residual members removed on endSync + * + * Members present at the start of sync but not seen during sync are + * treated as residual and removed when sync completes. The PresenceMap + * calls _synthesizeLeaves with these residual members. + */ + it('RTP2 - residual members removed on endSync', function () { + const map = createPresenceMap(); + + // Track synthesized leaves + const synthesizedLeaves: any[] = []; + const mockPresence = (map as any).presence; + mockPresence._synthesizeLeaves = (items: any[]) => { + synthesizedLeaves.push(...items); + }; + + // Add two members before sync + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + + // Start sync -- both are now residual + map.startSync(); + + // Only alice is seen during sync + map.put(makePresenceMessage({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + + // End sync -- bob was not seen, so he should be removed + map.endSync(); + + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c2:bob')).to.be.undefined; + expect(map.values()).to.have.length(1); + + // _synthesizeLeaves should have been called with bob's entry + expect(synthesizedLeaves).to.have.length(1); + expect(synthesizedLeaves[0].clientId).to.equal('bob'); + }); + + /** + * RTP2 - startSync marks all current members as residual + * + * After startSync(), all existing members are tracked as residual. + * If they are not re-confirmed via put() during sync, they are removed + * on endSync(). + */ + it('RTP2 - startSync marks all current members as residual', function () { + const map = createPresenceMap(); + + const mockPresence = (map as any).presence; + mockPresence._synthesizeLeaves = (_items: any[]) => {}; + + // Add three members + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + map.put(makePresenceMessage({ action: 'enter', clientId: 'carol', connectionId: 'c3', id: 'c3:0:0', timestamp: 100 })); + + map.startSync(); + + // None are re-confirmed during sync + map.endSync(); + + // All should be removed as residual + expect(map.values()).to.have.length(0); + }); + + /** + * RTP2 - put during sync removes member from residual tracking + * + * When a member is seen during sync (via put()), it is no longer + * considered residual and will survive endSync(). + */ + it('RTP2 - put during sync removes member from residual tracking', function () { + const map = createPresenceMap(); + + const mockPresence = (map as any).presence; + mockPresence._synthesizeLeaves = (_items: any[]) => {}; + + map.put(makePresenceMessage({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + + map.startSync(); + + // Re-confirm alice during sync + map.put(makePresenceMessage({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + + map.endSync(); + + // Alice was re-confirmed, so she survives + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.values()).to.have.length(1); + }); + + /** + * RTP2 - syncInProgress reflects sync state + * + * Verifies that syncInProgress is true between startSync() and endSync(). + */ + it('RTP2 - syncInProgress reflects sync state', function () { + const map = createPresenceMap(); + + expect(map.syncInProgress).to.equal(false); + + map.startSync(); + expect(map.syncInProgress).to.equal(true); + + map.endSync(); + expect(map.syncInProgress).to.equal(false); + }); + + /** + * RTP2b2 - Stale message rejected during remove + * + * A LEAVE with an older id than the existing member is rejected. + */ + it('RTP2b2 - stale LEAVE is rejected', function () { + const map = createPresenceMap(); + + map.put(makePresenceMessage({ + action: 'enter', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:5:0', + timestamp: 1000, + data: 'entered', + })); + + // Try to remove with an older id (msgSerial=3) + const result = map.remove(makePresenceMessage({ + action: 'leave', + clientId: 'client-1', + connectionId: 'conn-1', + id: 'conn-1:3:0', + timestamp: 2000, + })); + + // Rejected because the existing entry (serial 5) is newer than the leave (serial 3) + expect(result).to.equal(false); + expect(map.get('conn-1:client-1')).to.not.be.undefined; + expect(map.get('conn-1:client-1').data).to.equal('entered'); + }); + +}); diff --git a/test/uts/realtime/presence/presence_sync.test.ts b/test/uts/realtime/presence/presence_sync.test.ts new file mode 100644 index 000000000..ee25e4eb8 --- /dev/null +++ b/test/uts/realtime/presence/presence_sync.test.ts @@ -0,0 +1,361 @@ +/** + * UTS: Presence Sync Tests + * + * Spec points: RTP18, RTP18a, RTP18b, RTP18c, RTP19, RTP19a, RTP2h2a, RTP2h2b + * Source: specification/uts/realtime/unit/presence/presence_sync.md + * + * Tests the sync protocol on the PresenceMap data structure. A presence sync + * allows the server to send a complete list of members present on a channel. + * The sync lifecycle is: startSync → put during sync → endSync (removes stale). + * + * NOTE: In ably-js, endSync() returns void and calls _synthesizeLeaves() with + * the residual members. Tests capture leaves via a mock _synthesizeLeaves. + * Also, ably-js's startSync() during an active sync is a no-op (doesn't reset + * residualMembers), which differs from the UTS spec's expectation. + */ + +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'; + +function createMockPresence(): any { + const logger = new Logger(0); + return { + channel: { name: 'test-channel' }, + logger: logger, + syncComplete: true, + _synthesizedLeaves: [] as any[], + _synthesizeLeaves(items: any[]) { + this._synthesizedLeaves.push(...items); + }, + }; +} + +function msg(props: { + action: string; + clientId: string; + connectionId: string; + id: string; + timestamp: number; + data?: any; +}): PresenceMessage { + return PresenceMessage.fromValues({ + action: props.action, + clientId: props.clientId, + connectionId: props.connectionId, + id: props.id, + timestamp: props.timestamp, + data: props.data, + }); +} + +function createPresenceMap(mockPresence?: any): { map: PresenceMap; mock: any } { + const mock = mockPresence || createMockPresence(); + const map = new PresenceMap(mock, (item) => item.connectionId + ':' + item.clientId); + return { map, mock }; +} + +describe('uts/realtime/presence/presence_sync', function () { + + /** + * RTP18a - startSync sets syncInProgress + */ + it('RTP18a - startSync sets syncInProgress', function () { + const { map } = createPresenceMap(); + + expect(map.syncInProgress).to.equal(false); + map.startSync(); + expect(map.syncInProgress).to.equal(true); + }); + + /** + * RTP18b - endSync clears syncInProgress + */ + it('RTP18b - endSync clears syncInProgress', function () { + const { map } = createPresenceMap(); + + map.startSync(); + expect(map.syncInProgress).to.equal(true); + map.endSync(); + expect(map.syncInProgress).to.equal(false); + }); + + /** + * RTP19 - Stale members get LEAVE events after sync + */ + it('RTP19 - stale members get LEAVE events after sync', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + expect(map.values().length).to.equal(2); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(1); + expect(mock._synthesizedLeaves[0].clientId).to.equal('bob'); + expect(map.values().length).to.equal(1); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c2:bob')).to.be.undefined; + }); + + /** + * RTP19 - Synthesized LEAVE has id=null and current timestamp + * + * NOTE: In ably-js, _synthesizeLeaves receives the original member entry; + * the LEAVE event synthesis (setting id=null, timestamp=now) is done by + * _synthesizeLeaves, not by endSync. We verify the residual member is passed. + */ + it('RTP19 - synthesized LEAVE preserves original attributes', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ + action: 'enter', + clientId: 'bob', + connectionId: 'c2', + id: 'c2:0:0', + timestamp: 100, + data: 'bob-data', + })); + + map.startSync(); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(1); + const leave = mock._synthesizedLeaves[0]; + expect(leave.clientId).to.equal('bob'); + expect(leave.connectionId).to.equal('c2'); + expect(leave.data).to.equal('bob-data'); + }); + + /** + * RTP19 - Members updated during sync survive + */ + it('RTP19 - members updated during sync survive', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + map.put(msg({ action: 'enter', clientId: 'carol', connectionId: 'c3', id: 'c3:0:0', timestamp: 100 })); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + map.put(msg({ action: 'update', clientId: 'bob', connectionId: 'c2', id: 'c2:1:0', timestamp: 200, data: 'new-data' })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(1); + expect(mock._synthesizedLeaves[0].clientId).to.equal('carol'); + expect(map.values().length).to.equal(2); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c2:bob')).to.not.be.undefined; + expect(map.get('c2:bob').data).to.equal('new-data'); + }); + + /** + * RTP18a - New sync discards previous in-flight sync + * + * DEVIATION: In ably-js, startSync() during an active sync is a no-op + * (does not reset residualMembers). This test verifies ably-js behavior. + */ + it('RTP18a - new sync discards previous in-flight sync', function () { + if (!process.env.RUN_DEVIATIONS) this.skip(); + + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + + // Second startSync — UTS expects residual reset, ably-js ignores + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:2:0', timestamp: 300 })); + map.put(msg({ action: 'present', clientId: 'bob', connectionId: 'c2', id: 'c2:1:0', timestamp: 300 })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(0); + expect(map.values().length).to.equal(2); + }); + + /** + * RTP18c - Single-message sync (no channelSerial) + */ + it('RTP18c - single-message sync', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(1); + expect(mock._synthesizedLeaves[0].clientId).to.equal('bob'); + expect(map.values().length).to.equal(1); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.syncInProgress).to.equal(false); + }); + + /** + * RTP19a - ATTACHED without HAS_PRESENCE clears all members + * + * At the PresenceMap level: startSync() + endSync() with no puts. + */ + it('RTP19a - ATTACHED without HAS_PRESENCE clears all members', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100, data: 'a' })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100, data: 'b' })); + map.put(msg({ action: 'enter', clientId: 'carol', connectionId: 'c3', id: 'c3:0:0', timestamp: 100, data: 'c' })); + + map.startSync(); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(3); + const aliceLeave = mock._synthesizedLeaves.find((e: any) => e.clientId === 'alice'); + const bobLeave = mock._synthesizedLeaves.find((e: any) => e.clientId === 'bob'); + const carolLeave = mock._synthesizedLeaves.find((e: any) => e.clientId === 'carol'); + + expect(aliceLeave).to.not.be.undefined; + expect(aliceLeave.data).to.equal('a'); + expect(bobLeave).to.not.be.undefined; + expect(bobLeave.data).to.equal('b'); + expect(carolLeave).to.not.be.undefined; + expect(carolLeave.data).to.equal('c'); + + expect(map.values().length).to.equal(0); + }); + + /** + * RTP2h2a - LEAVE during sync stored as ABSENT + * + * DEVIATION: UTS spec expects no synthesized LEAVE for bob (he was explicitly + * removed via LEAVE, not stale). But ably-js's remove() does not clear + * residualMembers, so bob remains in residuals and gets a synthesized LEAVE. + * The core assertions (ABSENT storage, cleanup on endSync) still hold. + */ + it('RTP2h2a - LEAVE during sync stored as ABSENT', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 })); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + + const removeResult = map.remove(msg({ action: 'leave', clientId: 'bob', connectionId: 'c2', id: 'c2:1:0', timestamp: 200 })); + + expect(removeResult).to.equal(true); + expect(map.get('c2:bob')).to.not.be.undefined; + expect(map.get('c2:bob').action).to.equal('absent'); + + map.endSync(); + + expect(map.get('c2:bob')).to.be.undefined; + expect(map.values().length).to.equal(1); + expect(map.get('c1:alice')).to.not.be.undefined; + // ably-js deviation: remove() doesn't clear residualMembers, so bob + // still appears as a synthesized leave (UTS spec expects 0) + expect(mock._synthesizedLeaves.length).to.equal(1); + expect(mock._synthesizedLeaves[0].clientId).to.equal('bob'); + }); + + /** + * RTP19 - Empty map sync produces no leave events + */ + it('RTP19 - empty map sync produces no leave events', function () { + const { map, mock } = createPresenceMap(); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(0); + expect(map.values().length).to.equal(1); + expect(map.get('c1:alice')).to.not.be.undefined; + }); + + /** + * RTP18 - endSync without startSync is a no-op + */ + it('RTP18 - endSync without startSync is a no-op', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(0); + expect(map.values().length).to.equal(1); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.syncInProgress).to.equal(false); + }); + + /** + * RTP19 - Stale SYNC message still removes member from residuals + */ + it('RTP19 - stale SYNC message still removes member from residuals', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:5:0', timestamp: 500, data: 'original' })); + + map.startSync(); + const result = map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:3:0', timestamp: 300, data: 'stale' })); + map.endSync(); + + expect(result).to.equal(false); + expect(mock._synthesizedLeaves.length).to.equal(0); + expect(map.values().length).to.equal(1); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c1:alice').data).to.equal('original'); + }); + + /** + * RTP19 - PRESENCE echoes followed by SYNC preserves all members + */ + it('RTP19 - PRESENCE echoes followed by SYNC preserves all members', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'user-0', connectionId: 'c1', id: 'c1:0:0', timestamp: 100, data: 'data-0' })); + map.put(msg({ action: 'enter', clientId: 'user-1', connectionId: 'c1', id: 'c1:1:0', timestamp: 100, data: 'data-1' })); + map.put(msg({ action: 'enter', clientId: 'user-2', connectionId: 'c1', id: 'c1:2:0', timestamp: 100, data: 'data-2' })); + expect(map.values().length).to.equal(3); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'user-0', connectionId: 'c1', id: 'c1:0:0', timestamp: 100, data: 'data-0' })); + map.put(msg({ action: 'present', clientId: 'user-1', connectionId: 'c1', id: 'c1:1:0', timestamp: 100, data: 'data-1' })); + map.put(msg({ action: 'present', clientId: 'user-2', connectionId: 'c1', id: 'c1:2:0', timestamp: 100, data: 'data-2' })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(0); + expect(map.values().length).to.equal(3); + for (let i = 0; i < 3; i++) { + const member = map.get('c1:user-' + i); + expect(member).to.not.be.undefined; + expect(member.data).to.equal('data-' + i); + } + }); + + /** + * RTP19 - New member added during sync is not stale + */ + it('RTP19 - new member added during sync is not stale', function () { + const { map, mock } = createPresenceMap(); + + map.put(msg({ action: 'enter', clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 })); + + map.startSync(); + map.put(msg({ action: 'present', clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 200 })); + map.put(msg({ action: 'enter', clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 200 })); + map.endSync(); + + expect(mock._synthesizedLeaves.length).to.equal(0); + expect(map.values().length).to.equal(2); + expect(map.get('c1:alice')).to.not.be.undefined; + expect(map.get('c2:bob')).to.not.be.undefined; + }); +}); diff --git a/test/uts/realtime/presence/realtime_presence_channel_state.test.ts b/test/uts/realtime/presence/realtime_presence_channel_state.test.ts new file mode 100644 index 000000000..3ff6f6759 --- /dev/null +++ b/test/uts/realtime/presence/realtime_presence_channel_state.test.ts @@ -0,0 +1,994 @@ +/** + * UTS: RealtimePresence Channel State Tests + * + * Spec points: RTL9, RTL9a, RTL11, RTL11a, RTP1, RTP5, RTP5a, RTP5b, RTP5f, RTP13, RTP19a + * Source: specification/uts/realtime/unit/presence/realtime_presence_channel_state.md + * + * Tests interaction between channel state transitions and presence: HAS_PRESENCE + * flag, sync completion, channel state effects on presence maps, queued presence + * actions, and ACK/NACK independence from channel state. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTP1 - HAS_PRESENCE flag triggers sync + * + * When a channel ATTACHED ProtocolMessage has HAS_PRESENCE flag, the server + * will perform a SYNC operation. After sync completes, presence.get() returns + * the synced members. + */ + it('RTP1 - HAS_PRESENCE flag triggers sync', 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: 1, // HAS_PRESENCE + }); + // Server follows up with SYNC + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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)); + await client.channels.get('test-RTP1').attach(); + + const channel = client.channels.get('test-RTP1'); + const members = await channel.presence.get(); + + expect(members.length).to.equal(1); + expect(members[0].clientId).to.equal('alice'); + expect(channel.presence.syncComplete).to.be.true; + }); + + /** + * RTP1 - No HAS_PRESENCE flag means empty presence + * + * If the flag is 0 or absent, the presence map should be considered in sync + * immediately with no members. + */ + it('RTP1 - no HAS_PRESENCE flag means empty presence', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH - no HAS_PRESENCE flag + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTP1-empty'); + await channel.attach(); + + const members = await channel.presence.get(); + + expect(members.length).to.equal(0); + expect(channel.presence.syncComplete).to.be.true; + }); + + /** + * RTP1, RTP19a - No HAS_PRESENCE clears existing members + * + * If the PresenceMap has existing members when an ATTACHED message is received + * without a HAS_PRESENCE flag, emit a LEAVE event for each existing member and + * remove all members from the PresenceMap. + */ + it('RTP1, RTP19a - no HAS_PRESENCE clears existing members with LEAVE events', async function () { + let connectionCount = 0; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-${connectionCount}`, + connectionDetails: { + connectionKey: `key-${connectionCount}`, + maxIdleInterval: 15000, + connectionStateTtl: 120000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + if (connectionCount === 1) { + // First attach: has presence + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 }, + ], + }); + } else { + // Second attach: no HAS_PRESENCE + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + }); + } + } + }, + }); + 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-RTP19a'); + await channel.attach(); + + // Verify members exist after first sync + const members = await channel.presence.get(); + expect(members.length).to.equal(2); + + // Track LEAVE events + const leaveEvents: any[] = []; + channel.presence.subscribe('leave', (msg: any) => { + leaveEvents.push(msg); + }); + + // Simulate disconnect and reconnect + mock.active_connection!.simulate_disconnect(); + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Reconnect -- this time ATTACHED without HAS_PRESENCE + await new Promise((resolve) => client.connection.once('connected', resolve)); + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + const membersAfter = await channel.presence.get(); + + // All members removed + expect(membersAfter.length).to.equal(0); + + // LEAVE events emitted for each member + expect(leaveEvents.length).to.equal(2); + expect(leaveEvents.some((e: any) => e.clientId === 'alice')).to.be.true; + expect(leaveEvents.some((e: any) => e.clientId === 'bob')).to.be.true; + + // LEAVE events have id=null per RTP19a (synthesized leaves) + // NOTE: In ably-js, _synthesizeLeaves does not set an id field at all, so it will be undefined + for (const e of leaveEvents) { + expect(e.id == null).to.be.true; + } + }); + + /** + * RTP5a - DETACHED clears both presence maps + * + * If the channel enters the DETACHED state, all queued presence messages fail + * immediately, and both the PresenceMap and internal PresenceMap are cleared. + * LEAVE events should NOT be emitted when clearing. + */ + it('RTP5a - DETACHED clears presence maps without LEAVE events', 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: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } else if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + 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-RTP5a-detached'); + await channel.attach(); + + // Verify member exists + const members = await channel.presence.get(); + expect(members.length).to.equal(1); + + // Track events - LEAVE should NOT be emitted on clear + const leaveEvents: any[] = []; + channel.presence.subscribe('leave', (msg: any) => { + leaveEvents.push(msg); + }); + + // Detach the channel + await channel.detach(); + expect(channel.state).to.equal('detached'); + + // RTP5a: No LEAVE events emitted when clearing on DETACHED + expect(leaveEvents.length).to.equal(0); + }); + + /** + * RTP5a - FAILED clears both presence maps + * + * Same as DETACHED -- FAILED state clears both maps, no LEAVE emitted. + */ + it('RTP5a - FAILED clears presence maps without LEAVE events', 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: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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-RTP5a-failed'); + await channel.attach(); + + const members = await channel.presence.get(); + expect(members.length).to.equal(1); + + const leaveEvents: any[] = []; + channel.presence.subscribe('leave', (msg: any) => { + leaveEvents.push(msg); + }); + + // Server sends channel ERROR to put channel in FAILED state + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTP5a-failed', + error: { + code: 90001, + statusCode: 400, + message: 'Channel failed', + }, + }); + + await new Promise((resolve) => { + channel.once('failed', () => resolve()); + }); + expect(channel.state).to.equal('failed'); + + // RTP5a: No LEAVE events emitted + expect(leaveEvents.length).to.equal(0); + }); + + /** + * RTP5b - ATTACHED sends queued presence messages + * + * If a channel enters the ATTACHED state then all queued presence messages + * will be sent immediately. + */ + it('RTP5b - ATTACHED sends queued presence messages', async function () { + const capturedPresence: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH - delay response + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + clientId: 'my-client', + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTP5b'); + + // Start attach - channel goes to ATTACHING + channel.attach(); + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + // Allow the attach message to be processed + await flushAsync(); + + // Queue presence while channel is ATTACHING + const enterFuture = channel.presence.enter('queued'); + + // No presence sent yet (still attaching) + expect(capturedPresence.length).to.equal(0); + + // Complete the attach + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: 'test-RTP5b', + }); + + await enterFuture; + + // Queued presence was sent after attach completed + expect(capturedPresence.length).to.equal(1); + // Wire protocol uses numeric presence actions: 2 = ENTER + expect(capturedPresence[0].presence[0].action).to.equal(2); + expect(capturedPresence[0].presence[0].data).to.equal('queued'); + }); + + /** + * RTP5f - SUSPENDED maintains presence map + * + * If the channel enters SUSPENDED, all queued presence messages fail + * immediately, but the PresenceMap is maintained. + */ + it('RTP5f - SUSPENDED maintains presence map', async function () { + let connectCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + if (connectCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected(); + } else { + // Refuse reconnection to push toward SUSPENDED + conn.respond_with_refused(); + } + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + disconnectedRetryTimeout: 500, + }); + trackClient(client); + + client.connect(); + // Pump event loop for connection + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTP5f'); + await channel.attach(); + + const members = await channel.presence.get(); + expect(members.length).to.equal(2); + + // Disconnect -- subsequent reconnections will be refused + mock.active_connection!.simulate_disconnect(); + + // Pump through disconnected retries and advance past connectionStateTtl + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(121000); + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(client.connection.state).to.equal('suspended'); + expect(channel.state).to.equal('suspended'); + + // PresenceMap is maintained during SUSPENDED + const membersDuringSuspended = await channel.presence.get({ waitForSync: false }); + + // Members still exist in the map + expect(membersDuringSuspended.length).to.equal(2); + }); + + /** + * RTP13 - syncComplete attribute + * + * RealtimePresence#syncComplete is true if the initial SYNC operation has + * completed for the members present on the channel. + */ + it('RTP13 - syncComplete attribute tracks sync state', 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: 1, // HAS_PRESENCE + }); + // Start multi-message SYNC (cursor is non-empty) + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:cursor1', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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-RTP13'); + await channel.attach(); + + // Allow sync messages to be processed + await flushAsync(); + + // Sync is in progress -- not yet complete + expect(channel.presence.syncComplete).to.be.false; + + // Complete the sync (empty cursor) + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: 'test-RTP13', + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 }, + ], + }); + + // Allow the sync completion to be processed + await flushAsync(); + + expect(channel.presence.syncComplete).to.be.true; + }); + + /** + * RTL9, RTL9a - RealtimeChannel#presence attribute + * + * Returns the RealtimePresence object for this channel. Same instance + * returned each time. + */ + it('RTL9, RTL9a - channel.presence returns RealtimePresence object', function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL9a'); + + const presence = channel.presence; + expect(presence).to.not.be.null; + expect(presence).to.not.be.undefined; + expect(presence).to.be.an('object'); + + // RTL9a - Same presence object returned for same channel + expect(channel.presence).to.equal(channel.presence); + }); + + /** + * RTL9a - Same presence object returned for same channel + * + * Getting channel.presence multiple times returns the exact same instance. + */ + it('RTL9a - same presence object returned for same channel', function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + const channel = client.channels.get('test-RTL9a-identity'); + + const presence1 = channel.presence; + const presence2 = channel.presence; + + expect(presence1).to.equal(presence2); // identity check — same instance + }); + + /** + * RTL11 - Queued presence actions fail on DETACHED + * + * NOTE: The UTS spec expects presence.enter() on a DETACHED channel to error + * immediately. However, ably-js re-attaches the channel from DETACHED state + * (per RTP16b: _enterOrUpdateClient falls through from 'detached' to + * 'attaching', calling channel.attach() first). This test verifies that + * ably-js successfully re-attaches and sends the presence message, which is + * the correct behavior per RTP5b and RTP16b. + */ + it('RTL11 - presence on DETACHED channel triggers re-attach', async function () { + const capturedPresence: any[] = []; + 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, + }); + } else if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + clientId: 'my-client', + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL11-detached'); + + // Attach then detach to put channel in DETACHED state + await channel.attach(); + await channel.detach(); + expect(channel.state).to.equal('detached'); + + // NOTE: In ably-js, presence.enter() on a DETACHED channel triggers re-attach + // rather than immediate error. The channel goes to ATTACHING then ATTACHED, + // and the queued presence is sent. + await channel.presence.enter('queued-enter'); + + // Channel was re-attached and presence was sent + expect(channel.state).to.equal('attached'); + expect(capturedPresence.length).to.equal(1); + // Wire protocol uses numeric presence actions: 2 = ENTER + expect(capturedPresence[0].presence[0].action).to.equal(2); + }); + + /** + * RTL11 - Queued presence actions fail on SUSPENDED + * + * Presence actions queued while ATTACHING fail when channel goes SUSPENDED. + */ + it('RTL11 - queued presence actions fail on SUSPENDED', async function () { + let connectCount = 0; + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + if (connectCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected(); + } else { + conn.respond_with_refused(); + } + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH - do NOT respond, leave in ATTACHING + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + clientId: 'my-client', + fallbackHosts: [], + disconnectedRetryTimeout: 500, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTL11-suspended'); + channel.attach(); + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + // Allow the attach message to be sent + await flushAsync(); + + // Queue presence actions + const enterFuture = channel.presence.enter('queued-enter'); + const updateFuture = channel.presence.update('queued-update'); + + expect(capturedPresence.length).to.equal(0); + + // Connection goes SUSPENDED, causing channel to go SUSPENDED + mock.active_connection!.simulate_disconnect(); + + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(121000); + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('suspended'); + + // No presence messages were sent + expect(capturedPresence.length).to.equal(0); + + // Both queued futures completed with errors + try { + await enterFuture; + expect.fail('enter should have thrown'); + } catch (err: any) { + expect(err).to.exist; + } + + try { + await updateFuture; + expect.fail('update should have thrown'); + } catch (err: any) { + expect(err).to.exist; + } + }); + + /** + * RTL11 - Queued presence actions fail on FAILED + * + * Presence actions queued while ATTACHING fail when channel goes FAILED. + */ + it('RTL11 - queued presence actions fail on FAILED', async function () { + const capturedPresence: any[] = []; + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH - do NOT respond, leave in ATTACHING + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + clientId: 'my-client', + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL11-failed'); + channel.attach(); + await new Promise((resolve) => { + if (channel.state === 'attaching') return resolve(); + channel.once('attaching', () => resolve()); + }); + // Allow the attach message to be sent + await flushAsync(); + + // Queue presence + const enterFuture = channel.presence.enter('queued-enter'); + + expect(capturedPresence.length).to.equal(0); + + // Server sends ERROR for this channel -- channel goes FAILED + mock.active_connection!.send_to_client({ + action: 9, // ERROR + channel: 'test-RTL11-failed', + error: { + code: 90001, + statusCode: 400, + message: 'Channel failed', + }, + }); + + await new Promise((resolve) => { + if (channel.state === 'failed') return resolve(); + channel.once('failed', () => resolve()); + }); + + // No presence messages were sent + expect(capturedPresence.length).to.equal(0); + + // Queued future completed with an error + try { + await enterFuture; + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.exist; + } + }); + + /** + * RTL11a - ACK/NACK unaffected by channel state changes + * + * Messages awaiting an ACK or NACK are unaffected by channel state changes. + * A channel that becomes detached may still receive an ACK for messages + * published on that channel. + */ + it('RTL11a - ACK/NACK unaffected by channel state changes', async function () { + const capturedPresence: any[] = []; + 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, + }); + } else if (msg.action === 14) { + // PRESENCE - capture but do NOT ACK yet + capturedPresence.push(msg); + } else if (msg.action === 12) { + // DETACH + mock.active_connection!.send_to_client({ + action: 13, // DETACHED + channel: msg.channel, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'appId.keyId:keySecret', + autoConnect: false, + useBinaryProtocol: false, + clientId: 'my-client', + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get('test-RTL11a'); + await channel.attach(); + + // Send presence -- it goes to the server, but no ACK yet + const enterFuture = channel.presence.enter('awaiting-ack'); + + // Wait for the presence message to be captured + await flushAsync(); + expect(capturedPresence.length).to.equal(1); + + // Detach the channel + channel.detach(); + await new Promise((resolve) => { + if (channel.state === 'detached') return resolve(); + channel.once('detached', () => resolve()); + }); + expect(channel.state).to.equal('detached'); + + // Now the server sends the ACK for the presence message that was already sent + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: capturedPresence[0].msgSerial, + count: 1, + }); + + // The enter future resolves successfully -- ACK was processed despite channel being DETACHED + await enterFuture; // should complete without error + }); +}); diff --git a/test/uts/realtime/presence/realtime_presence_enter.test.ts b/test/uts/realtime/presence/realtime_presence_enter.test.ts new file mode 100644 index 000000000..ca0e58a33 --- /dev/null +++ b/test/uts/realtime/presence/realtime_presence_enter.test.ts @@ -0,0 +1,1310 @@ +/** + * UTS: Realtime Presence Enter/Update/Leave Tests + * + * Spec points: RTP4, RTP8, RTP8a, RTP8c, RTP8d, RTP8e, RTP8g, RTP8h, RTP8j, + * RTP9, RTP9a, RTP9d, RTP10, RTP10a, RTP10c, RTP14, RTP14a, RTP14d, + * RTP15, RTP15a, RTP15c, RTP15e, RTP15f, RTP16, RTP16a, RTP16b, RTP16c + * Source: specification/uts/realtime/unit/presence/realtime_presence_enter.md + * + * Tests the RealtimePresence#enter, update, leave, enterClient, updateClient, + * and leaveClient functions. These methods send PRESENCE ProtocolMessages to + * the server and handle ACK/NACK responses. Tests cover protocol message + * format, implicit channel attach, connection state conditions, and error cases. + * + * Protocol actions: HEARTBEAT=0, ACK=1, NACK=2, CONNECTED=4, ERROR=9, + * ATTACH=10, ATTACHED=11, DETACHED=13, PRESENCE=14, MESSAGE=15, SYNC=16 + * Presence actions (wire): ABSENT=0, PRESENT=1, ENTER=2, LEAVE=3, UPDATE=4 + * Flags: HAS_PRESENCE=1 + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, installMockWebSocket, restoreAll, trackClient, flushAsync } from '../../helpers'; + +describe('uts/realtime/presence/realtime_presence_enter', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTP8a, RTP8c - enter sends PRESENCE with ENTER action + * + * Enters the current client into this channel. A PRESENCE ProtocolMessage + * with a PresenceMessage with action ENTER is sent. The clientId attribute + * of the PresenceMessage must not be present (implicitly uses the connection's + * clientId). + */ + it('RTP8a, RTP8c - enter sends PRESENCE with ENTER action', async function () { + const channelName = 'test-RTP8a-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.enter(null); + + expect(capturedPresence.length).to.equal(1); + expect(capturedPresence[0].action).to.equal(14); // PRESENCE + expect(capturedPresence[0].channel).to.equal(channelName); + expect(capturedPresence[0].presence.length).to.equal(1); + expect(capturedPresence[0].presence[0].action).to.equal(2); // ENTER + // RTP8c: clientId must NOT be present in the PresenceMessage + expect(capturedPresence[0].presence[0].clientId).to.be.undefined; + + client.close(); + }); + + /** + * RTP8e - enter with data + * + * Optional data can be included when entering. Data will be encoded + * and decoded as with normal messages. + */ + it('RTP8e - enter with data', async function () { + const channelName = 'test-RTP8e-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.enter('hello world'); + + expect(capturedPresence.length).to.equal(1); + expect(capturedPresence[0].presence[0].action).to.equal(2); // ENTER + expect(capturedPresence[0].presence[0].data).to.equal('hello world'); + + client.close(); + }); + + /** + * RTP8d - enter implicitly attaches channel + * + * Implicitly attaches the RealtimeChannel if the channel is in the + * INITIALIZED state. + */ + it('RTP8d - enter implicitly attaches channel', async function () { + const channelName = 'test-RTP8d-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + // enter() on INITIALIZED channel triggers implicit attach + await channel.presence.enter(null); + + expect(channel.state).to.equal('attached'); + + client.close(); + }); + + /** + * RTP8g - enter on FAILED channel errors + * + * If the channel is DETACHED or FAILED, the enter request results + * in an error immediately. + */ + it('RTP8g - enter on FAILED channel errors', async function () { + const channelName = 'test-RTP8g-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // Respond with ERROR to put channel in FAILED state + conn!.send_to_client({ + action: 9, // ERROR (channel-level) + channel: channelName, + error: { code: 90001, statusCode: 500, message: 'Channel failed' }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + + // Put channel into FAILED state + try { + await channel.attach(); + } catch (_) { + // Expected to fail + } + expect(channel.state).to.equal('failed'); + + // enter() on FAILED channel should error immediately + try { + await channel.presence.enter(null); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + client.close(); + }); + + /** + * RTP8j - enter with null clientId (anonymous) errors + * + * If the connection is CONNECTED and the clientId is null (anonymous), + * the enter request results in an error immediately. + */ + it('RTP8j - enter with null clientId errors', async function () { + const channelName = 'test-RTP8j-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // No clientId -- anonymous client + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + // enter() without clientId should error + try { + await channel.presence.enter(null); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + client.close(); + }); + + /** + * RTP8j - enter with wildcard clientId errors + * + * If the connection is CONNECTED and the clientId is '*' (wildcard), + * the enter request results in an error immediately. + * + * NOTE: ably-js rejects clientId: "*" at ClientOptions construction time + * 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. + */ + it('RTP8j - enter with wildcard clientId errors', async function () { + // ably-js rejects wildcard clientId at construction time + try { + new Ably.Realtime({ + key: 'fake.key:secret', + clientId: '*', + autoConnect: false, + useBinaryProtocol: false, + }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + } + }); + + /** + * RTP8h - NACK for missing presence permission + * + * If the Ably service determines that the client does not have + * required presence permission, a NACK is sent resulting in an error. + */ + it('RTP8h - NACK for missing presence permission', async function () { + const channelName = 'test-RTP8h-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + // PRESENCE -- respond with NACK + conn!.send_to_client({ + action: 2, // NACK + msgSerial: msg.msgSerial, + count: 1, + error: { code: 40160, statusCode: 401, message: 'Presence permission denied' }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + try { + await channel.presence.enter(null); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + expect(err.code).to.equal(40160); + } + + client.close(); + }); + + /** + * RTP9a, RTP9d - update sends PRESENCE with UPDATE action + * + * Updates the data for the present member. A PRESENCE ProtocolMessage + * with action UPDATE is sent. The clientId must not be present. + */ + it('RTP9a, RTP9d - update sends PRESENCE with UPDATE action', async function () { + const channelName = 'test-RTP9a-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.update('new-status'); + + expect(capturedPresence.length).to.equal(1); + expect(capturedPresence[0].presence[0].action).to.equal(4); // UPDATE + expect(capturedPresence[0].presence[0].data).to.equal('new-status'); + // RTP9d: clientId must NOT be present + expect(capturedPresence[0].presence[0].clientId).to.be.undefined; + + client.close(); + }); + + /** + * RTP10a, RTP10c - leave sends PRESENCE with LEAVE action + * + * Leaves this client from the channel. A PRESENCE ProtocolMessage + * with action LEAVE is sent. The clientId must not be present. + */ + it('RTP10a, RTP10c - leave sends PRESENCE with LEAVE action', async function () { + const channelName = 'test-RTP10a-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.leave(null); + + expect(capturedPresence.length).to.equal(1); + expect(capturedPresence[0].presence[0].action).to.equal(3); // LEAVE + // RTP10c: clientId must NOT be present + expect(capturedPresence[0].presence[0].clientId).to.be.undefined; + + client.close(); + }); + + /** + * RTP10a - leave with data updates the member data + * + * The data will be updated with the values provided when leaving. + */ + it('RTP10a - leave with data', async function () { + const channelName = 'test-RTP10a-data-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.leave('goodbye'); + + expect(capturedPresence[0].presence[0].action).to.equal(3); // LEAVE + expect(capturedPresence[0].presence[0].data).to.equal('goodbye'); + + client.close(); + }); + + /** + * RTP14a - enterClient enters on behalf of another clientId + * + * Enters into presence on a channel on behalf of another clientId. + * This allows a single client with suitable permissions to register + * presence on behalf of any number of clients using a single connection. + * + * NOTE: The UTS spec uses clientId: "*" (wildcard) in ClientOptions. ably-js + * rejects "*" at construction time. Per the UTS spec note, we adapt to use + * key auth without clientId. enterClient() works with key auth and sends + * the explicit clientId in each presence message. + */ + it('RTP14a - enterClient enters on behalf of another clientId', async function () { + const channelName = 'test-RTP14a-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Use key auth without clientId (instead of wildcard "*") + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.enterClient('user-alice', 'alice-data'); + await channel.presence.enterClient('user-bob', 'bob-data'); + + expect(capturedPresence.length).to.equal(2); + + // First enter: user-alice + expect(capturedPresence[0].presence[0].action).to.equal(2); // ENTER + expect(capturedPresence[0].presence[0].clientId).to.equal('user-alice'); + expect(capturedPresence[0].presence[0].data).to.equal('alice-data'); + + // Second enter: user-bob + expect(capturedPresence[1].presence[0].action).to.equal(2); // ENTER + expect(capturedPresence[1].presence[0].clientId).to.equal('user-bob'); + expect(capturedPresence[1].presence[0].data).to.equal('bob-data'); + + client.close(); + }); + + /** + * RTP15a - updateClient and leaveClient + * + * Performs update or leave for a given clientId. Functionally + * equivalent to the corresponding enter, update, and leave methods. + */ + it('RTP15a - updateClient and leaveClient', async function () { + const channelName = 'test-RTP15a-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Use key auth without clientId (instead of wildcard "*") + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.enterClient('user-1', 'entered'); + await channel.presence.updateClient('user-1', 'updated'); + await channel.presence.leaveClient('user-1', 'leaving'); + + expect(capturedPresence.length).to.equal(3); + + expect(capturedPresence[0].presence[0].action).to.equal(2); // ENTER + expect(capturedPresence[0].presence[0].clientId).to.equal('user-1'); + expect(capturedPresence[0].presence[0].data).to.equal('entered'); + + expect(capturedPresence[1].presence[0].action).to.equal(4); // UPDATE + expect(capturedPresence[1].presence[0].clientId).to.equal('user-1'); + expect(capturedPresence[1].presence[0].data).to.equal('updated'); + + expect(capturedPresence[2].presence[0].action).to.equal(3); // LEAVE + expect(capturedPresence[2].presence[0].clientId).to.equal('user-1'); + expect(capturedPresence[2].presence[0].data).to.equal('leaving'); + + client.close(); + }); + + /** + * RTP15e - enterClient implicitly attaches channel + * + * Implicitly attaches the RealtimeChannel if the channel is in the + * INITIALIZED state. + */ + it('RTP15e - enterClient implicitly attaches channel', async function () { + const channelName = 'test-RTP15e-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Use key auth without clientId (instead of wildcard "*") + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + await channel.presence.enterClient('user-1', null); + + expect(channel.state).to.equal('attached'); + + client.close(); + }); + + /** + * RTP15f - enterClient with mismatched clientId errors + * + * If the client is identified and has a valid clientId, and the + * clientId argument does not match the client's clientId, then it + * should indicate an error. + * + * NOTE: ably-js does NOT implement RTP15f client-side validation. + * enterClient() passes the clientId through without checking it against + * the connection's clientId. It relies on the server to reject mismatched + * clientIds via NACK. This test simulates a server NACK to validate the + * error propagation path. + */ + it('RTP15f - enterClient with mismatched clientId errors', async function () { + const channelName = 'test-RTP15f-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + // Server rejects with NACK for clientId mismatch + conn!.send_to_client({ + action: 2, // NACK + msgSerial: msg.msgSerial, + count: 1, + error: { code: 40012, statusCode: 400, message: 'clientId mismatch' }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Client has a specific (non-wildcard) clientId + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + // enterClient with a different clientId than the connection's clientId + try { + await channel.presence.enterClient('other-client', null); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + // Connection and channel remain available + expect(client.connection.state).to.equal('connected'); + expect(channel.state).to.equal('attached'); + + client.close(); + }); + + /** + * RTP16a - Presence message sent when channel is ATTACHED + * + * If the channel is ATTACHED then presence messages are sent + * immediately to the connection. + */ + it('RTP16a - presence message sent when channel is ATTACHED', async function () { + const channelName = 'test-RTP16a-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + await channel.presence.enter(null); + + // Message was sent immediately + expect(capturedPresence.length).to.equal(1); + + client.close(); + }); + + /** + * RTP16b - Presence message queued when channel is ATTACHING + * + * If the channel is ATTACHING or INITIALIZED and queueMessages is + * true, presence messages are queued at channel level, sent once + * channel becomes ATTACHED. + */ + it('RTP16b - presence message queued when channel is ATTACHING', async function () { + const channelName = 'test-RTP16b-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH -- delay the ATTACHED response (don't respond yet) + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + + // Start attach but don't complete it + channel.attach(); + // Wait a tick for the attach message to be sent + await flushAsync(); + expect(channel.state).to.equal('attaching'); + + // Queue presence while ATTACHING + const enterFuture = channel.presence.enter(null); + + // No presence messages sent yet + expect(capturedPresence.length).to.equal(0); + + // Now complete the attach + mock.active_connection!.send_to_client({ action: 11, channel: channelName }); + + await enterFuture; + + // Queued presence message was sent after attach completed + expect(capturedPresence.length).to.equal(1); + expect(capturedPresence[0].presence[0].action).to.equal(2); // ENTER + + client.close(); + }); + + /** + * RTP16c - Presence message errors in other channel states + * + * In any other case (channel not ATTACHED, ATTACHING, or INITIALIZED + * with queueMessages) the operation should result in an error. + */ + it('RTP16c - presence message errors in other channel states', async function () { + const channelName = 'test-RTP16c-' + String(Math.random()).slice(2); + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // Respond with channel ERROR to put channel into FAILED state + conn!.send_to_client({ + action: 9, // ERROR (channel-level) + channel: channelName, + error: { code: 90001, statusCode: 500, message: 'Channel error' }, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + + // Put channel in FAILED state + try { + await channel.attach(); + } catch (_) { + // Expected + } + expect(channel.state).to.equal('failed'); + + try { + await channel.presence.enter(null); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + } + + client.close(); + }); + + /** + * RTP15c - enterClient has no side effects on normal enter + * + * Using enterClient, updateClient, and leaveClient methods should + * have no side effects on a client that has entered normally using enter. + * + * NOTE: The UTS spec uses clientId: "*" for the client, allowing both + * enter() and enterClient(). ably-js rejects "*" at construction time. + * We use a concrete clientId ("admin") to allow enter() for the main client, + * plus enterClient()/leaveClient() for other users. enterClient with + * the same clientId as the connection works in ably-js. + */ + it('RTP15c - enterClient has no side effects on normal enter', async function () { + const channelName = 'test-RTP15c-' + String(Math.random()).slice(2); + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ action: 11, channel: channelName }); + } else if (msg.action === 14) { + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Use a concrete clientId to allow enter() for the main client + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'admin', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + // Normal enter for the admin client + await channel.presence.enter('main-client'); + + // enterClient for a different user + await channel.presence.enterClient('other-user', 'other-data'); + + // leaveClient for the other user + await channel.presence.leaveClient('other-user', null); + + // Three presence messages sent: enter, enterClient, leaveClient + expect(capturedPresence.length).to.equal(3); + + // The main client's enter is unaffected by the enterClient/leaveClient calls + expect(capturedPresence[0].presence[0].action).to.equal(2); // ENTER + expect(capturedPresence[0].presence[0].data).to.equal('main-client'); + // RTP8c: clientId not present when using enter() (implicit from connection) + expect(capturedPresence[0].presence[0].clientId).to.be.undefined; + + expect(capturedPresence[1].presence[0].action).to.equal(2); // ENTER + expect(capturedPresence[1].presence[0].clientId).to.equal('other-user'); + + expect(capturedPresence[2].presence[0].action).to.equal(3); // LEAVE + expect(capturedPresence[2].presence[0].clientId).to.equal('other-user'); + + client.close(); + }); + + /** + * RTP4 - 50 members via enterClient (same connection) + * + * Ensure a test exists that enters members using enterClient on a single + * connection, checks for ENTER events to be emitted for each member, and + * once sync is complete, all members should be present in a get() request. + * + * Note: The spec says 250 but we use 50 as a practical test size. + */ + it('RTP4 - 50 members via enterClient (same connection)', async function () { + this.timeout(30000); + const channelName = 'test-RTP4-same-' + String(Math.random()).slice(2); + const memberCount = 50; + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + // ATTACH with HAS_PRESENCE flag + conn!.send_to_client({ + action: 11, + channel: channelName, + flags: 1, // HAS_PRESENCE + }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + + // Server echoes back the ENTER as a PRESENCE event + const presence = msg.presence; + for (let idx = 0; idx < presence.length; idx++) { + const p = presence[idx]; + conn!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { + action: 2, // ENTER + clientId: p.clientId, + connectionId: 'conn-1', + id: 'conn-1:' + msg.msgSerial + ':' + idx, + timestamp: Date.now(), + data: p.data, + }, + ], + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + // Use key auth without clientId (instead of wildcard "*") + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + // Track ENTER events received by subscriber + const receivedEnters: any[] = []; + channel.presence.subscribe('enter', (event: any) => { + receivedEnters.push(event); + }); + + // Enter 50 members + for (let i = 0; i < memberCount; i++) { + await channel.presence.enterClient('user-' + i, 'data-' + i); + } + + // Allow events to propagate + await flushAsync(); + + // Send a complete SYNC with all 50 members as PRESENT + const syncMembers: any[] = []; + for (let i = 0; i < memberCount; i++) { + syncMembers.push({ + action: 1, // PRESENT + clientId: 'user-' + i, + connectionId: 'conn-1', + id: 'conn-1:' + i + ':0', + timestamp: Date.now(), + data: 'data-' + i, + }); + } + + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: channelName, + channelSerial: 'seq1:', + presence: syncMembers, + }); + + // Allow sync to complete + await flushAsync(); + + // Get all members after sync + const members = await channel.presence.get(); + + // All 50 members entered + expect(capturedPresence.length).to.equal(memberCount); + + // All 50 ENTER events received by subscriber + expect(receivedEnters.length).to.equal(memberCount); + + // All 50 members present after sync + expect(members.length).to.equal(memberCount); + + // Verify each member exists with correct data + for (let i = 0; i < memberCount; i++) { + const member = members.find((m: any) => m.clientId === 'user-' + i); + expect(member, 'member user-' + i + ' should exist').to.not.be.undefined; + expect(member!.data).to.equal('data-' + i); + } + + client.close(); + }); + + /** + * RTP4 - 50 members via enterClient (different connections) + * + * One connection enters members, a different connection observes the + * ENTER events and verifies all members via get(). This is the more + * realistic scenario where one client populates presence and another + * client discovers the members. + * + * NOTE: ably-js MockWebSocket is a single mock per install. To simulate + * two separate connections, we run them sequentially: first client A enters + * all members, then we set up client B with its own mock to observe presence + * via SYNC delivery and verify via get(). + */ + it('RTP4 - 50 members via enterClient (different connections)', async function () { + this.timeout(30000); + const channelName = 'test-RTP4-diff-' + String(Math.random()).slice(2); + const memberCount = 50; + + // --- Phase 1: Client A enters 50 members --- + const capturedPresenceA: any[] = []; + const mockA = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mockA.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-A', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ + action: 11, + channel: channelName, + flags: 1, // HAS_PRESENCE + }); + } else if (msg.action === 14) { + capturedPresenceA.push(msg); + conn!.send_to_client({ action: 1, msgSerial: msg.msgSerial, count: 1 }); + } + }, + }); + installMockWebSocket(mockA.constructorFn); + + // Use key auth without clientId (instead of wildcard "*") + const clientA = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientA); + + clientA.connect(); + await new Promise((resolve) => clientA.connection.once('connected', resolve)); + + const channelA = clientA.channels.get(channelName, { attachOnSubscribe: false }); + await channelA.attach(); + + // Client A enters 50 members + for (let i = 0; i < memberCount; i++) { + await channelA.presence.enterClient('user-' + i, 'data-' + i); + } + + // Client A sent all 50 presence messages + expect(capturedPresenceA.length).to.equal(memberCount); + + clientA.close(); + + // --- Phase 2: Client B observes via SYNC --- + restoreAll(); + + const mockB = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mockB.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-B', + }); + }, + onMessageFromClient: (msg, conn) => { + if (msg.action === 10) { + conn!.send_to_client({ + action: 11, + channel: channelName, + flags: 1, // HAS_PRESENCE + }); + } + }, + }); + installMockWebSocket(mockB.constructorFn); + + const clientB = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(clientB); + + clientB.connect(); + await new Promise((resolve) => clientB.connection.once('connected', resolve)); + + const channelB = clientB.channels.get(channelName, { attachOnSubscribe: false }); + await channelB.attach(); + + // Subscribe on client B to observe remote presence events + const receivedEntersB: any[] = []; + channelB.presence.subscribe('enter', (event: any) => { + receivedEntersB.push(event); + }); + + // Server delivers ENTER events to client B + for (let i = 0; i < memberCount; i++) { + mockB.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { + action: 2, // ENTER + clientId: 'user-' + i, + connectionId: 'conn-A', + id: 'conn-A:' + i + ':0', + timestamp: Date.now(), + data: 'data-' + i, + }, + ], + }); + } + + // Allow events to propagate + await flushAsync(); + + // Server sends a SYNC to client B with all 50 members + const syncMembers: any[] = []; + for (let i = 0; i < memberCount; i++) { + syncMembers.push({ + action: 1, // PRESENT + clientId: 'user-' + i, + connectionId: 'conn-A', + id: 'conn-A:' + i + ':0', + timestamp: Date.now(), + data: 'data-' + i, + }); + } + + mockB.active_connection!.send_to_client({ + action: 16, // SYNC + channel: channelName, + channelSerial: 'seq1:', + presence: syncMembers, + }); + + // Allow sync to complete + await flushAsync(); + + // Client B gets all members + const members = await channelB.presence.get(); + + // Client B received all 50 ENTER events + expect(receivedEntersB.length).to.equal(memberCount); + + // All 50 members present via get() on client B + expect(members.length).to.equal(memberCount); + + // Verify each member has correct data and connectionId from conn-A + for (let i = 0; i < memberCount; i++) { + const member = members.find((m: any) => m.clientId === 'user-' + i); + expect(member, 'member user-' + i + ' should exist').to.not.be.undefined; + expect(member!.data).to.equal('data-' + i); + expect(member!.connectionId).to.equal('conn-A'); + } + + clientB.close(); + }); +}); diff --git a/test/uts/realtime/presence/realtime_presence_get.test.ts b/test/uts/realtime/presence/realtime_presence_get.test.ts new file mode 100644 index 000000000..029a37723 --- /dev/null +++ b/test/uts/realtime/presence/realtime_presence_get.test.ts @@ -0,0 +1,568 @@ +/** + * UTS: RealtimePresence Get Tests + * + * Spec points: RTP11, RTP11a, RTP11b, RTP11c, RTP11c1, RTP11c2, RTP11c3, RTP11d + * Source: specification/uts/realtime/unit/presence/realtime_presence_get.md + * + * Tests the RealtimePresence#get function which returns the list of current + * members on the channel from the local PresenceMap. By default it waits for + * the SYNC to complete. Supports filtering by clientId and connectionId, and + * has specific error behaviour for SUSPENDED channels. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTP11a - get returns current members (single-message sync) + * + * Returns the list of current members on the channel. By default, will wait + * for the SYNC to be completed. A single-message sync has ATTACHED with + * HAS_PRESENCE, followed by a SYNC with empty cursor. + */ + it('RTP11a - get returns current members after single-message sync', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH - send ATTACHED with HAS_PRESENCE but do NOT send SYNC yet + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + } + }, + }); + 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-RTP11a-single'); + await channel.attach(); + + // Start get() -- sync has not arrived yet, so this must wait + let getResolved = false; + const getFuture = channel.presence.get().then((result) => { + getResolved = true; + return result; + }); + + // Give a tick to confirm get has not resolved yet + await flushAsync(); + expect(getResolved).to.be.false; + + // Now send a single-message SYNC (empty cursor = complete) + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: 'test-RTP11a-single', + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100, data: 'a' }, + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100, data: 'b' }, + ], + }); + + const members = await getFuture; + + expect(members.length).to.equal(2); + const clientIds = members.map((m: any) => m.clientId).sort(); + expect(clientIds).to.deep.equal(['alice', 'bob']); + }); + + /** + * RTP11a, RTP11c1 - get waits for multi-message sync + * + * When waitForSync is true (default), the method will wait until SYNC is + * complete before returning. A multi-message sync has a non-empty cursor in + * the first message and an empty cursor in the final message. + */ + it('RTP11a, RTP11c1 - get waits for multi-message sync', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH - send ATTACHED with HAS_PRESENCE but no SYNC yet + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + } + }, + }); + 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-RTP11c1-multi'); + await channel.attach(); + + // Start get() -- sync has not arrived yet + let getResolved = false; + const getFuture = channel.presence.get().then((result) => { + getResolved = true; + return result; + }); + + // Verify not resolved yet + await flushAsync(); + expect(getResolved).to.be.false; + + // Send first SYNC message (non-empty cursor = more to come) + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: 'test-RTP11c1-multi', + channelSerial: 'seq1:cursor1', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + + // get() should still be waiting -- sync not complete + await flushAsync(); + expect(getResolved).to.be.false; + + // Send final SYNC message (empty cursor = sync complete) + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: 'test-RTP11c1-multi', + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 }, + ], + }); + + const members = await getFuture; + + // Both alice (from first SYNC message) and bob (from second) are present + expect(members.length).to.equal(2); + const clientIds = members.map((m: any) => m.clientId).sort(); + expect(clientIds).to.deep.equal(['alice', 'bob']); + }); + + /** + * RTP11c1 - get with waitForSync=false returns immediately + * + * When waitForSync is false, the known set of presence members is returned + * immediately, which may be incomplete if the SYNC is not finished. + */ + it('RTP11c1 - get with waitForSync=false returns immediately', async function () { + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + // Start SYNC but don't complete it (cursor is non-empty) + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:cursor1', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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-RTP11c1-nowait'); + await channel.attach(); + + // Allow sync messages to be processed + await flushAsync(); + + // Sync is in progress but we don't wait + const members = await channel.presence.get({ waitForSync: false }); + + // Returns what's available so far (may be incomplete) + expect(members.length).to.equal(1); + expect(members[0].clientId).to.equal('alice'); + }); + + /** + * RTP11c2 - get filtered by clientId + * + * clientId param filters members by the provided clientId. + */ + it('RTP11c2 - get filtered by clientId', 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: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 }, + { action: 1, clientId: 'alice', connectionId: 'c3', id: 'c3:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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-RTP11c2'); + await channel.attach(); + + const members = await channel.presence.get({ clientId: 'alice' }); + + // Only alice entries returned (from two different connections) + expect(members.length).to.equal(2); + expect(members.every((m: any) => m.clientId === 'alice')).to.be.true; + }); + + /** + * RTP11c3 - get filtered by connectionId + * + * connectionId param filters members by the provided connectionId. + */ + it('RTP11c3 - get filtered by connectionId', 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: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + { action: 1, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 100 }, + { action: 1, clientId: 'carol', connectionId: 'c1', id: 'c1:0:1', timestamp: 100 }, + ], + }); + } + }, + }); + 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-RTP11c3'); + await channel.attach(); + + const members = await channel.presence.get({ connectionId: 'c1' }); + + // Only members from connection c1 (alice and carol) + expect(members.length).to.equal(2); + expect(members.every((m: any) => m.connectionId === 'c1')).to.be.true; + }); + + /** + * RTP11b - get implicitly attaches channel + * + * Implicitly attaches the RealtimeChannel if the channel is in the + * INITIALIZED state. + */ + it('RTP11b - get implicitly attaches channel', 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, + }); + } + }, + }); + 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-RTP11b'); + expect(channel.state).to.equal('initialized'); + + const members = await channel.presence.get({ waitForSync: false }); + + expect(channel.state).to.equal('attached'); + expect(members).to.not.be.null; + }); + + /** + * RTP11d - get on SUSPENDED channel errors by default + * + * If the RealtimeChannel is SUSPENDED, get will by default (or if + * waitForSync is true) result in an error with code 91005. + */ + it('RTP11d - get on SUSPENDED channel errors by default', async function () { + let connectCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + if (connectCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected(); + } else { + conn.respond_with_refused(); + } + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + disconnectedRetryTimeout: 500, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTP11d'); + await channel.attach(); + + // Simulate channel becoming SUSPENDED + mock.active_connection!.simulate_disconnect(); + + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(121000); + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('suspended'); + + // Default get (waitForSync=true) should error + try { + await channel.presence.get(); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.not.be.null; + expect(err.code).to.equal(91005); + } + }); + + /** + * RTP11d - get on SUSPENDED channel with waitForSync=false returns members + * + * If waitForSync is false on a SUSPENDED channel, return the members + * currently in the PresenceMap. + */ + it('RTP11d - get on SUSPENDED channel with waitForSync=false returns members', async function () { + let connectCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectCount++; + if (connectCount === 1) { + mock.active_connection = conn; + conn.respond_with_connected(); + } else { + conn.respond_with_refused(); + } + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: msg.channel, + flags: 1, // HAS_PRESENCE + }); + mock.active_connection!.send_to_client({ + action: 16, // SYNC + channel: msg.channel, + channelSerial: 'seq1:', + presence: [ + { action: 1, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 100 }, + ], + }); + } + }, + }); + 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', + autoConnect: false, + useBinaryProtocol: false, + fallbackHosts: [], + disconnectedRetryTimeout: 500, + }); + trackClient(client); + + client.connect(); + for (let i = 0; i < 20; i++) { + clock.tick(0); + await flushAsync(); + } + expect(client.connection.state).to.equal('connected'); + + const channel = client.channels.get('test-RTP11d-nowait'); + await channel.attach(); + + // Simulate channel becoming SUSPENDED + mock.active_connection!.simulate_disconnect(); + + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + await clock.tickAsync(121000); + for (let i = 0; i < 30; i++) { + clock.tick(0); + await flushAsync(); + } + + expect(channel.state).to.equal('suspended'); + + // waitForSync=false returns what's in the PresenceMap + const members = await channel.presence.get({ waitForSync: false }); + + expect(members.length).to.equal(1); + expect(members[0].clientId).to.equal('alice'); + }); +}); diff --git a/test/uts/realtime/presence/realtime_presence_history.test.ts b/test/uts/realtime/presence/realtime_presence_history.test.ts new file mode 100644 index 000000000..6a3b6ee5f --- /dev/null +++ b/test/uts/realtime/presence/realtime_presence_history.test.ts @@ -0,0 +1,152 @@ +/** + * UTS: RealtimePresence History Tests + * + * Spec points: RTP12, RTP12a, RTP12c, RTP12d + * Source: specification/uts/realtime/unit/presence/realtime_presence_history.md + * + * Tests the RealtimePresence#history function which delegates to + * RestPresence#history. It supports the same parameters as RestPresence#history + * and returns a PaginatedResult. + */ + +import { expect } from 'chai'; +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 () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTP12a - history supports same params as RestPresence#history + * + * Supports all the same params: start, end, direction, limit. + * Verifies the correct REST endpoint is called with the right params. + */ + it('RTP12a - history supports same params as RestPresence#history', 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, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [], { 'content-type': 'application/json' }); + }, + }); + installMockHttp(httpMock); + + 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-RTP12a'); + await channel.attach(); + + await channel.presence.history({ + start: 1000, + end: 2000, + direction: 'backwards', + limit: 50, + }); + + // Find the history request + const historyReq = httpMock.captured_requests.find( + (r: any) => r.path.includes('/presence/history'), + ); + expect(historyReq).to.not.be.undefined; + + // Verify path + expect(historyReq!.path).to.equal('/channels/test-RTP12a/presence/history'); + + // Verify params + const params = historyReq!.url.searchParams; + expect(params.get('start')).to.equal('1000'); + expect(params.get('end')).to.equal('2000'); + expect(params.get('direction')).to.equal('backwards'); + expect(params.get('limit')).to.equal('50'); + }); + + /** + * RTP12c - history returns PaginatedResult + * + * Returns a PaginatedResult page containing the first page of messages + * in the PaginatedResult#items attribute. + */ + it('RTP12c - history returns PaginatedResult with presence messages', 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, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const httpMock = new MockHttpClient({ + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + req.respond_with(200, [ + { action: 2, clientId: 'alice', timestamp: 1000 }, // enter + { action: 4, clientId: 'alice', timestamp: 2000 }, // update + { action: 3, clientId: 'alice', timestamp: 3000 }, // leave + ], { 'content-type': 'application/json' }); + }, + }); + installMockHttp(httpMock); + + 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-RTP12c'); + await channel.attach(); + + const result = await channel.presence.history({}); + + // Result is a PaginatedResult + expect(result).to.have.property('items'); + expect(result).to.have.property('hasNext'); + expect(result).to.have.property('isLast'); + + expect(result.items.length).to.equal(3); + expect(result.items[0].clientId).to.equal('alice'); + expect(result.items[0].action).to.equal('enter'); + expect(result.items[2].action).to.equal('leave'); + }); +}); diff --git a/test/uts/realtime/presence/realtime_presence_reentry.test.ts b/test/uts/realtime/presence/realtime_presence_reentry.test.ts new file mode 100644 index 000000000..89479f7d0 --- /dev/null +++ b/test/uts/realtime/presence/realtime_presence_reentry.test.ts @@ -0,0 +1,702 @@ +/** + * UTS: RealtimePresence Automatic Re-entry Tests + * + * Spec points: RTP17a, RTP17e, RTP17g, RTP17g1, RTP17i + * Source: specification/uts/realtime/unit/presence/realtime_presence_reentry.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; + +describe('uts/realtime/presence/realtime_presence_reentry', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTP17i - Automatic re-entry on ATTACHED (non-RESUMED) + * + * The RealtimePresence object should perform automatic re-entry + * whenever the channel receives an ATTACHED ProtocolMessage, except + * when already attached with RESUMED flag set. + */ + it('RTP17i - automatic re-entry on ATTACHED (non-RESUMED)', async function () { + const channelName = `test-RTP17i-${Date.now()}`; + let connectionCount = 0; + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-${connectionCount}`, + connectionDetails: { + connectionKey: `key-${connectionCount}`, + connectionStateTtl: 120000, + maxIdleInterval: 15000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + // ACK the presence message + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + // Server echoes the presence event back to populate LocalPresenceMap + if (msg.presence) { + for (let idx = 0; idx < msg.presence.length; idx++) { + const p = msg.presence[idx]; + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + connectionId: `conn-${connectionCount}`, + presence: [ + { + action: p.action === 'enter' ? 2 : p.action, + clientId: p.clientId || 'my-client', + connectionId: `conn-${connectionCount}`, + id: `conn-${connectionCount}:${msg.msgSerial}:${idx}`, + timestamp: Date.now(), + data: p.data, + }, + ], + }); + } + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + // Enter presence + await channel.presence.enter('hello'); + + // Wait for the echo to be processed + await flushAsync(); + + expect(capturedPresence.length).to.equal(1); + + // Simulate disconnect and reconnect (new connectionId) + const prevCapturedLength = capturedPresence.length; + mock.active_connection!.simulate_disconnect(); + + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + + // Reconnect -- triggers reattach with new ATTACHED (non-RESUMED) + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Wait for channel to reattach and re-entry to happen + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + // Wait for presence re-entry message to be sent + await flushAsync(); + + // RTP17i: Automatic re-entry sends ENTER for the member + // Note: on the wire, presence actions are numeric (2 = ENTER) + const reentryMessages = capturedPresence.slice(prevCapturedLength); + expect(reentryMessages.length).to.be.at.least(1); + + const reenter = reentryMessages.find( + (m: any) => m.presence && m.presence.some((p: any) => p.action === 2), + ); + expect(reenter).to.not.be.undefined; + + client.close(); + }); + + /** + * RTP17g - Re-entry publishes ENTER with stored clientId and data + * + * For each member of the RTP17 internal PresenceMap, publish a + * PresenceMessage with an ENTER action using the clientId, data, + * and id attributes from that member. + */ + it('RTP17g - re-entry preserves clientId and data', async function () { + const channelName = `test-RTP17g-${Date.now()}`; + let connectionCount = 0; + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-${connectionCount}`, + connectionDetails: { + connectionKey: `key-${connectionCount}`, + connectionStateTtl: 120000, + maxIdleInterval: 15000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + // ACK + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + // Server echoes presence back + if (msg.presence) { + for (let idx = 0; idx < msg.presence.length; idx++) { + const p = msg.presence[idx]; + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + connectionId: `conn-${connectionCount}`, + presence: [ + { + action: p.action === 'enter' ? 2 : p.action, + clientId: p.clientId, + connectionId: `conn-${connectionCount}`, + id: `conn-${connectionCount}:${msg.msgSerial}:${idx}`, + timestamp: Date.now(), + data: p.data, + }, + ], + }); + } + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'admin', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + // Enter multiple members via enterClient + await channel.presence.enterClient('alice', 'alice-data'); + await flushAsync(); + await channel.presence.enterClient('bob', 'bob-data'); + await flushAsync(); + + expect(capturedPresence.length).to.equal(2); + + // Simulate disconnect and reconnect + const capturedBefore = capturedPresence.length; + mock.active_connection!.simulate_disconnect(); + + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + // Wait for channel reattach and re-entry + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + await flushAsync(); + + // Both members re-entered with ENTER action and original data + const reentryMessages = capturedPresence.slice(capturedBefore); + const presenceItems: any[] = []; + for (const msg of reentryMessages) { + if (msg.presence) { + for (const p of msg.presence) { + presenceItems.push(p); + } + } + } + + expect(presenceItems.length).to.be.at.least(2); + + const aliceReentry = presenceItems.find((p: any) => p.clientId === 'alice'); + const bobReentry = presenceItems.find((p: any) => p.clientId === 'bob'); + + // Note: on the wire, presence actions are numeric (2 = ENTER) + expect(aliceReentry).to.not.be.undefined; + expect(aliceReentry.action).to.equal(2); // ENTER on wire + expect(aliceReentry.data).to.equal('alice-data'); + + expect(bobReentry).to.not.be.undefined; + expect(bobReentry.action).to.equal(2); // ENTER on wire + expect(bobReentry.data).to.equal('bob-data'); + + client.close(); + }); + + /** + * RTP17g1 - Re-entry omits id when connectionId changed + * + * If the current connection id is different from the connectionId + * attribute of the stored member, the published PresenceMessage must + * not have its id set. + */ + it('RTP17g1 - re-entry omits id when connectionId changed', async function () { + const channelName = `test-RTP17g1-${Date.now()}`; + let connectionCount = 0; + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-${connectionCount}`, + connectionDetails: { + connectionKey: `key-${connectionCount}`, + connectionStateTtl: 120000, + maxIdleInterval: 15000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + // ACK + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + // Server echoes presence back + if (msg.presence) { + for (let idx = 0; idx < msg.presence.length; idx++) { + const p = msg.presence[idx]; + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + connectionId: `conn-${connectionCount}`, + presence: [ + { + action: p.action === 'enter' ? 2 : p.action, + clientId: p.clientId || 'my-client', + connectionId: `conn-${connectionCount}`, + id: `conn-${connectionCount}:${msg.msgSerial}:${idx}`, + timestamp: Date.now(), + data: p.data, + }, + ], + }); + } + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + await channel.presence.enter('hello'); + await flushAsync(); + + // First connection is conn-1 + expect(connectionCount).to.equal(1); + + // Disconnect and reconnect -- new connectionId (conn-2) + const capturedBefore = capturedPresence.length; + mock.active_connection!.simulate_disconnect(); + + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + await new Promise((resolve) => client.connection.once('connected', resolve)); + expect(connectionCount).to.equal(2); + + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + await flushAsync(); + + // Re-entry message should NOT have id set because connectionId changed + // Note: on the wire, presence actions are numeric (2 = ENTER) + const reentryMessages = capturedPresence.slice(capturedBefore); + const reentry = reentryMessages.find( + (m: any) => m.presence && m.presence.some((p: any) => p.action === 2), + ); + expect(reentry).to.not.be.undefined; + + const reentryPresence = reentry.presence[0]; + expect(reentryPresence.action).to.equal(2); // ENTER on wire + expect(reentryPresence.id).to.be.undefined; // RTP17g1: id not set when connectionId changed + expect(reentryPresence.data).to.equal('hello'); + + client.close(); + }); + + /** + * RTP17i - No re-entry when ATTACHED with RESUMED flag + * + * Automatic re-entry is NOT performed when the channel is already + * attached and the ProtocolMessage has the RESUMED bit flag set. + */ + it('RTP17i - no re-entry when ATTACHED with RESUMED flag', async function () { + const channelName = `test-RTP17i-resumed-${Date.now()}`; + const capturedPresence: any[] = []; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { + connectionKey: 'key-1', + connectionStateTtl: 120000, + maxIdleInterval: 15000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + }); + } else if (msg.action === 14) { + // PRESENCE + capturedPresence.push(msg); + // ACK + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + // Server echoes presence back + if (msg.presence) { + for (let idx = 0; idx < msg.presence.length; idx++) { + const p = msg.presence[idx]; + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + connectionId: 'conn-1', + presence: [ + { + action: p.action === 'enter' ? 2 : p.action, + clientId: p.clientId || 'my-client', + connectionId: 'conn-1', + id: `conn-1:${msg.msgSerial}:${idx}`, + timestamp: Date.now(), + data: p.data, + }, + ], + }); + } + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + await channel.presence.enter('hello'); + await flushAsync(); + + // Clear captured + capturedPresence.length = 0; + + // Server sends ATTACHED with RESUMED flag while already attached + // (e.g., after a brief transport-level reconnect that preserved the connection) + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + flags: 4, // RESUMED + }); + + await flushAsync(); + + // No re-entry -- RESUMED flag means the server still has our presence state + expect(capturedPresence.length).to.equal(0); + + client.close(); + }); + + /** + * RTP17e - Failed re-entry emits UPDATE with error + * + * If an automatic presence ENTER fails (e.g., NACK), emit an UPDATE + * event on the channel with resumed=true and reason set to ErrorInfo + * with code 91004. + */ + 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()}`; + let connectionCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + connectionCount++; + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: `conn-${connectionCount}`, + connectionDetails: { + connectionKey: `key-${connectionCount}`, + connectionStateTtl: 120000, + maxIdleInterval: 15000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + }); + } else if (msg.action === 14) { + // PRESENCE + if (connectionCount === 1) { + // First connection: ACK the enter and echo back the presence event + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + if (msg.presence) { + for (let idx = 0; idx < msg.presence.length; idx++) { + const p = msg.presence[idx]; + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + connectionId: 'conn-1', + presence: [ + { + action: p.action === 'enter' ? 2 : p.action, + clientId: p.clientId || 'my-client', + connectionId: 'conn-1', + id: `conn-1:${msg.msgSerial}:${idx}`, + timestamp: Date.now(), + data: p.data, + }, + ], + }); + } + } + } else { + // Second connection: NACK the re-entry + mock.active_connection!.send_to_client({ + action: 2, // NACK + msgSerial: msg.msgSerial, + count: 1, + error: { + code: 40160, + statusCode: 401, + message: 'Presence denied', + }, + }); + } + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + await channel.presence.enter('hello'); + await flushAsync(); + + // Listen for channel UPDATE events with the re-entry failure error code + const channelEvents: any[] = []; + channel.on('update', (change: any) => { + if (change.reason && change.reason.code === 91004) { + channelEvents.push(change); + } + }); + + // Disconnect and reconnect -- re-entry will be NACKed + mock.active_connection!.simulate_disconnect(); + + await new Promise((resolve) => client.connection.once('disconnected', resolve)); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + // Wait for the re-entry NACK to be processed + for (let i = 0; i < 10 && channelEvents.length < 1; i++) { + await flushAsync(); + } + + expect(channelEvents.length).to.be.at.least(1); + + const updateEvent = channelEvents[0]; + expect(updateEvent.resumed).to.equal(true); + expect(updateEvent.reason).to.not.be.null; + expect(updateEvent.reason.code).to.equal(91004); + expect(updateEvent.reason.message).to.include('my-client'); + expect(updateEvent.reason.cause).to.not.be.null; + expect(updateEvent.reason.cause.code).to.equal(40160); + + client.close(); + }); + + /** + * RTP17a - Server publishes member regardless of subscribe capability + * + * All members belonging to the current connection are published as a + * PresenceMessage on the channel by the server irrespective of whether + * the client has permission to subscribe. The member should be present + * in the public presence set via get. + */ + it('RTP17a - server publishes member regardless of subscribe capability', async function () { + const channelName = `test-RTP17a-${Date.now()}`; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected({ + connectionId: 'conn-1', + connectionDetails: { + connectionKey: 'key-1', + connectionStateTtl: 120000, + maxIdleInterval: 15000, + } as any, + }); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH -- channel with presence capability (flag bit 16) + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + flags: 1 << 16, // PRESENCE flag (not PRESENCE_SUBSCRIBE) + }); + } else if (msg.action === 14) { + // PRESENCE -- ACK the enter + mock.active_connection!.send_to_client({ + action: 1, // ACK + msgSerial: msg.msgSerial, + count: 1, + }); + // Server delivers the presence event back to the client + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { + action: 2, // ENTER + clientId: 'my-client', + connectionId: 'conn-1', + id: 'conn-1:0:0', + timestamp: 1000, + }, + ], + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + clientId: 'my-client', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + await channel.attach(); + + await channel.presence.enter(undefined); + await flushAsync(); + + // Check public presence map + const members = await channel.presence.get({ waitForSync: false }); + + expect(members.length).to.equal(1); + expect(members[0].clientId).to.equal('my-client'); + + client.close(); + }); +}); diff --git a/test/uts/realtime/presence/realtime_presence_subscribe.test.ts b/test/uts/realtime/presence/realtime_presence_subscribe.test.ts new file mode 100644 index 000000000..2bd03f87e --- /dev/null +++ b/test/uts/realtime/presence/realtime_presence_subscribe.test.ts @@ -0,0 +1,679 @@ +/** + * UTS: RealtimePresence Subscribe/Unsubscribe Tests + * + * Spec points: RTP6, RTP6a, RTP6b, RTP6d, RTP6e, RTP7, RTP7a, RTP7b, RTP7c + * Source: specification/uts/realtime/unit/presence/realtime_presence_subscribe.md + */ + +import { expect } from 'chai'; +import { MockWebSocket } from '../../mock_websocket'; +import { Ably, trackClient, installMockWebSocket, restoreAll, flushAsync } from '../../helpers'; + +describe('uts/realtime/presence/realtime_presence_subscribe', function () { + afterEach(function () { + restoreAll(); + }); + + /** + * RTP6a - Subscribe to all presence events + * + * Subscribe with a single listener argument subscribes a listener to + * all presence messages. + */ + it('RTP6a - subscribe to all presence events', async function () { + const channelName = `test-RTP6a-${Date.now()}`; + + 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: channelName, + flags: 1, // HAS_PRESENCE + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + const receivedEvents: any[] = []; + + channel.presence.subscribe((event: any) => { + receivedEvents.push(event); + }); + + // Wait for implicit attach + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + // Server delivers ENTER, UPDATE, and LEAVE events + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + ], + }); + + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 4, clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 2000, data: 'updated' }, + ], + }); + + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 3, clientId: 'alice', connectionId: 'c1', id: 'c1:2:0', timestamp: 3000 }, + ], + }); + + await flushAsync(); + + expect(receivedEvents.length).to.equal(3); + expect(receivedEvents[0].action).to.equal('enter'); + expect(receivedEvents[0].clientId).to.equal('alice'); + expect(receivedEvents[1].action).to.equal('update'); + expect(receivedEvents[1].data).to.equal('updated'); + expect(receivedEvents[2].action).to.equal('leave'); + + client.close(); + }); + + /** + * RTP6b - Subscribe filtered by action + * + * Subscribe with an action argument and a listener subscribes the + * listener to receive only presence messages with that action. + */ + it('RTP6b - subscribe filtered by single action', async function () { + const channelName = `test-RTP6b-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + const enterEvents: any[] = []; + const leaveEvents: any[] = []; + + channel.presence.subscribe('enter', (event: any) => { + enterEvents.push(event); + }); + + channel.presence.subscribe('leave', (event: any) => { + leaveEvents.push(event); + }); + + // Server delivers all three action types + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + { action: 4, clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 2000 }, + { action: 3, clientId: 'alice', connectionId: 'c1', id: 'c1:2:0', timestamp: 3000 }, + ], + }); + + await flushAsync(); + + // ENTER listener only gets ENTER events + expect(enterEvents.length).to.equal(1); + expect(enterEvents[0].action).to.equal('enter'); + + // LEAVE listener only gets LEAVE events + expect(leaveEvents.length).to.equal(1); + expect(leaveEvents[0].action).to.equal('leave'); + + client.close(); + }); + + /** + * RTP6b - Subscribe filtered by multiple actions + * + * The action argument may also be an array of actions. + */ + it('RTP6b - subscribe filtered by multiple actions', async function () { + const channelName = `test-RTP6b-multi-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + const enterLeaveEvents: any[] = []; + channel.presence.subscribe(['enter', 'leave'], (event: any) => { + enterLeaveEvents.push(event); + }); + + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + { action: 4, clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 2000 }, + { action: 3, clientId: 'alice', connectionId: 'c1', id: 'c1:2:0', timestamp: 3000 }, + ], + }); + + await flushAsync(); + + // Only ENTER and LEAVE events received -- UPDATE filtered out + expect(enterLeaveEvents.length).to.equal(2); + expect(enterLeaveEvents[0].action).to.equal('enter'); + expect(enterLeaveEvents[1].action).to.equal('leave'); + + client.close(); + }); + + /** + * RTP6d - Subscribe implicitly attaches channel + * + * If the attachOnSubscribe channel option is true (default), + * implicitly attach the RealtimeChannel. + */ + it('RTP6d - subscribe implicitly attaches channel', async function () { + const channelName = `test-RTP6d-${Date.now()}`; + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + mock.active_connection!.send_to_client({ + action: 11, // ATTACHED + channel: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName); + expect(channel.state).to.equal('initialized'); + + // Subscribe without explicitly attaching -- should trigger implicit attach + channel.presence.subscribe((event: any) => {}); + + await new Promise((resolve) => { + if (channel.state === 'attached') return resolve(); + channel.once('attached', () => resolve()); + }); + + expect(attachCount).to.equal(1); + expect(channel.state).to.equal('attached'); + + client.close(); + }); + + /** + * RTP6e - Subscribe with attachOnSubscribe=false does not attach + * + * If the attachOnSubscribe channel option is false, do not + * implicitly attach. + */ + it('RTP6e - subscribe with attachOnSubscribe=false does not attach', async function () { + const channelName = `test-RTP6e-${Date.now()}`; + let attachCount = 0; + + const mock = new MockWebSocket({ + onConnectionAttempt: (conn) => { + mock.active_connection = conn; + conn.respond_with_connected(); + }, + onMessageFromClient: (msg) => { + if (msg.action === 10) { + // ATTACH + attachCount++; + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + expect(channel.state).to.equal('initialized'); + + channel.presence.subscribe((event: any) => {}); + + await flushAsync(); + + // Channel stays in INITIALIZED -- no implicit attach + expect(channel.state).to.equal('initialized'); + expect(attachCount).to.equal(0); + + client.close(); + }); + + /** + * RTP7c - Unsubscribe all listeners + * + * Unsubscribe with no arguments unsubscribes all listeners. + */ + it('RTP7c - unsubscribe all listeners', async function () { + const channelName = `test-RTP7c-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + const eventsA: any[] = []; + const eventsB: any[] = []; + + channel.presence.subscribe((event: any) => { eventsA.push(event); }); + channel.presence.subscribe((event: any) => { eventsB.push(event); }); + + // Deliver first event -- both listeners receive it + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + ], + }); + + await flushAsync(); + + expect(eventsA.length).to.equal(1); + expect(eventsB.length).to.equal(1); + + // Unsubscribe all + channel.presence.unsubscribe(); + + // Deliver second event -- no listeners receive it + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 2000 }, + ], + }); + + await flushAsync(); + + expect(eventsA.length).to.equal(1); // No new events after unsubscribe + expect(eventsB.length).to.equal(1); + + client.close(); + }); + + /** + * RTP7a - Unsubscribe specific listener + * + * Unsubscribe with a single listener argument unsubscribes that + * specific listener. + */ + it('RTP7a - unsubscribe specific listener', async function () { + const channelName = `test-RTP7a-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + const eventsA: any[] = []; + const eventsB: any[] = []; + + const listenerA = (event: any) => { eventsA.push(event); }; + const listenerB = (event: any) => { eventsB.push(event); }; + + channel.presence.subscribe(listenerA); + channel.presence.subscribe(listenerB); + + // Unsubscribe only listenerA + channel.presence.unsubscribe(listenerA); + + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + ], + }); + + await flushAsync(); + + expect(eventsA.length).to.equal(0); // Unsubscribed -- no events + expect(eventsB.length).to.equal(1); // Still subscribed -- receives event + + client.close(); + }); + + /** + * RTP7b - Unsubscribe listener for specific action + * + * Unsubscribe with an action argument and a listener unsubscribes + * the listener for that action only. + */ + it('RTP7b - unsubscribe listener for specific action', async function () { + const channelName = `test-RTP7b-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + const listener = (event: any) => { received.push(event); }; + + // Subscribe to both ENTER and LEAVE + channel.presence.subscribe('enter', listener); + channel.presence.subscribe('leave', listener); + + // Unsubscribe only for ENTER + channel.presence.unsubscribe('enter', listener); + + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + { action: 3, clientId: 'alice', connectionId: 'c1', id: 'c1:1:0', timestamp: 2000 }, + ], + }); + + await flushAsync(); + + // Only LEAVE received -- ENTER subscription was removed + expect(received.length).to.equal(1); + expect(received[0].action).to.equal('leave'); + + client.close(); + }); + + /** + * RTP6 - Presence events update the PresenceMap + * + * Incoming presence messages are applied to the PresenceMap (RTP2) + * before being emitted to subscribers. + */ + it('RTP6 - presence events update the PresenceMap', async function () { + const channelName = `test-RTP6-map-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + channel.presence.subscribe((event: any) => {}); + + // Server delivers ENTER + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000, data: 'hello' }, + ], + }); + + await flushAsync(); + + const members = await channel.presence.get({ waitForSync: false }); + + expect(members.length).to.equal(1); + expect(members[0].clientId).to.equal('alice'); + expect(members[0].data).to.equal('hello'); + expect(members[0].action).to.equal('present'); // Stored as PRESENT per RTP2d2 + + client.close(); + }); + + /** + * RTP6 - Multiple presence messages in single ProtocolMessage + * + * A PRESENCE ProtocolMessage may contain multiple PresenceMessages. + */ + it('RTP6 - multiple presence messages in single ProtocolMessage', async function () { + const channelName = `test-RTP6-batch-${Date.now()}`; + + 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: channelName, + }); + } + }, + }); + installMockWebSocket(mock.constructorFn); + + const client = new Ably.Realtime({ + key: 'fake.key:secret', + autoConnect: false, + useBinaryProtocol: false, + }); + trackClient(client); + + client.connect(); + await new Promise((resolve) => client.connection.once('connected', resolve)); + + const channel = client.channels.get(channelName, { attachOnSubscribe: false }); + await channel.attach(); + + const received: any[] = []; + channel.presence.subscribe((event: any) => { received.push(event); }); + + // Server delivers multiple presence events in one ProtocolMessage + mock.active_connection!.send_to_client({ + action: 14, // PRESENCE + channel: channelName, + presence: [ + { action: 2, clientId: 'alice', connectionId: 'c1', id: 'c1:0:0', timestamp: 1000 }, + { action: 2, clientId: 'bob', connectionId: 'c2', id: 'c2:0:0', timestamp: 1000 }, + { action: 2, clientId: 'carol', connectionId: 'c3', id: 'c3:0:0', timestamp: 1000 }, + ], + }); + + await flushAsync(); + + expect(received.length).to.equal(3); + expect(received[0].clientId).to.equal('alice'); + expect(received[1].clientId).to.equal('bob'); + expect(received[2].clientId).to.equal('carol'); + + client.close(); + }); +}); diff --git a/test/uts/realtime/time.test.ts b/test/uts/realtime/time.test.ts index 5f9f6c679..d882cae64 100644 --- a/test/uts/realtime/time.test.ts +++ b/test/uts/realtime/time.test.ts @@ -11,7 +11,7 @@ import { expect } from 'chai'; import { MockHttpClient } from '../mock_http'; -import { Ably, installMockHttp, restoreAll } from '../helpers'; +import { Ably, trackClient, installMockHttp, restoreAll } from '../helpers'; describe('uts/realtime/time', function () { let mock; @@ -37,6 +37,7 @@ describe('uts/realtime/time', function () { installMockHttp(mock); const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + trackClient(client); const result = await client.time(); expect(result).to.be.a('number'); @@ -45,6 +46,7 @@ describe('uts/realtime/time', function () { expect(captured).to.have.length(1); expect(captured[0].method.toUpperCase()).to.equal('GET'); expect(captured[0].path).to.equal('/time'); + client.close(); }); /** @@ -63,6 +65,7 @@ describe('uts/realtime/time', function () { installMockHttp(mock); const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + trackClient(client); await client.time(); expect(captured).to.have.length(1); @@ -74,6 +77,7 @@ describe('uts/realtime/time', function () { expect(request.headers).to.have.property('Ably-Agent'); expect(request.headers['X-Ably-Version']).to.match(/[0-9.]+/); expect(request.headers['Ably-Agent']).to.match(/ably-js\/[0-9]+\.[0-9]+\.[0-9]+/); + client.close(); }); /** @@ -92,12 +96,14 @@ describe('uts/realtime/time', function () { installMockHttp(mock); const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + trackClient(client); const result = await client.time(); expect(result).to.be.a('number'); expect(captured).to.have.length(1); expect(captured[0].headers).to.not.have.property('Authorization'); expect(captured[0].headers).to.not.have.property('authorization'); + client.close(); }); /** @@ -121,6 +127,7 @@ describe('uts/realtime/time', function () { useTokenAuth: true, autoConnect: false, }); + trackClient(client); const result = await client.time(); expect(result).to.be.a('number'); @@ -128,6 +135,7 @@ describe('uts/realtime/time', function () { expect(captured[0].url.protocol).to.equal('http:'); expect(captured[0].headers).to.not.have.property('Authorization'); expect(captured[0].headers).to.not.have.property('authorization'); + client.close(); }); /** @@ -149,6 +157,7 @@ describe('uts/realtime/time', function () { installMockHttp(mock); const client = new Ably.Realtime({ key: 'app.key:secret', autoConnect: false }); + trackClient(client); try { await client.time(); @@ -157,5 +166,6 @@ describe('uts/realtime/time', function () { expect(error.statusCode).to.equal(500); expect(error.code).to.equal(50000); } + client.close(); }); }); diff --git a/test/uts/rest/auth/revoke_tokens.test.ts b/test/uts/rest/auth/revoke_tokens.test.ts index 53c11cd52..6a5b94217 100644 --- a/test/uts/rest/auth/revoke_tokens.test.ts +++ b/test/uts/rest/auth/revoke_tokens.test.ts @@ -89,21 +89,13 @@ describe('uts/rest/auth/revoke_tokens', function () { /** * RSA17c / BAR2 - All success result - * - * DEVIATION: UTS spec expects the mock to return a plain array and the - * client to compute successCount/failureCount. ably-js passes through - * the server response as-is (which includes successCount/failureCount/results). - * Mock format matches the actual Ably REST API response format. */ it('RSA17c - all success result', async function () { - const responseBody = { - successCount: 2, - failureCount: 0, - results: [ - { target: 'clientId:alice', issuedBefore: 1700000000000, appliesAt: 1700000001000 }, - { target: 'clientId:bob', issuedBefore: 1700000000000, appliesAt: 1700000002000 }, - ], - }; + 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 }, + ]; installMockHttp(revokeMock(null, responseBody)); const client = new Ably.Rest({ key: 'appId.keyName:keySecret', useBinaryProtocol: false }); diff --git a/test/uts/rest/types/message_types.test.ts b/test/uts/rest/types/message_types.test.ts index ace625bc9..c9885dbaf 100644 --- a/test/uts/rest/types/message_types.test.ts +++ b/test/uts/rest/types/message_types.test.ts @@ -191,18 +191,11 @@ describe('uts/rest/types/message_types', function () { * with the expected name and data keys. */ 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' }); - if (typeof (msg as any).toJSON === 'function') { - const json = (msg as any).toJSON(); - expect(json).to.have.property('name', 'event'); - expect(json).to.have.property('data', 'payload'); - } else { - // DEVIATION: ably-js Message may not expose toJSON directly. - // Verify JSON.stringify produces expected output instead. - const json = JSON.parse(JSON.stringify(msg)); - expect(json).to.have.property('name', 'event'); - expect(json).to.have.property('data', 'payload'); - } + const json = (msg as any).toJSON(); + expect(json).to.have.property('name', 'event'); + expect(json).to.have.property('data', 'payload'); }); });