From 7bcbd92586177d4381e74d747afa0821af3923fa Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:40 +0100 Subject: [PATCH 01/32] Add UTS specs for Realtime connection lifecycle Add test specs covering connection failures (RTN14/RTN15), open failures, error reason handling, fallback hosts (RSC15), heartbeats, update events, whenState helper, and a connection lifecycle integration test. --- .../integration/connection_lifecycle_test.md | 228 ++++ .../connection/connection_failures_test.md | 1047 +++++++++++++++++ .../connection_open_failures_test.md | 568 +++++++++ .../unit/connection/error_reason_test.md | 448 +++++++ .../unit/connection/fallback_hosts_test.md | 664 +++++++++++ .../unit/connection/heartbeat_test.md | 417 +++++++ .../unit/connection/update_events_test.md | 384 ++++++ .../unit/connection/when_state_test.md | 480 ++++++++ 8 files changed, 4236 insertions(+) create mode 100644 uts/realtime/integration/connection_lifecycle_test.md create mode 100644 uts/realtime/unit/connection/connection_failures_test.md create mode 100644 uts/realtime/unit/connection/connection_open_failures_test.md create mode 100644 uts/realtime/unit/connection/error_reason_test.md create mode 100644 uts/realtime/unit/connection/fallback_hosts_test.md create mode 100644 uts/realtime/unit/connection/heartbeat_test.md create mode 100644 uts/realtime/unit/connection/update_events_test.md create mode 100644 uts/realtime/unit/connection/when_state_test.md diff --git a/uts/realtime/integration/connection_lifecycle_test.md b/uts/realtime/integration/connection_lifecycle_test.md new file mode 100644 index 000000000..ed34b6ad2 --- /dev/null +++ b/uts/realtime/integration/connection_lifecycle_test.md @@ -0,0 +1,228 @@ +# Realtime Connection Lifecycle Integration Tests + +Spec points: `RTN4b`, `RTN4c`, `RTN11`, `RTN12`, `RTN21` + +## Test Type +Integration test against Ably Sandbox endpoint + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + # Provision test app + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + # Clean up test app + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTN4b, RTN21 - Successful connection establishment + +| Spec | Requirement | +|------|-------------| +| RTN4b | When a connection is initiated, it transitions INITIALIZED → CONNECTING → CONNECTED | +| RTN21 | Connections are initiated via WebSocket transport | + +Tests that a Realtime client can successfully connect to Ably via WebSocket. + +### Setup + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +``` + +### Test Steps + +```pseudo +# Client starts in INITIALIZED state +ASSERT client.connection.state == ConnectionState.initialized + +# Start connection +client.connect() + +# Wait for CONNECTING state +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Wait for CONNECTED state (with timeout) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +# Verify connection properties are set +ASSERT client.connection.id IS NOT null +ASSERT client.connection.key IS NOT null +``` + +### Assertions + +```pseudo +# Final state is CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# Connection ID is a non-empty string +ASSERT client.connection.id matches "[a-zA-Z0-9_-]+" + +# Connection key is a non-empty string +ASSERT client.connection.key matches "[a-zA-Z0-9_!-]+" + +# No error reason +ASSERT client.connection.errorReason IS null +``` + +--- + +## RTN4c, RTN12, RTN12a - Graceful connection close + +| Spec | Requirement | +|------|-------------| +| RTN4c | Normal disconnection: CONNECTED → CLOSING → CLOSED | +| RTN12 | Connection.close() initiates close sequence | +| RTN12a | Sends CLOSE message and waits for confirmation | + +Tests that a connected client can gracefully close the connection. + +### Setup + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +# Establish connection first +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Test Steps + +```pseudo +# Close the connection +client.connection.close() + +# Should transition through CLOSING +AWAIT_STATE client.connection.state == ConnectionState.closing + +# Should reach CLOSED +AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Final state is CLOSED +ASSERT client.connection.state == ConnectionState.closed + +# No error reason (clean close) +ASSERT client.connection.errorReason IS null + +# Connection ID is cleared +ASSERT client.connection.id IS null + +# Connection key is cleared +ASSERT client.connection.key IS null +``` + +--- + +## RTN11, RTN4b - Connect and reconnect cycle + +| Spec | Requirement | +|------|-------------| +| RTN11 | Connection.connect() explicitly opens connection | +| RTN4b | Each connection follows CONNECTING → CONNECTED flow | + +Tests that a client can be closed and reconnected multiple times. + +### Setup + +```pseudo +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + autoConnect: false # Don't connect automatically +)) +``` + +### Test Steps + +```pseudo +# Initial state +ASSERT client.connection.state == ConnectionState.initialized + +# First connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +first_connection_id = client.connection.id + +# Close connection +client.connection.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Reconnect +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +second_connection_id = client.connection.id +``` + +### Assertions + +```pseudo +# Successfully connected twice +ASSERT second_connection_id IS NOT null + +# Each connection gets a new ID (not a resume) +ASSERT first_connection_id != second_connection_id + +# No errors +ASSERT client.connection.errorReason IS null +``` + +--- + +## Integration Test Notes + +### Timeout Handling + +All `AWAIT_STATE` calls should have reasonable timeouts: +- CONNECTING → CONNECTED: 10 seconds (allows for auth + transport setup) +- CONNECTED → CLOSING: 1 second (immediate transition) +- CLOSING → CLOSED: 5 seconds (allows for CLOSE message roundtrip) + +### Error Handling + +If any connection fails to reach CONNECTED state: +- Log the connection errorReason +- Log any emitted state changes with reasons +- Fail the test with diagnostic information + +### Cleanup + +Always close connections in test cleanup: + +```pseudo +AFTER EACH TEST: + IF client IS NOT null AND client.connection.state IN [CONNECTED, CONNECTING]: + client.connection.close() + # Wait briefly for close to complete + AWAIT_STATE client.connection.state == ConnectionState.closed + WITH timeout: 10 seconds +``` diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md new file mode 100644 index 000000000..62d6ca573 --- /dev/null +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -0,0 +1,1047 @@ +# Connection Failures When Connected Tests (RTN15) + +Spec points: `RTN15`, `RTN15a`, `RTN15b`, `RTN15c`, `RTN15d`, `RTN15e`, `RTN15g`, `RTN15h`, `RTN15j` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN15h1 - DISCONNECTED with token error, no means to renew + +**Spec requirement:** If a DISCONNECTED message contains a token error and the library cannot renew the token, transition to FAILED state. + +Tests that non-renewable token errors cause permanent failure. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Use token directly (no way to renew) +client = Realtime(options: ClientOptions( + token: "some_token_string", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get reference to the WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with token error +ws_connection.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) +)) + +# Should transition to FAILED (no means to renew) +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 2 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40142 +ASSERT client.connection.errorReason.statusCode == 401 +``` + +--- + +## RTN15h2 - DISCONNECTED with token error, renewable token + +**Spec requirement:** If a DISCONNECTED message contains a token error and the library can renew the token, transition to CONNECTING, obtain new token, and attempt resume. + +Tests that renewable token errors trigger token renewal and reconnection. + +### Setup + +```pseudo +token_request_count = 0 +connection_attempt_count = 0 + +# Mock HTTP for token requests +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + token_request_count++ + req.respond_with(200, { + "token": "renewed_token_" + token_request_count, + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First connection succeeds + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume after token renewal succeeds + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID = successful resume + connectionKey: "key-1-renewed", + connectionDetails: ConnectionDetails( + connectionKey: "key-1-renewed", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +first_connection_id = client.connection.id +first_connection_key = client.connection.key + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with token error +ws_connection.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) +)) + +# Should transition to CONNECTING (to renew and resume) +AWAIT_STATE client.connection.state == ConnectionState.connecting + WITH timeout: 2 seconds + +# Should reconnect with renewed token +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +# Token was renewed +ASSERT token_request_count == 2 # Initial + renewal + +# Connection was resumed (same ID) +ASSERT client.connection.id == first_connection_id + +# Connection key was updated +ASSERT client.connection.key != first_connection_key +ASSERT client.connection.key == "key-1-renewed" +``` + +--- + +## RTN15h2 - DISCONNECTED with token error, renewal fails + +**Spec requirement:** If token renewal or reconnection fails after DISCONNECTED with token error, transition to DISCONNECTED with errorReason set. + +Tests that failed token renewal leads to DISCONNECTED state. + +### Setup + +```pseudo +# Mock HTTP for token requests (returns error) +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + req.respond_with(401, { + "error": { + "code": 40101, + "statusCode": 401, + "message": "Invalid credentials" + } + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with token error +ws_connection.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) +)) + +# Should transition to CONNECTING (to attempt renewal) +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Renewal fails, should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection is DISCONNECTED (will retry later) +ASSERT client.connection.state == ConnectionState.disconnected + +# Error reason is set (from token renewal failure) +ASSERT client.connection.errorReason IS NOT null +``` + +--- + +## RTN15h3 - DISCONNECTED with non-token error + +**Spec requirement:** If a DISCONNECTED message contains an error other than a token error, initiate immediate reconnect with resume attempt. + +Tests that non-token disconnection triggers immediate resume. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID = resumed + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends DISCONNECTED with non-token error +ws_connection.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 80003, + statusCode: 503, + message: "Service unavailable" + ) +)) + +# Should transition to CONNECTING immediately (no token renewal) +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Should reconnect and resume +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +# Connection was resumed (same ID) +ASSERT client.connection.id == original_connection_id + +# Two connection attempts total +ASSERT connection_attempt_count == 2 + +# Second connection attempt included resume parameter +ASSERT mock_ws.events[1].url.query_params["resume"] == "key-1" +``` + +--- + +## RTN15j - ERROR protocol message with empty channel + +**Spec requirement:** If an ERROR ProtocolMessage with empty channel is received when CONNECTED, transition to FAILED state and set errorReason. + +Tests that fatal connection errors cause FAILED state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Server sends ERROR with empty channel (connection-level error) +ws_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: null, # Empty = connection-level error + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal error" + ) +)) + +# Should transition to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 2 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +``` + +--- + +## RTN15a - Unexpected transport disconnect + +**Spec requirement:** If transport is disconnected unexpectedly (without DISCONNECTED or ERROR), respond as if receiving non-token DISCONNECTED message. + +Tests that transport failures trigger resume attempts. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID = resumed + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect successfully +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Get WebSocket connection +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection + +# Simulate unexpected disconnect (no protocol message) +ws_connection.simulate_disconnect() + +# Should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second + +# Should automatically attempt reconnect +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Should resume successfully +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected +ASSERT client.connection.state == ConnectionState.connected + +# Connection was resumed (same ID) +ASSERT client.connection.id == original_connection_id + +# Two connection attempts made +ASSERT connection_attempt_count == 2 +``` + +--- + +## RTN15b, RTN15c6 - Successful resume + +| Spec | Requirement | +|------|-------------| +| RTN15b | Resume is attempted with connectionKey in query parameter | +| RTN15c6 | Successful resume indicated by same connectionId in CONNECTED | + +Tests that connection resume works correctly. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume succeeds (same connectionId) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", # Same ID indicates successful resume + connectionKey: "key-1-updated", + connectionDetails: ConnectionDetails( + connectionKey: "key-1-updated", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id +ASSERT original_connection_id == "connection-1" + +# Force disconnect +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection resumed (same ID) +ASSERT client.connection.id == "connection-1" + +# Connection key was updated (RTN15e) +ASSERT client.connection.key == "key-1-updated" + +# Second connection attempt included resume parameter (RTN15b1) +ASSERT captured_connection_attempts[1].url.query_params["resume"] == "key-1" + +# Two connection attempts total +ASSERT connection_attempt_count == 2 +``` + +--- + +## RTN15c7 - Failed resume (new connectionId) + +**Spec requirement:** If resume fails, server sends CONNECTED with new connectionId and error. Client should reset msgSerial to 0. + +Tests that failed resume is handled correctly. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume failed (new connectionId + error) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-2", # Different ID = failed resume + connectionKey: "key-2", + error: ErrorInfo( + code: 80008, + statusCode: 400, + message: "Unable to recover connection" + ), + connectionDetails: ConnectionDetails( + connectionKey: "key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Force disconnect +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# New connection (different ID) +ASSERT client.connection.id == "connection-2" +ASSERT client.connection.id != original_connection_id + +# Connection key updated +ASSERT client.connection.key == "key-2" + +# Error reason set (indicates why resume failed) +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80008 + +# Connection is still CONNECTED (despite error) +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTN15e - Connection key updated on resume + +**Spec requirement:** When connection is resumed, Connection.key may change and is provided in CONNECTED message connectionDetails. + +Tests that connection key is updated after resume. + +This is covered by the RTN15b, RTN15c6 test above. The key assertion is: + +```pseudo +ASSERT client.connection.key == "key-1-updated" +``` + +--- + +## RTN15g - Connection state cleared after connectionStateTtl + +**Spec requirement:** If disconnected longer than connectionStateTtl, don't attempt resume. Clear local state and make fresh connection. + +Tests that stale connections don't attempt resume. + +### Setup + +```pseudo +connection_attempt_count = 0 +captured_connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + captured_connection_attempts.append(conn) + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 5000 # 5 seconds TTL + ) + )) + ELSE: + # Fresh connection (no resume) + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-2", # New ID + connectionKey: "key-2", + connectionDetails: ConnectionDetails( + connectionKey: "key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 1000, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +original_connection_id = client.connection.id + +# Force disconnect +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance time past connectionStateTtl +ADVANCE_TIME(6000) # Past the 5s TTL + +# Trigger reconnection +ADVANCE_TIME(1000) # Past disconnectedRetryTimeout + +# Wait for reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# New connection (different ID, not resumed) +ASSERT client.connection.id == "connection-2" +ASSERT client.connection.id != original_connection_id + +# Second connection did NOT include resume parameter +ASSERT "resume" NOT IN captured_connection_attempts[1].url.query_params + +# Fresh connection key +ASSERT client.connection.key == "key-2" +``` + +--- + +## RTN15c5 - ERROR with token error during resume + +**Spec requirement:** If resume attempt receives ERROR with token error, follow RTN15h spec for token error handling. + +Tests that token errors during resume trigger renewal. + +### Setup + +```pseudo +token_request_count = 0 +connection_attempt_count = 0 + +# Mock HTTP for token requests +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + token_request_count++ + req.respond_with(200, { + "token": "renewed_token", + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE IF connection_attempt_count == 2: + # Resume attempt fails with token error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + ELSE: + # Retry with renewed token succeeds + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-2", + connectionKey: "key-2", + connectionDetails: ConnectionDetails( + connectionKey: "key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect (will trigger resume attempt) +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for final CONNECTED (after token renewal) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Successfully reconnected after token renewal +ASSERT client.connection.state == ConnectionState.connected + +# Token was renewed +ASSERT token_request_count == 2 # Initial + renewal + +# Three connection attempts (initial, failed resume, retry with new token) +ASSERT connection_attempt_count == 3 +``` + +--- + +## RTN15c4 - ERROR with fatal error during resume + +**Spec requirement:** If resume attempt receives ERROR with fatal error, transition to FAILED state. + +Tests that fatal errors during resume cause permanent failure. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + conn.respond_with_success() + + IF connection_attempt_count == 1: + # Initial connection + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Resume attempt fails with fatal error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal server error" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initial connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect (will trigger resume attempt) +ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection +ws_connection.simulate_disconnect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 + +# Only two connection attempts (no retry after fatal error) +ASSERT connection_attempt_count == 2 +``` diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md new file mode 100644 index 000000000..dc282c275 --- /dev/null +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -0,0 +1,568 @@ +# Connection Opening Failures Tests (RTN14) + +Spec points: `RTN14`, `RTN14a`, `RTN14b`, `RTN14c`, `RTN14d`, `RTN14e`, `RTN14f`, `RTN14g` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN14a - Invalid API key causes FAILED state + +**Spec requirement:** If an API key is invalid, the connection transitions to FAILED state and Connection.errorReason is set. + +Tests that connecting with an invalid API key results in immediate failure. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # WebSocket connects successfully + conn.respond_with_success() + + # But server immediately sends ERROR for invalid key + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40005, + statusCode: 400, + message: "Invalid key" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "invalid.key:secret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTING state +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40005 +ASSERT client.connection.errorReason.statusCode == 400 + +# Connection ID/key not set +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +``` + +--- + +## RTN14b - Token error during connection with renewal + +**Spec requirement:** If a token error occurs during connection and the token is renewable, attempt to obtain a new token and retry the connection. + +Tests that token errors trigger renewal and retry when possible. + +### Setup + +```pseudo +token_request_count = 0 +connection_attempt_count = 0 + +# Mock HTTP for token requests +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + IF req.url.path CONTAINS "/keys/": + token_request_count++ + req.respond_with(200, { + "token": "renewed_token_" + token_request_count, + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +# Mock WebSocket +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First attempt: token error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + ELSE: + # Second attempt: success + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED (should retry after token renewal) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Successfully connected after retry +ASSERT client.connection.state == ConnectionState.connected + +# Token was renewed +ASSERT token_request_count == 2 # Initial + renewal + +# Connection was attempted twice +ASSERT connection_attempt_count == 2 +``` + +--- + +## RTN14b - Token error during connection without renewal (RSA4a) + +**Spec requirement:** If a token error occurs and the token cannot be renewed, transition to DISCONNECTED state. + +Tests that non-renewable token errors cause disconnection. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +install_mock(mock_ws) + +# Use token directly (no way to renew) +client = Realtime(options: ClientOptions( + token: "expired_token_string", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to DISCONNECTED (not FAILED, will retry) +ASSERT client.connection.state == ConnectionState.disconnected + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40142 +``` + +--- + +## RTN14c - Connection timeout + +**Spec requirement:** A connection attempt fails if not connected within realtimeRequestTimeout. + +Tests that connections time out if no CONNECTED message is received. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + # WebSocket connects but server never sends CONNECTED + # (simulates unresponsive server) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second timeout + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Wait for CONNECTING state +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Advance time past timeout +ADVANCE_TIME(1100) + +# Should transition to DISCONNECTED (will retry) +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection timed out +ASSERT client.connection.state == ConnectionState.disconnected + +# Error indicates timeout +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message CONTAINS "timeout" + OR client.connection.errorReason.code IN [50003, 80003] +``` + +--- + +## RTN14d - Retry after recoverable failure + +**Spec requirement:** After a recoverable connection failure, the client transitions to DISCONNECTED and automatically retries after disconnectedRetryTimeout. + +Tests that recoverable failures trigger automatic retry. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt fails (network error) + conn.respond_with_refused() + ELSE: + # Second attempt succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Should transition to DISCONNECTED after first failure +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 2 seconds + +# Advance time to trigger retry +ADVANCE_TIME(1100) + +# Should reconnect automatically +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully connected on retry +ASSERT client.connection.state == ConnectionState.connected + +# Two connection attempts were made +ASSERT connection_attempt_count == 2 +``` + +--- + +## RTN14e - DISCONNECTED to SUSPENDED after connectionStateTtl + +**Spec requirement:** Once the connection has been DISCONNECTED for longer than connectionStateTtl, transition to SUSPENDED state. + +Tests that prolonged disconnection leads to suspension. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # All connection attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 1000, # Retry every 1 second + autoConnect: false +)) + +# Simulate short connectionStateTtl +# In real implementation, this comes from server in CONNECTED message +# For this test, we'll use a short default value +DEFAULT_CONNECTION_STATE_TTL = 5000 # 5 seconds +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail) +client.connect() + +# Should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance time past connectionStateTtl +ADVANCE_TIME(DEFAULT_CONNECTION_STATE_TTL + 100) + +# Should transition to SUSPENDED +AWAIT_STATE client.connection.state == ConnectionState.suspended + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection is SUSPENDED +ASSERT client.connection.state == ConnectionState.suspended + +# Error reason is set (indicates why suspended) +ASSERT client.connection.errorReason IS NOT null +``` + +--- + +## RTN14f - SUSPENDED state retries indefinitely + +**Spec requirement:** The connection remains in SUSPENDED state indefinitely, periodically attempting to reestablish connection. + +Tests that SUSPENDED state continues retry attempts. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count < 3: + # First 2 attempts fail + conn.respond_with_refused() + ELSE: + # Third attempt succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + suspendedRetryTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail repeatedly) +client.connect() + +# Wait for SUSPENDED state +# (after initial failure + connectionStateTtl expiry) +AWAIT_STATE client.connection.state == ConnectionState.suspended + +recorded_suspended_time = current_fake_time() + +# Advance time to trigger first SUSPENDED retry +ADVANCE_TIME(1100) + +# Should attempt reconnection (but still fail) +WAIT_FOR connection_attempt_count >= 2 + +# Advance time to trigger second SUSPENDED retry +ADVANCE_TIME(1100) + +# Should reconnect successfully on third attempt +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Successfully connected after multiple SUSPENDED retries +ASSERT client.connection.state == ConnectionState.connected + +# Multiple connection attempts were made from SUSPENDED state +ASSERT connection_attempt_count >= 3 +``` + +--- + +## RTN14g - ERROR protocol message with empty channel + +**Spec requirement:** If an ERROR ProtocolMessage with empty channel attribute is received, transition to FAILED state and set errorReason. + +Tests that fatal protocol errors cause FAILED state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: ERROR, + channel: null, # Empty channel = connection-level error + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal server error" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set from protocol message +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +ASSERT client.connection.errorReason.message == "Internal server error" +``` + +--- + +## Timer Mocking Note + +These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. Implementations should: + +1. **Prefer fake timers** (JavaScript Jest, Python freezegun, Go testing.Clock) +2. **Or use dependency injection** for timer/clock interfaces +3. **Or use very short timeout values** (e.g., 50ms instead of 15s) +4. **Last resort:** Use actual delays with generous test timeouts + +See the "Timer Mocking" section in `write-test-spec.md` for detailed guidance. diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md new file mode 100644 index 000000000..05222962d --- /dev/null +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -0,0 +1,448 @@ +# Connection errorReason Tests (RTN25) + +Spec point: `RTN25` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN25 - errorReason set on connection errors + +**Spec requirement:** Connection#errorReason attribute is an optional ErrorInfo object which is set by the library when an error occurs on the connection, as described by RSA4c1, RSA4d, RTN11d, RTN14a, RTN14b, RTN14e, RTN14g, RTN15c7, RTN15c4, RTN15d, RTN15h, RTN15i, RTN16e. + +Tests that errorReason is populated correctly across various error scenarios. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40005, + statusCode: 400, + message: "Invalid API key" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "invalid.key:secret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Initially errorReason should be null +ASSERT client.connection.errorReason IS null + +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason is set with error details +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40005 +ASSERT client.connection.errorReason.statusCode == 400 +ASSERT client.connection.errorReason.message == "Invalid API key" +``` + +--- + +## RTN25 - errorReason on DISCONNECTED state (RTN14e) + +**Spec requirement:** errorReason is set when connection enters DISCONNECTED state due to connection failure. + +Tests that errorReason is populated when transitioning to DISCONNECTED. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Connection attempt fails + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message IS NOT null + +# Error indicates connection failure +# (Exact error code/message depends on implementation) +``` + +--- + +## RTN25 - errorReason on SUSPENDED state (RTN14e) + +**Spec requirement:** errorReason is updated when connection enters SUSPENDED state after connectionStateTtl expires. + +Tests that errorReason reflects suspension reason. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # All connection attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + autoConnect: false +)) + +DEFAULT_CONNECTION_STATE_TTL = 5000 # 5 seconds +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail) +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance time past connectionStateTtl +ADVANCE_TIME(DEFAULT_CONNECTION_STATE_TTL + 100) + +# Wait for SUSPENDED state +AWAIT_STATE client.connection.state == ConnectionState.suspended + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# errorReason is set and indicates suspension +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message IS NOT null + +# Error should indicate timeout or suspension reason +# (Exact error code/message depends on implementation) +``` + +--- + +## RTN25 - errorReason on token errors (RTN14b, RTN15h) + +**Spec requirement:** errorReason is set when token errors occur during connection or while connected. + +Tests that errorReason captures token-related errors. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +install_mock(mock_ws) + +# Use token directly (no way to renew) +client = Realtime(options: ClientOptions( + token: "expired_token", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED state (can't renew token) +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason contains token error details +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40142 +ASSERT client.connection.errorReason.statusCode == 401 +ASSERT client.connection.errorReason.message CONTAINS "Token" +``` + +--- + +## RTN25 - errorReason cleared on successful connection + +**Spec requirement:** errorReason should be cleared when connection successfully recovers. + +Tests that errorReason is reset after successful connection following a failure. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt fails + conn.respond_with_refused() + ELSE: + # Second attempt succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 100, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection (will fail initially) +client.connect() + +# Wait for DISCONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +# errorReason should be set after failure +ASSERT client.connection.errorReason IS NOT null +failure_error = client.connection.errorReason + +# Advance time to trigger retry +ADVANCE_TIME(150) + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason should be cleared after successful connection +# Note: Specification doesn't explicitly require this, but it's common practice +# Some implementations may keep the last error for debugging purposes +# Verify implementation behavior: + +# Either: +# A) errorReason is cleared on successful connection +ASSERT client.connection.errorReason IS null + +# Or: +# B) errorReason is kept but clearly not relevant to current state +# (Implementation-specific behavior) +``` + +--- + +## RTN25 - errorReason on protocol-level ERROR message (RTN14g) + +**Spec requirement:** errorReason is set when ERROR ProtocolMessage with empty channel is received. + +Tests that connection-level protocol errors populate errorReason. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: ERROR, + channel: null, # Empty channel = connection-level error + error: ErrorInfo( + code: 50000, + statusCode: 500, + message: "Internal server error" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# errorReason is set from ERROR protocol message +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 50000 +ASSERT client.connection.errorReason.statusCode == 500 +ASSERT client.connection.errorReason.message == "Internal server error" +``` + +--- + +## RTN25 - errorReason propagated to ConnectionStateChange events + +**Spec requirement:** errorReason should be accessible through ConnectionStateChange events emitted during state transitions. + +Tests that state change events include error information. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40003, + statusCode: 400, + message: "Access token invalid" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track state changes +state_changes = [] + +client.connection.on(ConnectionState.failed, (change) => { + state_changes.push(change) +}) + +# Start connection +client.connect() + +# Wait for FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# State change event was emitted +ASSERT state_changes.length == 1 + +change = state_changes[0] + +# State change has reason populated +ASSERT change.reason IS NOT null +ASSERT change.reason.code == 40003 +ASSERT change.reason.statusCode == 400 +ASSERT change.reason.message == "Access token invalid" + +# Connection errorReason matches state change reason +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == change.reason.code +ASSERT client.connection.errorReason.message == change.reason.message +``` + +--- + +## Note on errorReason Lifecycle + +The errorReason attribute behavior across different implementations: + +1. **Set on error**: Always populated when an error causes a state transition +2. **Cleared on success**: May or may not be cleared on successful connection (implementation-specific) +3. **Accessible via**: Both `Connection#errorReason` attribute and `ConnectionStateChange#reason` +4. **Persistence**: Some implementations keep the last error for debugging, others clear it +5. **NULL vs defined**: Initially null before any errors occur + +Test implementations should verify their SDK's specific behavior regarding errorReason lifecycle. diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md new file mode 100644 index 000000000..6f6c21479 --- /dev/null +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -0,0 +1,664 @@ +# Fallback Hosts Tests (RTN17) + +Spec points: `RTN17`, `RTN17e`, `RTN17f`, `RTN17f1`, `RTN17g`, `RTN17h`, `RTN17i`, `RTN17j` + +## Test Type +Unit test with mocked WebSocket client and HTTP client + +## Mock Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/rest/mock_http_client.md` for Mock HTTP Client specification. + +--- + +## RTN17i - Always prefer primary domain first + +**Spec requirement:** By default, every connection attempt is first attempted to the primary domain. The client library must always prefer the primary domain, even if a previous connection attempt to that endpoint has failed. + +Tests that the client always tries the primary domain first, even after failures. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Record which host was attempted + connection_attempts.push({ + host: conn.url.host, + attempt_number: connection_attempts.length + 1 + }) + + IF connection_attempts.length == 1: + # First attempt (to primary): fail + conn.respond_with_refused() + ELSE IF connection_attempts.length == 2: + # Second attempt (to fallback): succeed + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# First connection attempt +client.connect() + +# Wait for successful connection (after trying primary then fallback) +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +# Now force a disconnection +mock_ws.active_connection.close() + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +# Clear previous attempts +connection_attempts.clear() + +# Allow next connection to primary to succeed +mock_ws.onConnectionAttempt = (conn) => { + connection_attempts.push({ + host: conn.url.host, + attempt_number: connection_attempts.length + 1 + }) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +} + +# Wait for automatic reconnection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# The reconnection attempt should have tried primary domain first +ASSERT connection_attempts.length >= 1 +ASSERT connection_attempts[0].host == "realtime.ably.io" + OR connection_attempts[0].host CONTAINS "realtime.ably" # Primary domain +``` + +--- + +## RTN17f - Errors that necessitate fallback host usage + +**Spec requirement:** Errors that necessitate use of an alternative host include conditions specified in RSC15l and also DISCONNECTED responses with error.statusCode in range 500-504. + +Tests that specific error conditions trigger fallback host usage. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary domain: unresolvable (simulated) + conn.respond_with_error("Host unresolvable") + ELSE: + # Fallback domain: succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection via fallback +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Should have tried at least 2 hosts (primary + fallback) +ASSERT connection_attempts.length >= 2 + +# First attempt was to primary domain +ASSERT connection_attempts[0] CONTAINS "realtime.ably" + +# Second attempt was to a fallback domain +ASSERT connection_attempts[1] CONTAINS "fallback" +``` + +--- + +## RTN17f1 - DISCONNECTED with 5xx status triggers fallback + +**Spec requirement:** A DISCONNECTED response with an error.statusCode in the range 500 <= code <= 504 necessitates use of an alternative host. + +Tests that 5xx errors in DISCONNECTED messages trigger fallback. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary domain: connect then send DISCONNECTED with 503 + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + code: 50003, + statusCode: 503, + message: "Service temporarily unavailable" + ) + )) + ELSE: + # Fallback domain: succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection via fallback +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Should have tried at least 2 hosts +ASSERT connection_attempts.length >= 2 + +# First was primary, second was fallback +ASSERT connection_attempts[0] CONTAINS "realtime.ably" +ASSERT connection_attempts[1] CONTAINS "fallback" +``` + +--- + +## RTN17j - Connectivity check before fallback + +**Spec requirement:** In case of an error necessitating fallback, check connectivity by issuing GET to connectivityCheckUrl. If response includes "yes", proceed with fallback hosts in random order. + +Tests that connectivity check is performed before trying fallback hosts. + +### Setup + +```pseudo +http_requests = [] +connection_attempts = [] + +# Mock HTTP client for connectivity check +mock_http = MockHttpClient( + onRequest: (req) => { + http_requests.push({ + url: req.url.toString(), + method: req.method + }) + + IF req.url.toString() CONTAINS "internet-up": + # Connectivity check succeeds + req.respond_with(200, "yes", contentType: "text/plain") + ELSE: + # Token requests etc + req.respond_with(200, { + "token": "test_token", + "keyName": "appId.keyId", + "issued": time_now(), + "expires": time_now() + 3600000, + "capability": "{\"*\":[\"*\"]}" + }) + } +) +install_mock(mock_http) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary domain fails + conn.respond_with_timeout() + ELSE: + # Fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds +``` + +### Assertions + +```pseudo +# Connectivity check should have been performed +connectivity_checks = FILTER http_requests WHERE req.url CONTAINS "internet-up" +ASSERT connectivity_checks.length >= 1 + +# Connectivity check was a GET request +ASSERT connectivity_checks[0].method == "GET" + +# Connection attempts proceeded to fallback after check +ASSERT connection_attempts.length >= 2 +``` + +--- + +## RTN17g - Empty fallback set results in immediate error + +**Spec requirement:** When the set of fallback domains is empty, failing requests that would have qualified for retry should result in an error immediately. + +Tests that no fallback is attempted when fallback set is empty. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + # Connection fails + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +# Use custom endpoint which results in empty fallback set (REC2c2) +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeHost: "custom.example.com", # Custom host = no fallbacks + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED (should not try fallbacks) +AWAIT_STATE client.connection.state IN [ConnectionState.disconnected, ConnectionState.failed] + WITH timeout: 5 seconds + +# Give it time to potentially try fallbacks (it shouldn't) +WAIT(2000) +``` + +### Assertions + +```pseudo +# Should have only tried the custom host once, no fallbacks +ASSERT connection_attempts.length == 1 +ASSERT connection_attempts[0] == "custom.example.com" +``` + +--- + +## RTN17h - Fallback domains determined by REC2 + +**Spec requirement:** When fallbacks apply, the set of fallback domains is determined by REC2. + +Tests that correct fallback hosts are used based on configuration. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary fails + conn.respond_with_refused() + ELSE: + # Fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Use default configuration (should use default fallback hosts) +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +``` + +### Assertions + +```pseudo +# Should have tried primary then fallback +ASSERT connection_attempts.length >= 2 + +# Second attempt should be a default fallback host +# Default fallback pattern: *.a|b|c|d|e.fallback.ably-realtime.com +fallback_host = connection_attempts[1] +ASSERT fallback_host CONTAINS "fallback.ably-realtime.com" +ASSERT fallback_host MATCHES /\.[abcde]\.fallback\.ably-realtime\.com$/ +``` + +--- + +## RTN17j - Fallback hosts tried in random order + +**Spec requirement:** Retry connection against fallback domains in random order to find an alternative healthy datacenter. + +Tests that fallback hosts are not always tried in the same order. + +### Setup + +```pseudo +# Run multiple test iterations to check randomness +fallback_orders = [] + +FOR iteration IN [1, 2, 3, 4, 5]: + connection_attempts = [] + + mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length <= 3: + # Primary and first 2 fallbacks fail + conn.respond_with_refused() + ELSE: + # Third fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } + ) + install_mock(mock_ws) + + client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false + )) + + client.connect() + + AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 15 seconds + + # Record the order of fallback hosts (skip primary at index 0) + fallback_order = connection_attempts[1:] + fallback_orders.push(fallback_order) + + await client.close() +``` + +### Test Steps + +```pseudo +# Analyze the collected fallback orders +``` + +### Assertions + +```pseudo +# At least one iteration should have different order than another +# (This is probabilistic - with 5 iterations and 5 fallback hosts, +# we should see some variation) + +unique_orders = COUNT_UNIQUE(fallback_orders) +ASSERT unique_orders >= 2 + +# Note: This test may occasionally fail due to randomness +# In production, this should use a larger sample size +``` + +--- + +## RTN17e - HTTP requests use same fallback host as realtime connection + +**Spec requirement:** If the realtime client is connected to a fallback host, HTTP requests should first be attempted to the same datacenter. If the HTTP request fails, follow normal fallback behavior. + +Tests that HTTP requests prefer the same host as the active realtime connection. + +### Setup + +```pseudo +connection_attempts = [] +http_requests = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.push(conn.url.host) + + IF connection_attempts.length == 1: + # Primary fails + conn.respond_with_refused() + ELSE: + # Fallback succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +mock_http = MockHttpClient( + onRequest: (req) => { + http_requests.push({ + url: req.url.toString(), + host: req.url.host + }) + + # Respond successfully to HTTP requests + IF req.url.path CONTAINS "/history": + req.respond_with(200, { + "items": [], + "start": 0, + "end": 0 + }) + ELSE: + req.respond_with(200, {}) + } +) +install_mock(mock_http) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for successful connection to fallback +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +# Determine which fallback host we're connected to +connected_fallback_host = connection_attempts[1] + +# Make an HTTP request (e.g., channel history) +channel = client.channels.get("test-channel") +await channel.history() + +# Wait for HTTP request to complete +WAIT(500) +``` + +### Assertions + +```pseudo +# At least one HTTP request should have been made +history_requests = FILTER http_requests WHERE req.url CONTAINS "/history" +ASSERT history_requests.length >= 1 + +# HTTP request should have used the same fallback host +# Note: The exact host matching logic may vary by implementation +# Some SDKs may convert WebSocket host to REST host pattern +history_host = history_requests[0].host + +# Either: +# A) Exact match +ASSERT history_host == connected_fallback_host + +# Or: +# B) Same fallback datacenter (e.g., *.b.fallback.* matches) +ASSERT EXTRACT_FALLBACK_ID(history_host) == EXTRACT_FALLBACK_ID(connected_fallback_host) +``` + +--- + +## Implementation Notes + +Fallback host behavior involves several complex interactions: + +1. **Primary preference (RTN17i)**: Always try primary first, even after previous failures +2. **Error conditions (RTN17f)**: Only specific errors trigger fallback (host unreachable, timeout, 5xx) +3. **Connectivity check (RTN17j)**: Check internet connectivity before blaming Ably +4. **Randomization (RTN17j)**: Use fallbacks in random order to distribute load +5. **Empty fallback set (RTN17g)**: Custom hosts typically have no fallbacks +6. **HTTP coordination (RTN17e)**: REST and realtime should use same datacenter +7. **Configuration (RTN17h)**: Fallback set determined by REC2 rules + +Test implementations should verify their SDK correctly implements these behaviors. diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md new file mode 100644 index 000000000..6b8e24c03 --- /dev/null +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -0,0 +1,417 @@ +# Heartbeat Tests (RTN23) + +Spec points: `RTN23`, `RTN23a`, `RTN23b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN23a - Disconnect after maxIdleInterval + realtimeRequestTimeout + +**Spec requirement:** If no message is received from the server for maxIdleInterval + realtimeRequestTimeout milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state. + +Tests that the client disconnects when no server activity is detected. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 5000, # 5 seconds + connectionStateTtl: 120000 + ) + )) + # Server sends CONNECTED but then no further messages + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, # 2 seconds + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Advance time past maxIdleInterval + realtimeRequestTimeout +# = 5000 + 2000 = 7000ms +ADVANCE_TIME(7100) + +# Should transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection transitioned to DISCONNECTED +ASSERT client.connection.state == ConnectionState.disconnected + +# Error reason indicates timeout/inactivity +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.message CONTAINS "idle" + OR client.connection.errorReason.message CONTAINS "heartbeat" + OR client.connection.errorReason.message CONTAINS "timeout" +``` + +--- + +## RTN23a - HEARTBEAT message resets idle timer + +**Spec requirement:** Any message from the server, including HEARTBEAT messages, resets the idle timer. + +Tests that receiving HEARTBEAT messages keeps the connection alive. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 3000, # 3 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time (not enough to trigger timeout) +ADVANCE_TIME(2000) # 2 seconds + +# Send HEARTBEAT from server +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT +)) + +# Advance time again (should still be connected) +ADVANCE_TIME(2000) # Total 4 seconds, but timer reset at 2 seconds + +# Connection should still be alive +WAIT(500) + +ASSERT client.connection.state == ConnectionState.connected + +# Advance time past the new timeout window +ADVANCE_TIME(2100) # Now 2100ms since last HEARTBEAT + +# Should disconnect now +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection stayed alive after HEARTBEAT +# Then disconnected after no more messages +ASSERT client.connection.state == ConnectionState.disconnected + +# Error reason indicates timeout +ASSERT client.connection.errorReason IS NOT null +``` + +--- + +## RTN23a - Any protocol message resets idle timer + +**Spec requirement:** Any message from the server resets the idle timer, not just HEARTBEAT messages. + +Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time +ADVANCE_TIME(1500) + +# Send ACK message from server +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: 0 +)) + +# Advance time again +ADVANCE_TIME(1500) + +# Connection should still be alive (timer was reset) +ASSERT client.connection.state == ConnectionState.connected + +# Send MESSAGE from server +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: "test-channel", + messages: [ + Message(name: "event", data: "data") + ] +)) + +# Advance time again +ADVANCE_TIME(1500) + +# Still connected +ASSERT client.connection.state == ConnectionState.connected + +# Advance time past timeout without any message +ADVANCE_TIME(1600) + +# Should disconnect now +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 1 second +``` + +### Assertions + +```pseudo +# Connection stayed alive with various message types +# Then disconnected after no more messages +ASSERT client.connection.state == ConnectionState.disconnected +``` + +--- + +## RTN23b - Client can request heartbeats in query params + +**Spec requirement:** The client can request heartbeats by including heartbeats=true in the connection query parameters. + +Tests that the client can enable/disable heartbeats via query parameters. + +### Setup + +```pseudo +connection_urls = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Record the connection URL + connection_urls.push(conn.url) + + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Client with default behavior (heartbeats enabled) +client1 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Client with heartbeats explicitly disabled +client2 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + closeOnUnload: false, # Or another option that disables heartbeats + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connect first client (default, heartbeats enabled) +client1.connect() + +AWAIT_STATE client1.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Check URL includes heartbeats=true +url1 = connection_urls[0] + +await client1.close() + +# Connect second client (heartbeats disabled) +client2.connect() + +AWAIT_STATE client2.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Check URL includes heartbeats=false +url2 = connection_urls[1] +``` + +### Assertions + +```pseudo +# First client requested heartbeats +ASSERT url1.query_params CONTAINS "heartbeats=true" + OR "heartbeats" NOT IN url1.query_params # Default is true + +# Second client disabled heartbeats +ASSERT url2.query_params CONTAINS "heartbeats=false" + OR (implementation specific way to disable) +``` + +--- + +## RTN23b - Server respects heartbeats=false + +**Spec requirement:** If the client sends heartbeats=false, the server should not send HEARTBEAT messages and the client should not expect them. + +Tests that disabling heartbeats prevents timeout when no HEARTBEATs are sent. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + # Server sends no HEARTBEAT messages + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + # Configure to disable heartbeats (implementation-specific) + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time well past maxIdleInterval +ADVANCE_TIME(10000) # 10 seconds + +# Connection should remain CONNECTED (no heartbeat expectation) +# Note: This test may vary by implementation - some SDKs always +# expect some server activity even with heartbeats=false +``` + +### Assertions + +```pseudo +# Connection behavior when heartbeats disabled is implementation-specific +# Either: +# A) Connection stays alive indefinitely without messages +# B) Connection has a much longer timeout +# C) Connection still times out but with different threshold + +# Verify the implementation's documented behavior +ASSERT client.connection.state IN [ConnectionState.connected, ConnectionState.disconnected] +``` + +--- + +## Timer Mocking Note + +These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. Implementations should: + +1. **Prefer fake timers** (JavaScript Jest, Python freezegun, Go testing.Clock) +2. **Or use dependency injection** for timer/clock interfaces +3. **Or use very short timeout values** (e.g., 50ms instead of 5s) +4. **Last resort:** Use actual delays with generous test timeouts + +See the "Timer Mocking" section in `write-test-spec.md` for detailed guidance. diff --git a/uts/realtime/unit/connection/update_events_test.md b/uts/realtime/unit/connection/update_events_test.md new file mode 100644 index 000000000..1cfa49b3c --- /dev/null +++ b/uts/realtime/unit/connection/update_events_test.md @@ -0,0 +1,384 @@ +# UPDATE Events Tests (RTN24) + +Spec point: `RTN24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN24 - CONNECTED message while already CONNECTED emits UPDATE event + +**Spec requirement:** A connected client may receive a CONNECTED ProtocolMessage from Ably at any point (typically triggered by reauth). The connectionDetails must override stored details. The Connection should emit an UPDATE event with ConnectionStateChange having both previous and current attributes set to CONNECTED, and reason set to the error member of the CONNECTED ProtocolMessage (if any). The library must NOT emit a CONNECTED event if already connected. + +Tests that receiving CONNECTED while CONNECTED emits UPDATE, not CONNECTED. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000, + clientId: "client-123" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track events +connected_events = [] +update_events = [] + +client.connection.on(ConnectionState.connected, (change) => { + connected_events.push(change) +}) + +client.connection.on(ConnectionEvent.update, (change) => { + update_events.push(change) +}) + +# Start connection +client.connect() + +# Wait for initial CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Verify initial connection +ASSERT connected_events.length == 1 +ASSERT update_events.length == 0 + +# Server sends another CONNECTED message (e.g., after reauth) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 20000, # Different value + connectionStateTtl: 120000, + clientId: "client-123" + ) +)) + +# Wait for event to be processed +WAIT(100) +``` + +### Assertions + +```pseudo +# State remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# No additional CONNECTED event was emitted +ASSERT connected_events.length == 1 + +# UPDATE event was emitted +ASSERT update_events.length == 1 + +# UPDATE event has correct structure +update_change = update_events[0] +ASSERT update_change.previous == ConnectionState.connected +ASSERT update_change.current == ConnectionState.connected +ASSERT update_change.reason IS null # No error in this case + +# Connection details were updated +ASSERT client.connection.id == "connection-id-2" +ASSERT client.connection.key == "connection-key-2" +``` + +--- + +## RTN24 - UPDATE event with error reason + +**Spec requirement:** The UPDATE event's reason attribute should be set to the error member of the CONNECTED ProtocolMessage (if any). + +Tests that UPDATE events include error information when present. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track UPDATE events +update_events = [] + +client.connection.on(ConnectionEvent.update, (change) => { + update_events.push(change) +}) + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Server sends CONNECTED with error (e.g., token was renewed due to expiry) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ), + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired; renewed automatically" + ) +)) + +# Wait for event to be processed +WAIT(100) +``` + +### Assertions + +```pseudo +# UPDATE event was emitted +ASSERT update_events.length == 1 + +# UPDATE event has error reason +update_change = update_events[0] +ASSERT update_change.previous == ConnectionState.connected +ASSERT update_change.current == ConnectionState.connected +ASSERT update_change.reason IS NOT null +ASSERT update_change.reason.code == 40142 +ASSERT update_change.reason.statusCode == 401 +ASSERT update_change.reason.message CONTAINS "Token expired" +``` + +--- + +## RTN24 - ConnectionDetails override + +**Spec requirement:** The connectionDetails in the ProtocolMessage must override any stored details (see RTN21). + +Tests that receiving a new CONNECTED message updates connection details. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 10000, + connectionStateTtl: 60000, + maxMessageSize: 16384, + serverId: "server-1", + clientId: "client-original" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Verify initial connection details +initial_id = client.connection.id +initial_key = client.connection.key +ASSERT initial_id == "connection-id-1" +ASSERT initial_key == "connection-key-1" + +# Server sends new CONNECTED with different details +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 20000, # Changed + connectionStateTtl: 120000, # Changed + maxMessageSize: 32768, # Changed + serverId: "server-2", # Changed + clientId: "client-updated" # Changed + ) +)) + +# Wait for update to be processed +WAIT(100) +``` + +### Assertions + +```pseudo +# Connection details were updated +ASSERT client.connection.id == "connection-id-2" +ASSERT client.connection.key == "connection-key-2" + +# All connection details should be overridden +# (The exact accessors for these details may vary by implementation) +# Verify that the implementation stores and uses the new values + +# State remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTN24 - No duplicate CONNECTED event + +**Spec requirement:** The library must not emit a CONNECTED event if the client was already connected (see RTN4h). + +Tests that only UPDATE events are emitted, not CONNECTED events, when receiving CONNECTED while already connected. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Track all events +all_events = [] + +# Subscribe to all connection events +FOR EACH state IN [ConnectionState.initialized, ConnectionState.connecting, + ConnectionState.connected, ConnectionState.disconnected, + ConnectionState.suspended, ConnectionState.closing, + ConnectionState.closed, ConnectionState.failed]: + client.connection.on(state, (change) => { + all_events.push({type: "state", state: state, change: change}) + }) + +# Also subscribe to UPDATE +client.connection.on(ConnectionEvent.update, (change) => { + all_events.push({type: "update", change: change}) +}) + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Record event count after initial connection +initial_event_count = all_events.length + +# Send multiple CONNECTED messages +FOR i IN [1, 2, 3]: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + (i + 1), + connectionKey: "connection-key-" + (i + 1), + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + (i + 1), + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + WAIT(50) +``` + +### Assertions + +```pseudo +# Exactly 3 UPDATE events were added (one per subsequent CONNECTED message) +new_events = all_events[initial_event_count:] +ASSERT new_events.length == 3 + +# All new events are UPDATE events, not CONNECTED state events +FOR EACH event IN new_events: + ASSERT event.type == "update" + ASSERT event.change.previous == ConnectionState.connected + ASSERT event.change.current == ConnectionState.connected + +# No additional CONNECTED state events were emitted +connected_state_events = FILTER all_events WHERE event.type == "state" + AND event.state == ConnectionState.connected +ASSERT connected_state_events.length == 1 # Only the initial one +``` diff --git a/uts/realtime/unit/connection/when_state_test.md b/uts/realtime/unit/connection/when_state_test.md new file mode 100644 index 000000000..61c06fe06 --- /dev/null +++ b/uts/realtime/unit/connection/when_state_test.md @@ -0,0 +1,480 @@ +# Connection whenState Tests (RTN26) + +Spec points: `RTN26`, `RTN26a`, `RTN26b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN26a - whenState calls listener immediately if already in state + +**Spec requirement:** If the connection is already in the given state, calls the listener with a null argument. + +Tests that whenState invokes callback immediately when the connection is already in the target state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Now call whenState for the current state +callback_invoked = false +callback_arg = undefined + +client.connection.whenState(ConnectionState.connected, (change) => { + callback_invoked = true + callback_arg = change +}) + +# Callback should be invoked synchronously or very quickly +WAIT(50) +``` + +### Assertions + +```pseudo +# Callback was invoked immediately +ASSERT callback_invoked == true + +# Callback was invoked with null argument (not a StateChange object) +ASSERT callback_arg IS null +``` + +--- + +## RTN26b - whenState waits for state if not already in it + +**Spec requirement:** Else, calls #once with the given state and listener. + +Tests that whenState waits for state transition when not currently in the target state. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Connection is in INITIALIZED state +ASSERT client.connection.state == ConnectionState.initialized + +# Set up whenState before connecting +callback_invoked = false +callback_arg = undefined + +client.connection.whenState(ConnectionState.connected, (change) => { + callback_invoked = true + callback_arg = change +}) + +# Callback should not be invoked yet +ASSERT callback_invoked == false + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Give callback a moment to execute +WAIT(50) +``` + +### Assertions + +```pseudo +# Callback was invoked after state transition +ASSERT callback_invoked == true + +# Callback was invoked with a ConnectionStateChange object (not null) +ASSERT callback_arg IS NOT null +ASSERT callback_arg.previous IN [ConnectionState.initialized, ConnectionState.connecting] +ASSERT callback_arg.current == ConnectionState.connected +``` + +--- + +## RTN26b - whenState only fires once + +**Spec requirement:** whenState uses #once, meaning it should only fire once, not on every subsequent occurrence of the state. + +Tests that whenState callback is invoked only once even if state is entered multiple times. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt: connect then disconnect + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + ELSE: + # Second attempt: connect again + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 100, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +# Set up whenState listener +callback_count = 0 + +client.connection.whenState(ConnectionState.connected, (change) => { + callback_count++ +}) + +# Start connection +client.connect() + +# Wait for first CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +WAIT(50) + +# Verify callback was invoked once +ASSERT callback_count == 1 + +# Force a disconnection +mock_ws.active_connection.close() + +# Wait for DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 2 seconds + +# Advance time to trigger reconnection +ADVANCE_TIME(150) + +# Wait for second CONNECTED +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +WAIT(50) +``` + +### Assertions + +```pseudo +# Callback was still only invoked once (not again on reconnection) +ASSERT callback_count == 1 +``` + +--- + +## RTN26a - Multiple whenState calls + +**Spec requirement:** Multiple calls to whenState should each be handled independently. + +Tests that multiple whenState listeners can be registered and each behaves correctly. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Set up multiple whenState listeners before connecting +callback1_invoked = false +callback2_invoked = false +callback3_invoked = false + +client.connection.whenState(ConnectionState.connected, (change) => { + callback1_invoked = true +}) + +client.connection.whenState(ConnectionState.connected, (change) => { + callback2_invoked = true +}) + +client.connection.whenState(ConnectionState.connecting, (change) => { + callback3_invoked = true +}) + +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +WAIT(50) +``` + +### Assertions + +```pseudo +# All whenState callbacks were invoked +ASSERT callback1_invoked == true +ASSERT callback2_invoked == true +ASSERT callback3_invoked == true +``` + +--- + +## RTN26a - whenState with already-passed state + +**Spec requirement:** whenState should invoke immediately with null if already in the target state. + +Tests that whenState for a state that was passed but is no longer current does NOT fire immediately. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds + +# Now call whenState for a past state (CONNECTING) +callback_invoked = false + +client.connection.whenState(ConnectionState.connecting, (change) => { + callback_invoked = true +}) + +# Wait to see if callback is invoked +WAIT(200) +``` + +### Assertions + +```pseudo +# Callback should NOT be invoked (we're not in CONNECTING state anymore) +ASSERT callback_invoked == false + +# This demonstrates whenState checks current state, not historical states +``` + +--- + +## RTN26 - whenState with different states + +**Spec requirement:** whenState should work correctly for all connection states. + +Tests that whenState functions correctly across different state transitions. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Connection attempt fails + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Set up whenState listeners for various states +initialized_fired = false +connecting_fired = false +disconnected_fired = false + +client.connection.whenState(ConnectionState.initialized, (change) => { + initialized_fired = true +}) + +client.connection.whenState(ConnectionState.connecting, (change) => { + connecting_fired = true +}) + +client.connection.whenState(ConnectionState.disconnected, (change) => { + disconnected_fired = true +}) + +# Initially in INITIALIZED +WAIT(50) + +# Should fire immediately for current state +ASSERT initialized_fired == true +ASSERT connecting_fired == false +ASSERT disconnected_fired == false + +# Start connection +client.connect() + +# Wait for DISCONNECTED (connection will fail) +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds + +WAIT(50) +``` + +### Assertions + +```pseudo +# All states were reached and callbacks invoked +ASSERT initialized_fired == true +ASSERT connecting_fired == true +ASSERT disconnected_fired == true +``` + +--- + +## Implementation Notes + +The `whenState` function is a convenience utility that: + +1. **Immediate invocation**: If `connection.state == targetState`, invoke callback with `null` immediately +2. **Deferred invocation**: Otherwise, it's equivalent to `connection.once(targetState, callback)` +3. **One-time only**: Each `whenState` call fires at most once +4. **Multiple calls**: Multiple `whenState` calls with same state are independent +5. **Return value**: Some implementations may return a way to unregister the listener (implementation-specific) + +Implementations may differ in: +- Whether immediate invocation is synchronous or scheduled for next tick +- Whether a cleanup/unregister function is returned +- Exact behavior with edge cases like rapid state changes From aa17d10fdb24f6b1869df9f40a189e8b8b5c89df Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:40 +0100 Subject: [PATCH 02/32] Fix connection test specs to close WebSocket transport when necessary Ensure mock WebSocket connections are properly closed in connection failure and open-failure test specs to prevent resource leaks in tests. --- .../integration/connection_lifecycle_test.md | 2 +- .../connection/connection_failures_test.md | 20 +++++++++---------- .../connection_open_failures_test.md | 12 +++++------ .../unit/connection/error_reason_test.md | 8 ++++---- .../unit/connection/fallback_hosts_test.md | 4 ++-- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/uts/realtime/integration/connection_lifecycle_test.md b/uts/realtime/integration/connection_lifecycle_test.md index ed34b6ad2..42cccf332 100644 --- a/uts/realtime/integration/connection_lifecycle_test.md +++ b/uts/realtime/integration/connection_lifecycle_test.md @@ -1,6 +1,6 @@ # Realtime Connection Lifecycle Integration Tests -Spec points: `RTN4b`, `RTN4c`, `RTN11`, `RTN12`, `RTN21` +Spec points: `RTN4b`, `RTN4c`, `RTN11`, `RTN12`, `RTN12a`, `RTN21` ## Test Type Integration test against Ably Sandbox endpoint diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index 62d6ca573..ee1bf7f77 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -54,8 +54,8 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Get reference to the WebSocket connection ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection -# Server sends DISCONNECTED with token error -ws_connection.send_to_client(ProtocolMessage( +# Server sends DISCONNECTED with token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( action: DISCONNECTED, error: ErrorInfo( code: 40142, @@ -165,8 +165,8 @@ first_connection_key = client.connection.key # Get WebSocket connection ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection -# Server sends DISCONNECTED with token error -ws_connection.send_to_client(ProtocolMessage( +# Server sends DISCONNECTED with token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( action: DISCONNECTED, error: ErrorInfo( code: 40142, @@ -262,8 +262,8 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Get WebSocket connection ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection -# Server sends DISCONNECTED with token error -ws_connection.send_to_client(ProtocolMessage( +# Server sends DISCONNECTED with token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( action: DISCONNECTED, error: ErrorInfo( code: 40142, @@ -355,8 +355,8 @@ original_connection_id = client.connection.id # Get WebSocket connection ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection -# Server sends DISCONNECTED with non-token error -ws_connection.send_to_client(ProtocolMessage( +# Server sends DISCONNECTED with non-token error and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( action: DISCONNECTED, error: ErrorInfo( code: 80003, @@ -433,8 +433,8 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Get WebSocket connection ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection -# Server sends ERROR with empty channel (connection-level error) -ws_connection.send_to_client(ProtocolMessage( +# Server sends ERROR with empty channel (connection-level error) and closes connection +ws_connection.send_to_client_and_close(ProtocolMessage( action: ERROR, channel: null, # Empty = connection-level error error: ErrorInfo( diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md index dc282c275..7656fb351 100644 --- a/uts/realtime/unit/connection/connection_open_failures_test.md +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -25,8 +25,8 @@ mock_ws = MockWebSocket( # WebSocket connects successfully conn.respond_with_success() - # But server immediately sends ERROR for invalid key - conn.send_to_client(ProtocolMessage( + # But server immediately sends ERROR for invalid key and closes connection + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, error: ErrorInfo( code: 40005, @@ -112,8 +112,8 @@ mock_ws = MockWebSocket( conn.respond_with_success() IF connection_attempt_count == 1: - # First attempt: token error - conn.send_to_client(ProtocolMessage( + # First attempt: token error, close connection + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, error: ErrorInfo( code: 40142, @@ -181,7 +181,7 @@ Tests that non-renewable token errors cause disconnection. mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, error: ErrorInfo( code: 40142, @@ -511,7 +511,7 @@ Tests that fatal protocol errors cause FAILED state. mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, channel: null, # Empty channel = connection-level error error: ErrorInfo( diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md index 05222962d..8ed20bd3c 100644 --- a/uts/realtime/unit/connection/error_reason_test.md +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -23,7 +23,7 @@ Tests that errorReason is populated correctly across various error scenarios. mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, error: ErrorInfo( code: 40005, @@ -184,7 +184,7 @@ Tests that errorReason captures token-related errors. mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, error: ErrorInfo( code: 40142, @@ -323,7 +323,7 @@ Tests that connection-level protocol errors populate errorReason. mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, channel: null, # Empty channel = connection-level error error: ErrorInfo( @@ -377,7 +377,7 @@ Tests that state change events include error information. mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: ERROR, error: ErrorInfo( code: 40003, diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md index 6f6c21479..8323ddeca 100644 --- a/uts/realtime/unit/connection/fallback_hosts_test.md +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -195,9 +195,9 @@ mock_ws = MockWebSocket( connection_attempts.push(conn.url.host) IF connection_attempts.length == 1: - # Primary domain: connect then send DISCONNECTED with 503 + # Primary domain: connect then send DISCONNECTED with 503 and close conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.send_to_client_and_close(ProtocolMessage( action: DISCONNECTED, error: ErrorInfo( code: 50003, From 0cf678c46eeb7130182ad936101b566d4278a71c Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:40 +0100 Subject: [PATCH 03/32] Refactor realtime test specs: extract mock WebSocket helper and add skill Separate the mock WebSocket specification into its own file for reuse across test specs, and add a skill document for writing test specs. --- uts/.claude/skills/write-test-spec.md | 754 ++++++++++++++++++ .../unit/auth/connection_auth_test.md | 359 +++++++++ uts/realtime/unit/client/realtime_client.md | 654 +++++++++++++++ .../connection/connection_failures_test.md | 2 +- .../connection_open_failures_test.md | 2 +- .../unit/connection/error_reason_test.md | 2 +- .../unit/connection/fallback_hosts_test.md | 4 +- .../unit/connection/heartbeat_test.md | 2 +- .../unit/connection/update_events_test.md | 2 +- .../unit/connection/when_state_test.md | 2 +- uts/realtime/unit/helpers/mock_websocket.md | 254 ++++++ 11 files changed, 2029 insertions(+), 8 deletions(-) create mode 100644 uts/.claude/skills/write-test-spec.md create mode 100644 uts/realtime/unit/auth/connection_auth_test.md create mode 100644 uts/realtime/unit/client/realtime_client.md create mode 100644 uts/realtime/unit/helpers/mock_websocket.md diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md new file mode 100644 index 000000000..2fecd7df2 --- /dev/null +++ b/uts/.claude/skills/write-test-spec.md @@ -0,0 +1,754 @@ +--- +skill: write-test-spec +description: Guidelines for writing Ably SDK test specifications with modern mock infrastructure patterns +tags: [testing, specifications, ably] +--- + +# Writing Ably SDK Test Specifications + +This skill provides comprehensive guidance for writing portable test specifications for Ably SDK implementations. + +## Test Types + +### Unit Tests (Mocked HTTP/WebSocket) +- Use mock HTTP client to verify request formation and response parsing +- Use mock WebSocket client for Realtime connection tests +- Test client-side validation and error handling +- Token strings are opaque - any arbitrary string works for unit tests +- No network calls - fast and deterministic + +### Integration Tests (Ably Sandbox) +- Run against `https://sandbox.realtime.ably-nonprod.net` +- Provision apps via `POST /apps` with body from `ably-common/test-resources/test-app-setup.json` +- Use `endpoint: "sandbox"` in ClientOptions + +## Mock Infrastructure Patterns + +### HTTP Mock Infrastructure + +**Reference the canonical specification:** +```markdown +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. +``` + +**Key interfaces:** +```pseudo +interface MockHttpClient: + await_connection_attempt(timeout?: Duration): Future + await_request(timeout?: Duration): Future + reset() + +interface PendingConnection: + host: String + port: Int + tls: Boolean + respond_with_success() + respond_with_refused() + respond_with_timeout() + respond_with_dns_error() + +interface PendingRequest: + url: URL + method: String + headers: Map + body: Bytes + respond_with(status: Int, body: Any, headers?: Map) + respond_with_delay(delay: Duration, status: Int, body: Any) + respond_with_timeout() +``` + +### Handler-Based Pattern (Simple Tests) + +Use for tests with predetermined responses: + +```pseudo +captured_request = null + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_request = req + req.respond_with(200, {"time": 1234567890000}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Handler-Based with State (Complex Tests) + +Use for tests needing different responses based on request count or conditions: + +```pseudo +request_count = 0 +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + IF request_count == 1: + req.respond_with(401, {"error": {"code": 40142}}) + ELSE: + req.respond_with(200, {"result": "success"}) + } +) +install_mock(mock_http) +``` + +### Await-Based Pattern (Advanced Control) + +Use when test needs to coordinate responses with test execution state. + +**Important:** The await pattern has a subtle timing requirement - when awaiting multiple sequential connection attempts, you must set up the await for the next attempt BEFORE responding to the current one: + +```pseudo +# Correct pattern for sequential awaits +first_conn = AWAIT mock_ws.await_connection_attempt() +second_future = mock_ws.await_connection_attempt() # Set up BEFORE responding +first_conn.respond_with_error(...) # This triggers retry +second_conn = AWAIT second_future +``` + +This avoids race conditions where the retry happens before the await is set up. + +### When to Use Each Pattern + +**Handler pattern** (recommended for most tests): +- Response is predetermined based on request count or content +- Simple "first attempt fails, second succeeds" scenarios +- No need to coordinate with external test state +- More universally safe across different language runtimes + +**Await pattern** (for advanced scenarios only): +- Need to inspect connection/request details before deciding how to respond +- Test logic depends on external state not known at setup time +- Complex coordination between multiple async operations + +Example using await pattern: + +```pseudo +mock_http = MockHttpClient() +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "...")) + +# Start operation +request_future = client.time() + +# Wait for and handle connection +connection = AWAIT mock_http.await_connection_attempt() +connection.respond_with_success() + +# Wait for and handle request +request = AWAIT mock_http.await_request() +ASSERT request.headers["X-Ably-Version"] IS NOT null +request.respond_with(200, {"time": 1234567890000}) + +# Complete operation +result = AWAIT request_future +``` + +### WebSocket Mock Infrastructure + +For Realtime tests, reference the WebSocket mock: + +```markdown +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +``` + +**Key interfaces:** +```pseudo +interface MockWebSocket: + events: List # Unified timeline + await_connection_attempt(timeout?: Duration): Future + await_request(timeout?: Duration): Future + send_to_client(message: ProtocolMessage) + send_to_client_and_close(message: ProtocolMessage) # Send then close + simulate_disconnect() # Close without message + reset() + +interface PendingConnection: + host: String + port: Int + tls: Boolean + respond_with_success() + respond_with_refused() + respond_with_timeout() + respond_with_dns_error() +``` + +### WebSocket Connection Closing Semantics + +When simulating server behavior, use the correct method based on the scenario: + +| Scenario | Method | Description | +|----------|--------|-------------| +| Server sends DISCONNECTED | `send_to_client_and_close()` | Server sends message then closes connection | +| Server sends ERROR (connection-level) | `send_to_client_and_close()` | ERROR without channel = fatal, closes connection | +| Server sends ERROR (channel-level) | `send_to_client()` | ERROR with channel = attachment failure, connection stays open | +| Server sends CONNECTED, HEARTBEAT, ACK, MESSAGE | `send_to_client()` | Normal messages, connection stays open | +| Unexpected transport failure | `simulate_disconnect()` | Connection drops without server message | + +**Key rule:** Whenever the server sends DISCONNECTED, or ERROR without a specified channel, it will be accompanied by the server closing the WebSocket connection. An ERROR with a specified channel is an attachment failure and doesn't end the connection. + +```pseudo +# Server-initiated disconnection (e.g., token expired) +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo(code: 40142, message: "Token expired") +)) + +# Connection-level error (fatal) +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 40101, message: "Invalid credentials") +)) + +# Channel attachment error (non-fatal, connection stays open) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: "private-channel", + error: ErrorInfo(code: 40160, message: "Not permitted") +)) + +# Normal message (connection stays open) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" +)) + +# Unexpected disconnect (no message, just closes) +mock_ws.active_connection.simulate_disconnect() +``` + +## Spec Requirement Summaries + +**Every test must include a spec requirement summary immediately after the heading.** + +### Single Spec Format + +```markdown +## RSC7e - X-Ably-Version header + +**Spec requirement:** All REST requests must include the `X-Ably-Version` header with the spec version. + +Tests that all REST requests include the `X-Ably-Version` header. +``` + +### Multiple Specs Format (Use Table) + +```markdown +## RSC7d, RSC7d1, RSC7d2 - Ably-Agent header + +| Spec | Requirement | +|------|-------------| +| RSC7d | All requests must include Ably-Agent header | +| RSC7d1 | Header format: space-separated key/value pairs | +| RSC7d2 | Must include library name and version | + +Tests that all REST requests include the `Ably-Agent` header with correct format. +``` + +## Pseudocode Conventions + +### Type Assertions + +Type assertions verify object types/interfaces. Implementation varies by language: + +- **Strongly typed** (Dart, Swift, Kotlin, TypeScript): Use native type checks +- **Weakly typed** (JavaScript, Python, Ruby): Verify expected methods/properties exist + +```pseudo +# Pseudocode +ASSERT client.connection IS Connection + +# JavaScript - check interface compliance +assert(typeof client.connection.connect === 'function'); +assert(typeof client.connection.close === 'function'); + +# Dart - native type check +expect(client.connection, isA()); +``` + +### State Transitions + +State transitions may be synchronous or asynchronous. Use `AWAIT_STATE`: + +```pseudo +# If already in state, proceed immediately +# Otherwise wait for state change event until condition is met +AWAIT_STATE client.connection.state == ConnectionState.connecting +``` + +This means implementations should: +- Check if condition is already true → proceed +- Otherwise wait for state change events with timeout +- Fail if timeout expires + +## Timer Mocking + +Tests verifying timeout behavior should use timer mocking where practical to avoid slow tests. + +**Approaches (in order of preference):** + +1. **Mock/fake timers** (JavaScript Jest, Python freezegun) + ```pseudo + enable_fake_timers() + request_future = client.time() + ADVANCE_TIME(1000) # Instantly trigger timeout + AWAIT request_future # Should fail with timeout + ``` + +2. **Dependency injection** (Go, Swift, Kotlin) + - Library accepts clock interface in tests + - Test provides controllable implementation + +3. **Short timeouts** (fallback if mocking unavailable) + ```pseudo + client = Rest(options: ClientOptions(httpRequestTimeout: 50)) + ``` + +4. **Actual delays** (last resort) + +Use `ADVANCE_TIME(milliseconds)` in pseudocode to indicate time progression. + +## Sandbox App Management + +Create apps **once** per test run, **explicitly delete** when complete: + +```pseudo +BEFORE ALL TESTS: + app_config = POST https://sandbox.realtime.ably-nonprod.net/apps + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +## Unique Channel Names + +Construct channel names with: +1. **Descriptive part** - test name or spec ID +2. **Random part** - base64-encoded random bytes (e.g., 6 bytes = 48 bits) + +Example: `test-RSL1-publish-${base64(random_bytes(6))}` + +Tests using channels should use uniquely-named channels to avoid: +- Collisions between concurrent tests +- Server-side side-effects from previous test runs +- State leakage between test cases + +## Authentication Testing + +### Do NOT use `time()` for auth testing + +The `/time` endpoint does NOT require authentication (RSC16). Using it for auth tests will give misleading results. + +**Key behaviors of `time()`:** +- Does not send Authorization header, even when client has credentials +- Works over non-TLS connections (RSC18 doesn't apply) +- Does not trigger token acquisition + +**Use `channel.status()` instead** for testing authentication: +```pseudo +# For auth tests, use channel status which requires authentication +status = AWAIT client.channels.get("test").status() + +# Verify auth header was sent +ASSERT request.headers["Authorization"] == "Bearer token" +``` + +### Constructor still requires authentication credentials + +While `time()` doesn't require auth, the **client constructor still requires credentials**. You must provide one of: +- `key` (API key) +- `authCallback` +- `authUrl` +- `token` or `tokenDetails` + +**Wrong - constructor will reject:** +```pseudo +# This fails with 40106 "No authentication method provided" +client = Rest(options: ClientOptions(tls: false)) +``` + +**Correct - provide credentials, but time() won't use them:** +```pseudo +# Constructor accepts credentials, time() doesn't send them +client = Rest(options: ClientOptions(key: "app.key:secret")) +result = AWAIT client.time() +ASSERT "Authorization" NOT IN request.headers # time() doesn't send auth +``` + +### RSC18 only applies to Basic auth configurations + +The RSC18 restriction (no Basic auth over non-TLS) is checked at **client construction time**. The error is thrown immediately when creating a client that would use Basic auth over non-TLS. + +**RSC18 check triggers when:** +- API key is provided AND +- `tls: false` AND +- No `clientId` (which would force token auth) AND +- No `useTokenAuth: true` AND +- No authCallback/authUrl/token + +**Testing RSC18:** +```pseudo +# RSC18 test - Basic auth over HTTP rejected at construction +TRY: + client = Rest(options: ClientOptions(key: "app.key:secret", tls: false)) + FAIL("Expected exception at construction") +CATCH AblyException as e: + ASSERT e.code == 40103 + +# Token auth over HTTP allowed - client can be constructed +client = Rest(options: ClientOptions(token: "token", tls: false)) +status = AWAIT client.channels.get("test").status() # Works fine +ASSERT request.url.scheme == "http" +ASSERT request.headers["Authorization"] == "Bearer token" +``` + +**Why `time()` works over non-TLS with any client:** +Since `time()` uses `authenticated: false`, it never sends credentials, so RSC18 doesn't apply to it. A client configured for Basic auth can still call `time()` - it just can't make authenticated requests. + +## Token Testing + +Test with **both** token formats: +1. **JWTs** (primary) - Use a third-party JWT library for integration tests +2. **Ably native tokens** - Obtained via `requestToken()` + +For unit tests, any string works as a token value since tokens are opaque to the library. + +## Avoiding Flaky Tests + +**Never use fixed WAITs.** Use polling instead: + +```pseudo +# Bad - flaky +WAIT 5 seconds +ASSERT condition + +# Good - reliable +poll_until( + condition, + interval: 500ms, + timeout: 10s +) +``` + +## Test Structure + +Each test should have three sections: + +### Setup +```pseudo +request_count = 0 +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + request_count++ + req.respond_with(200, {...}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.operation() +``` + +### Assertions +```pseudo +ASSERT result.field == expected +ASSERT request_count == 1 +ASSERT captured_requests[0].headers["Authorization"] == "Bearer token" +``` + +## Common Mock Patterns + +### Capturing All Requests + +```pseudo +captured_requests = [] + +onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {...}) +} +``` + +### Different Responses by Count + +```pseudo +request_count = 0 + +onRequest: (req) => { + request_count++ + IF request_count == 1: + req.respond_with(500, {...}) + ELSE: + req.respond_with(200, {...}) +} +``` + +### Different Responses by URL + +```pseudo +onRequest: (req) => { + IF req.url.path CONTAINS "/time": + req.respond_with(200, {"time": ...}) + ELSE IF req.url.path CONTAINS "/channels": + req.respond_with(200, [...]) +} +``` + +### Connection-Level Failures + +```pseudo +connection_count = 0 + +onConnectionAttempt: (conn) => { + connection_count++ + IF connection_count == 1: + conn.respond_with_refused() # Or timeout, dns_error + ELSE: + conn.respond_with_success() +} +``` + +## Common Assertion Patterns + +```pseudo +ASSERT value == expected +ASSERT value IS Type +ASSERT value IN list +ASSERT value matches pattern "regex" +ASSERT "key" IN object +ASSERT "key" NOT IN object +ASSERT value STARTS WITH "prefix" +ASSERT value CONTAINS "substring" +``` + +## Error Testing Pattern + +```pseudo +TRY: + AWAIT operation_that_fails() + FAIL("Expected exception") +CATCH AblyException as e: + ASSERT e.code == 40160 + ASSERT e.statusCode == 401 +``` + +## Key Spec Points to Remember + +| Spec | Behavior | +|------|----------| +| RSA4b | key + clientId triggers token auth (not basic auth) | +| RSA4b4 | Token renewal on 40140-40149 errors | +| RSA8d | authCallback returns TokenDetails, TokenRequest, or JWT string | +| RSC16 | time() does NOT require authentication - doesn't send auth headers even with credentials | +| RSC18 | Basic auth requires TLS - only applies to authenticated operations (not time()) | +| RSC15l | Fallback on: host unreachable, timeout, HTTP 5xx | +| 40103 | Cannot use Basic auth over non-TLS | +| 40106 | No authentication method configured (constructor rejects) | +| 40171 | Token expired with no means of renewal | +| 40160 | Not permitted (capability error) | +| 40012 | Incompatible clientId | +| 40142 | Token expired | +| 40140 | Token error | + +## File Organization + +``` +uts/test/ +├── rest/ +│ ├── unit/ +│ │ ├── helpers/ +│ │ │ └── mock_http.md # Mock HTTP infrastructure spec +│ │ ├── auth/ +│ │ │ ├── auth_callback.md # RSA8c, RSA8d +│ │ │ ├── auth_scheme.md # RSA1-4, RSA4b +│ │ │ ├── authorize.md # RSA10 +│ │ │ ├── token_renewal.md # RSA4b4, RSA14 +│ │ │ └── client_id.md # RSA7, RSC17 +│ │ ├── channel/ +│ │ │ ├── publish.md # RSL1 +│ │ │ ├── history.md # RSL2 +│ │ │ └── idempotency.md # RSL1k +│ │ ├── rest_client.md # RSC7, RSC8, RSC13, RSC18 +│ │ ├── fallback.md # RSC15, REC1, REC2 +│ │ ├── time.md # RSC16 +│ │ ├── stats.md # RSC6 +│ │ ├── request.md # RSC19 +│ │ ├── batch_publish.md # RSC22, BSP, BPR, BPF +│ │ ├── presence/ +│ │ │ └── rest_presence.md # RSP1, RSP3, RSP4 +│ │ ├── encoding/ +│ │ │ └── message_encoding.md # RSL4, RSL5, RSL6 +│ │ └── types/ +│ │ ├── message_types.md # TM2, TM3, TM4 +│ │ ├── error_types.md # TI1-5 +│ │ ├── token_types.md # TD1-5, TK1-6, TE1-6 +│ │ ├── options_types.md # TO3, AO2 +│ │ └── paginated_result.md # TG1-5 +│ └── integration/ +│ ├── auth.md +│ ├── publish.md +│ ├── history.md +│ ├── presence.md +│ ├── pagination.md +│ └── time_stats.md +├── realtime/ +│ ├── unit/ +│ │ ├── helpers/ +│ │ │ └── mock_websocket.md # Mock WebSocket infrastructure spec +│ │ ├── client/ +│ │ │ ├── realtime_client.md # RTC1, RTC2, RTC15, RTC16 +│ │ │ └── client_options.md # TO3 (Realtime-specific) +│ │ └── connection/ +│ │ ├── connection_failures_test.md +│ │ ├── connection_open_failures_test.md +│ │ └── ... +│ └── integration/ +│ └── (future Realtime integration tests) +└── README.md +``` + +## Writing Tips + +1. **Reference spec points** in test names and file headers +2. **Add spec requirement summaries** at the start of each test +3. **One concept per test** - don't combine unrelated assertions +4. **Describe what you're testing** - not implementation details +5. **Include error codes** when testing error conditions +6. **Mock responses realistically** - include all fields the real API returns +7. **Test both success and failure paths** +8. **Verify request formation** - check headers, path, body, query params +9. **Consider edge cases** - empty results, pagination boundaries, expired tokens +10. **Use handler pattern for simple tests**, await pattern for complex coordination +11. **Distinguish connection-level vs request-level failures** +12. **Use unique channel names** to avoid test interference + +## Example Test Spec (Modern Pattern) + +```markdown +# Feature Name Tests + +Spec points: `RSA4`, `RSA8` + +## Test Type +Unit test with mocked HTTP client + +## Mock HTTP Infrastructure + +See `uts/test/rest/unit/helpers/mock_http.md` for the full Mock HTTP Infrastructure specification. + +--- + +## RSA4 - Descriptive test name + +**Spec requirement:** Brief description of what the spec requires. + +Tests that [specific behavior being tested]. + +### Setup +```pseudo +captured_requests = [] + +mock_http = MockHttpClient( + onConnectionAttempt: (conn) => conn.respond_with_success(), + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, {"result": "success"}) + } +) +install_mock(mock_http) + +client = Rest(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +result = AWAIT client.operation() +``` + +### Assertions +```pseudo +ASSERT result.field == "success" +ASSERT captured_requests[0].method == "GET" +ASSERT captured_requests[0].headers["Authorization"] IS NOT null +``` +``` + +## Pattern Decision Tree + +**Choose handler pattern when:** +- Response is predetermined +- Simple pass-through scenarios +- No need to inspect request before responding + +**Choose await pattern when:** +- Need to respond based on test execution state +- Need to coordinate timing with other operations +- Complex scenarios requiring request inspection before response +- Testing connection-level failures separately from request handling + +## Common Mistakes to Avoid + +1. ❌ Using `mock_http.queue_response()` (old pattern) + ✅ Use `onRequest: (req) => req.respond_with(...)` + +2. ❌ Referencing `mock_http.captured_requests` + ✅ Use local `captured_requests` array + +3. ❌ Referencing `mock_http.request_count` + ✅ Use local `request_count` variable + +4. ❌ Not installing mock: Missing `install_mock(mock_http)` + ✅ Always call `install_mock(mock_http)` after creating mock + +5. ❌ Passing mock to client: `Rest(..., httpClient: mock_http)` + ✅ Mock is installed globally via `install_mock()` + +6. ❌ Missing spec requirement summary + ✅ Every test must have `**Spec requirement:**` or table + +7. ❌ Using fixed WAITs for async operations + ✅ Use polling with timeout or `AWAIT_STATE` + +8. ❌ Not using unique channel names + ✅ Generate unique names with random component + +9. ❌ Synchronous state assertions: `ASSERT state == connecting` + ✅ Use `AWAIT_STATE state == connecting` + +10. ❌ Missing connection handler: Only defining `onRequest` + ✅ Always include `onConnectionAttempt: (conn) => conn.respond_with_success()` + +11. ❌ Using `send_to_client()` for DISCONNECTED or connection-level ERROR + ✅ Use `send_to_client_and_close()` - server closes connection after these messages + +12. ❌ Using `send_to_client_and_close()` for channel-level ERROR + ✅ Use `send_to_client()` - ERROR with channel doesn't close connection + +13. ❌ Using `time()` to test authentication behavior + ✅ Use `channel.status()` - time() doesn't require or send auth + +14. ❌ Creating client without credentials for time() tests: `ClientOptions(tls: false)` + ✅ Constructor requires credentials - use `ClientOptions(key: "...", tls: false, useTokenAuth: true)` diff --git a/uts/realtime/unit/auth/connection_auth_test.md b/uts/realtime/unit/auth/connection_auth_test.md new file mode 100644 index 000000000..a0468b83a --- /dev/null +++ b/uts/realtime/unit/auth/connection_auth_test.md @@ -0,0 +1,359 @@ +# Realtime Connection Authentication Tests + +Spec points: `RTN2e`, `RTN27b`, `RSA4`, `RSA8d`, `RSA12a` + +## Test Type +Unit test with mocked WebSocket client and authCallback + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify realtime-specific authentication behavior for establishing and maintaining WebSocket connections. While general auth behavior (RSA1-17) is tested in `rest/unit/auth/`, these tests focus on how token authentication integrates with the realtime connection lifecycle. + +Key behaviors tested: +- Token acquisition occurs **before** WebSocket connection attempts (RTN2e, RTN27b) +- Token is included in WebSocket URL query parameters (RTN2e) +- Token caching and expiry handling for connection attempts +- authCallback integration with connection state machine + +--- + +## RTN2e/RTN27b - Token obtained before WebSocket connection + +**Spec requirement:** When `authCallback` is configured but no token is provided, the library must obtain a token via the callback **before** opening the WebSocket connection. The token is then included in the WebSocket URL as the `accessToken` query parameter. + +This is implied by: +- RTN2e: "Depending on the authentication scheme, either `accessToken` contains the token string, or `key` contains the API key" +- RTN27b: "CONNECTING - the state whenever the library is actively attempting to connect to the server (whether trying to obtain a token, trying to open a transport, or waiting for a CONNECTED event)" + +Tests that when `authCallback` is configured without an existing token, the library: +1. Transitions to CONNECTING state +2. Invokes the authCallback to obtain a token +3. Opens WebSocket connection with the token in the URL +4. Does NOT make a connection attempt before obtaining the token + +### Setup + +```pseudo +callback_invoked = false +callback_invoked_time = null +connection_attempt_time = null +captured_ws_url = null + +auth_callback = FUNCTION(params): + callback_invoked = true + callback_invoked_time = current_time() + RETURN TokenDetails( + token: "callback-provided-token", + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_time = current_time() + captured_ws_url = conn.url + + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Client with authCallback but NO existing token +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# authCallback was invoked +ASSERT callback_invoked == true + +# authCallback was invoked BEFORE WebSocket connection attempt +ASSERT callback_invoked_time < connection_attempt_time + +# WebSocket URL contains the token from authCallback +ASSERT captured_ws_url.queryParameters["accessToken"] == "callback-provided-token" + +# WebSocket URL does NOT contain a key parameter (using token auth, not basic auth) +ASSERT captured_ws_url.queryParameters["key"] IS null + +# Connection succeeded +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTN2e/RTN27b - authCallback error prevents connection attempt + +**Spec requirement:** If `authCallback` fails during the initial token acquisition, the library should NOT attempt to open a WebSocket connection. + +Tests that authCallback errors are handled before any connection attempt is made. + +### Setup + +```pseudo +connection_attempted = false + +auth_callback = FUNCTION(params): + THROW Error("Auth callback failed") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for DISCONNECTED or FAILED state +AWAIT_STATE client.connection.state IN [ConnectionState.disconnected, ConnectionState.failed] + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# No WebSocket connection was attempted +ASSERT connection_attempted == false + +# Error reason is set +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.statusCode == 401 + OR client.connection.errorReason.code == 40170 +``` + +--- + +## RTN2e - authCallback TokenParams include clientId + +**Spec requirement:** When invoking `authCallback`, the library passes `TokenParams` that include any configured `clientId`. + +Tests that clientId is passed to authCallback via TokenParams (per RSA12a). + +### Setup + +```pseudo +received_params = null + +auth_callback = FUNCTION(params): + received_params = params + RETURN TokenDetails( + token: "token-for-client", + expires: now() + 3600000, + clientId: "my-client-id" + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + clientId: "my-client-id", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# Start connection +client.connect() + +# Wait for CONNECTED state +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions + +```pseudo +# authCallback received TokenParams with clientId +ASSERT received_params IS NOT null +ASSERT received_params.clientId == "my-client-id" +``` + +--- + +## RTN2e - Multiple connections reuse valid token + +**Spec requirement:** If a valid (non-expired) token exists from a previous authCallback invocation, it should be reused for subsequent connection attempts without invoking authCallback again. + +Tests that valid tokens are cached and reused. + +### Setup + +```pseudo +callback_count = 0 + +auth_callback = FUNCTION(params): + callback_count++ + RETURN TokenDetails( + token: "reusable-token", + expires: now() + 3600000 # Valid for 1 hour + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# First connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Disconnect +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Second connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# authCallback was only invoked once (token was reused) +ASSERT callback_count == 1 +``` + +--- + +## RTN2e - Expired token triggers new authCallback invocation + +**Spec requirement:** If the cached token has expired, `authCallback` must be invoked again to obtain a fresh token before connecting. + +Tests that expired tokens trigger re-authentication. + +### Setup + +```pseudo +callback_count = 0 + +auth_callback = FUNCTION(params): + callback_count++ + RETURN TokenDetails( + token: "token-" + callback_count, + expires: now() + 100 # Expires in 100ms + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +# First connection +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Disconnect +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Wait for token to expire +WAIT 200ms + +# Second connection (token expired, should get new one) +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# authCallback was invoked twice (once per connection due to expiry) +ASSERT callback_count == 2 +``` + +--- + +## Notes + +These tests verify the **pre-connection** token acquisition flow. For token **renewal** after connection failures (e.g., 401 errors from server), see: +- `../connection/connection_open_failures_test.md` (RTN14b) +- `../connection/connection_failures_test.md` (RTN15h2) diff --git a/uts/realtime/unit/client/realtime_client.md b/uts/realtime/unit/client/realtime_client.md new file mode 100644 index 000000000..18f7c247f --- /dev/null +++ b/uts/realtime/unit/client/realtime_client.md @@ -0,0 +1,654 @@ +# Realtime Client Tests + +Spec points: `RTC1`, `RTC1a`, `RTC1b`, `RTC1c`, `RTC1f`, `RTC2`, `RTC3`, `RTC4`, `RTC12`, `RTC15`, `RTC16`, `RTC17` + +## Test Type +Unit test with mocked WebSocket connection + +## Pseudocode Conventions + +### Type Assertions + +Type assertions in pseudocode (e.g., `ASSERT client.connection IS Connection`) verify that an object has the expected type or interface. Implementation varies by language: + +- **Strongly typed languages** (Dart, Swift, Kotlin, TypeScript): Use native type checks or casting verification +- **Weakly typed languages** (JavaScript, Python, Ruby): Verify the object has the expected methods/properties instead of checking type directly + +**Example:** +```pseudo +# Pseudocode +ASSERT client.connection IS Connection + +# JavaScript implementation +assert(typeof client.connection.connect === 'function'); +assert(typeof client.connection.close === 'function'); +assert(typeof client.connection.state === 'string'); + +# Dart implementation +expect(client.connection, isA()); +``` + +For weakly typed languages, verify the object behaves as the expected interface rather than checking its type name. + +### State Transitions + +State transitions may be synchronous or asynchronous depending on the implementation. Use `AWAIT_STATE` to indicate waiting for a state to reach an expected value: + +```pseudo +# Pseudocode +AWAIT_STATE client.connection.state == ConnectionState.connecting +``` + +This means: if the state is already `connecting`, proceed immediately; otherwise, wait for a state change event until it reaches `connecting`. Implementations should use appropriate timeout values to prevent tests hanging indefinitely. + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTC12 - Constructor String Argument Detection + +**Spec requirement:** The Realtime constructor must accept a string argument and detect whether it's an API key (contains `:`) or token (no `:`), matching REST client behavior. + +The Realtime client has the same constructors as the REST client. + +**See:** `uts/test/realtime/unit/client/client_options.md` - RSC1, RSC1a, RSC1c + +The same test cases apply: +- API key string (`"appId.keyId:keySecret"`) → Basic auth +- Token string (no `:` delimiter) → Token auth +- Empty string → Error + +--- + +## RTC12 - Invalid Arguments Error + +**Spec requirement:** Error code 40106 must be raised when no valid credentials are provided, matching REST client behavior. + +The Realtime client has the same error handling as the REST client for invalid credentials. + +**See:** `uts/test/realtime/unit/client/client_options.md` - RSC1b + +Error code 40106 should be raised when no valid credentials are provided. + +--- + +## RTC2 - Connection Attribute + +**Spec requirement:** The Realtime client must expose a `connection` property that provides access to the Connection object. + +Tests that `RealtimeClient#connection` provides access to the underlying Connection object. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +# Create client with autoConnect: false to avoid immediate connection +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.connection IS NOT null +ASSERT client.connection IS Connection +ASSERT client.connection.state == ConnectionState.initialized +``` + +--- + +## RTC3 - Channels Attribute + +**Spec requirement:** The Realtime client must expose a `channels` property that provides access to the Channels collection. + +Tests that `RealtimeClient#channels` provides access to the Channels collection. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.channels IS NOT null +ASSERT client.channels IS Channels + +# Should be able to get/create channels +channel = client.channels.get("test-channel") +ASSERT channel IS RealtimeChannel +ASSERT channel.name == "test-channel" +``` + +--- + +## RTC4 - Auth Attribute + +**Spec requirement:** The Realtime client must expose an `auth` property that provides access to the Auth object. + +Tests that `RealtimeClient#auth` provides access to the Auth object. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.auth IS NOT null +ASSERT client.auth IS Auth +``` + +--- + +## RTC17 - ClientId Attribute + +**Spec requirement:** The Realtime client must expose a `clientId` property that returns the clientId from the auth object. + +Tests that `RealtimeClient#clientId` returns the clientId from the auth object. + +### RTC17a - Returns auth clientId + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "explicit-client-id", + autoConnect: false +)) + +ASSERT client.clientId == "explicit-client-id" +ASSERT client.clientId == client.auth.clientId +``` + +--- + +## RTC1a - echoMessages Option + +**Spec requirement:** The `echoMessages` option (default true) controls whether messages published by this client are echoed back on subscriptions. Sent as `echo` query parameter. + +Tests the `echoMessages` option which controls whether messages from this connection are echoed back. + +### RTC1a_1 - echoMessages defaults to true + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["echo"] == "true" +``` + +### RTC1a_2 - echoMessages set to false + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + echoMessages: false +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["echo"] == "false" +``` + +--- + +## RTC1b - autoConnect Option + +**Spec requirement:** The `autoConnect` option (default true) controls whether the client automatically connects on instantiation or waits for explicit `connect()` call. + +Tests the `autoConnect` option which controls automatic connection on instantiation. + +### RTC1b_1 - autoConnect defaults to true + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Should immediately attempt connection (state may be connecting or already connected) +AWAIT_STATE client.connection.state == ConnectionState.connecting OR + client.connection.state == ConnectionState.connected + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) + +ASSERT mock_ws.connect_attempts.length >= 1 +``` + +### RTC1b_2 - autoConnect set to false + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Should NOT attempt connection +ASSERT client.connection.state == ConnectionState.initialized +ASSERT mock_ws.connect_attempts.length == 0 + +# Should remain in initialized state until explicit connect +WAIT 100ms +ASSERT client.connection.state == ConnectionState.initialized +ASSERT mock_ws.connect_attempts.length == 0 +``` + +### RTC1b_3 - Explicit connect after autoConnect false + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +ASSERT client.connection.state == ConnectionState.initialized + +# Explicit connect +client.connection.connect() + +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) + +ASSERT mock_ws.events.filter(type: CONNECTION_ATTEMPT).length == 1 +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +--- + +## RTC1c - recover Option + +**Spec requirement:** The `recover` option accepts a recovery key to resume a previous connection's state. The connection key is sent as the `recover` query parameter and is used only for the initial connection attempt. + +Tests the `recover` option for connection state recovery. + +### RTC1c_1 - recover string sent in connection request + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +recovery_key = encode_recovery_key({ + connectionKey: "previous-connection-key", + msgSerial: 5, + channelSerials: { "channel1": "serial1" } +}) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["recover"] == "previous-connection-key" +``` + +### RTC1c_2 - recover option cleared after connection attempt (RTN16k) + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +recovery_key = encode_recovery_key({ + connectionKey: "previous-connection-key", + msgSerial: 5, + channelSerials: {} +}) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: recovery_key +)) + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) + +# Simulate disconnect and reconnect +mock_ws.simulate_disconnect() +AWAIT client.connection.once(ConnectionEvent.disconnected) + +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +AWAIT client.connection.once(ConnectionEvent.connected) + +# Second connection should NOT include recover parameter +# (RTN16k - recover is used only for initial connection) +second_connect_url = mock_ws.connect_attempts[1].url +ASSERT "recover" NOT IN second_connect_url.query_params +``` + +### RTC1c_3 - Invalid recovery key handled gracefully + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + recover: "invalid-not-a-valid-recovery-key" +)) + +# Wait for connection attempt (recovery key decoding failure is logged, not fatal) +pending = AWAIT mock_ws.await_connection_attempt() + +# Connection should proceed without recover parameter +ASSERT "recover" NOT IN pending.url.query_params +``` + +--- + +## RTC1f - transportParams Option + +| Spec | Requirement | +|------|-------------| +| RTC1f | Custom query parameters can be added via `transportParams` | +| RTC1f1 | User-specified transportParams override library defaults | + +Tests the `transportParams` option for additional WebSocket query parameters. + +### RTC1f_1 - transportParams included in connection URL + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + transportParams: { + "customParam": "customValue", + "anotherParam": "123" + } +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check the connection URL query parameters +ASSERT pending.url.query_params["customParam"] == "customValue" +ASSERT pending.url.query_params["anotherParam"] == "123" +``` + +### RTC1f_2 - transportParams with different value types (Stringifiable) + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + transportParams: { + "stringParam": "hello", + "numberParam": 42, + "boolTrueParam": true, + "boolFalseParam": false + } +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# Check stringification of values (RTC1f) +ASSERT pending.url.query_params["stringParam"] == "hello" +ASSERT pending.url.query_params["numberParam"] == "42" +ASSERT pending.url.query_params["boolTrueParam"] == "true" +ASSERT pending.url.query_params["boolFalseParam"] == "false" +``` + +### RTC1f1 - transportParams override library defaults + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + transportParams: { + "v": "3", # Override protocol version + "heartbeats": "false" # Override heartbeats + } +)) + +# Wait for connection attempt +pending = AWAIT mock_ws.await_connection_attempt() + +# User-specified values should override defaults +ASSERT pending.url.query_params["v"] == "3" +ASSERT pending.url.query_params["heartbeats"] == "false" +``` + +--- + +## RTC15 - connect() Method + +**Spec requirement:** The Realtime client must provide a `connect()` method that calls `Connection#connect()`. + +Tests the `RealtimeClient#connect` method. + +### RTC15a - connect() calls Connection#connect + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +ASSERT client.connection.state == ConnectionState.initialized + +# Call connect on client (should proxy to connection) +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.connecting + +AWAIT client.connection.once(ConnectionEvent.connected) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +--- + +## RTC16 - close() Method + +**Spec requirement:** The Realtime client must provide a `close()` method that calls `Connection#close()`. + +Tests the `RealtimeClient#close` method. + +### RTC16a - close() calls Connection#close + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Wait for connection +AWAIT client.connection.once(ConnectionEvent.connected) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Configure mock to respond to CLOSE with CLOSED +mock_ws.on_message(action: CLOSE, respond_with: CLOSED_MESSAGE) + +# Call close on client (should proxy to connection) +client.close() + +AWAIT_STATE client.connection.state == ConnectionState.closing OR + client.connection.state == ConnectionState.closed + +AWAIT client.connection.once(ConnectionEvent.closed) +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +--- + +## Shared Options (Reference to REST Client Tests) + +The following options are shared with the REST client and should behave identically: + +| Option | REST Spec | Test File | +|--------|-----------|-----------| +| `key` | RSC1, RSC1a | `uts/test/realtime/unit/client/client_options.md` | +| `token` / `tokenDetails` | RSC1c | `uts/test/realtime/unit/client/client_options.md` | +| `authCallback` / `authUrl` | RSA8 | `unit/auth/auth_callback.md` | +| `clientId` | RSA7, RSC17 | `unit/auth/client_id.md` | +| `tls` | RSC18 | `uts/test/rest/unit/rest_client.md` | +| `environment` / `endpoint` | RSC15e, REC1 | `unit/client/fallback.md` | +| `restHost` / `realtimeHost` | RSC12, TO3k2, TO3k3 | `unit/client/fallback.md` | +| `fallbackHosts` | RSC15 | `unit/client/fallback.md` | +| `useBinaryProtocol` | RSC8, TO3f | `uts/test/rest/unit/rest_client.md` | +| `logLevel` / `logHandler` | TO3b, TO3c | (not yet specified) | + +### Realtime-Specific Verification for Shared Options + +For shared options that affect the WebSocket connection, verify the behavior in the Realtime context: + +#### TLS Setting (RSC18) in Realtime + +```pseudo +mock_ws = create_mock_websocket() +mock_ws.on_connect(respond_with: CONNECTED_MESSAGE) +install_mock(mock_ws) + +FOR EACH tls_setting IN [true, false]: + mock_ws.reset() + + # Note: Basic auth requires TLS, so use token auth for tls: false + IF tls_setting: + client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + tls: true + )) + ELSE: + client = Realtime(options: ClientOptions( + token: "test-token", + tls: false + )) + + AWAIT client.connection.once(ConnectionEvent.connected) + + connect_url = mock_ws.last_connect_url + IF tls_setting: + ASSERT connect_url.scheme == "wss" + ELSE: + ASSERT connect_url.scheme == "ws" + + client.close() +``` + +#### useBinaryProtocol in Realtime + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +FOR EACH use_binary IN [true, false]: + mock_ws.reset() + + client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + useBinaryProtocol: use_binary + )) + + pending = AWAIT mock_ws.await_connection_attempt() + + IF use_binary: + ASSERT pending.url.query_params["format"] == "msgpack" + ELSE: + ASSERT pending.url.query_params["format"] == "json" + + client.close() +``` + +--- + +## Connection URL Query Parameters + +Tests that the connection URL includes all required query parameters. + +### Standard Query Parameters + +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +pending = AWAIT mock_ws.await_connection_attempt() + +# Required parameters +ASSERT "v" IN pending.url.query_params # Protocol version +ASSERT "format" IN pending.url.query_params # msgpack or json +ASSERT "heartbeats" IN pending.url.query_params # RTN23b +ASSERT "echo" IN pending.url.query_params + +# Auth parameters (one of these depending on auth method) +ASSERT ("key" IN pending.url.query_params) OR + ("accessToken" IN pending.url.query_params) +``` + +--- + +## Test Infrastructure Notes + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for mock installation, test isolation, and timer mocking guidance. + +### Channel Naming + +Tests that use channels should use uniquely-named channels to avoid: +- Collisions between concurrent tests +- Server-side side-effects from previous test runs +- State leakage between test cases + +Use generated unique identifiers (UUIDs, timestamps, or test-framework-provided unique names) for channel names rather than fixed strings like "test-channel". diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index ee1bf7f77..d2b143f62 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -7,7 +7,7 @@ Unit test with mocked WebSocket client ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. --- diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md index 7656fb351..beaec126e 100644 --- a/uts/realtime/unit/connection/connection_open_failures_test.md +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -7,7 +7,7 @@ Unit test with mocked WebSocket client ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. --- diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md index 8ed20bd3c..759ebf463 100644 --- a/uts/realtime/unit/connection/error_reason_test.md +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -7,7 +7,7 @@ Unit test with mocked WebSocket client ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. --- diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md index 8323ddeca..10560f7a8 100644 --- a/uts/realtime/unit/connection/fallback_hosts_test.md +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -7,8 +7,8 @@ Unit test with mocked WebSocket client and HTTP client ## Mock Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. -See `uts/test/rest/mock_http_client.md` for Mock HTTP Client specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/rest/unit/helpers/mock_http.md` for Mock HTTP Client specification. --- diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index 6b8e24c03..67a4f40e1 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -7,7 +7,7 @@ Unit test with mocked WebSocket client ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. --- diff --git a/uts/realtime/unit/connection/update_events_test.md b/uts/realtime/unit/connection/update_events_test.md index 1cfa49b3c..4012644c6 100644 --- a/uts/realtime/unit/connection/update_events_test.md +++ b/uts/realtime/unit/connection/update_events_test.md @@ -7,7 +7,7 @@ Unit test with mocked WebSocket client ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. --- diff --git a/uts/realtime/unit/connection/when_state_test.md b/uts/realtime/unit/connection/when_state_test.md index 61c06fe06..a446959ba 100644 --- a/uts/realtime/unit/connection/when_state_test.md +++ b/uts/realtime/unit/connection/when_state_test.md @@ -7,7 +7,7 @@ Unit test with mocked WebSocket client ## Mock WebSocket Infrastructure -See `uts/test/realtime/unit/client/realtime_client.md` for the full Mock WebSocket Infrastructure specification. +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. --- diff --git a/uts/realtime/unit/helpers/mock_websocket.md b/uts/realtime/unit/helpers/mock_websocket.md new file mode 100644 index 000000000..39fb9b7ea --- /dev/null +++ b/uts/realtime/unit/helpers/mock_websocket.md @@ -0,0 +1,254 @@ +# Mock WebSocket Infrastructure + +This document specifies the mock WebSocket infrastructure for Realtime unit tests. All Realtime unit tests that need to intercept WebSocket connections should reference this document. + +## Purpose + +The mock infrastructure enables unit testing of Realtime client behavior without making real network calls. It supports: + +1. **Intercepting connection attempts** - Capture the URL and query parameters used when connecting +2. **Injecting server messages** - Deliver protocol messages to the client as if from the server +3. **Capturing client messages** - Record protocol messages sent by the client +4. **Controlling connection outcomes** - Simulate various connection results including successful connections, connection refused, DNS errors, timeouts, and other network-level failures +5. **Simulating connection events** - Trigger disconnect and error conditions on established connections + +## Installation Mechanism + +The mechanism for injecting the mock is implementation-specific and not part of the public API. Possible approaches include: + +- Package-level variable substitution (e.g., `var dialWebsocket = ...`) +- Build tag conditional compilation +- Internal test exports (`export_test.go` pattern in Go) +- Dependency injection via internal constructors + +## Mock Interface + +```pseudo +interface MockWebSocket: + # Event sequence tracking - unified timeline of all events + events: List # Ordered sequence of all connection and message events + + # Message injection (server -> client) + send_to_client(message: ProtocolMessage) + send_to_client_and_close(message: ProtocolMessage) # Send then close connection + simulate_disconnect(error?: ErrorInfo) # Close without sending a message + + # Awaitable event triggers for test code + await_next_message_from_client(timeout?: Duration): Future + await_connection_attempt(timeout?: Duration): Future + await_close_request(timeout?: Duration): Future + + # Test management + reset() # Clear all state + +enum MockEventType: + CONNECTION_ATTEMPT + CONNECTION_SUCCESS + CONNECTION_FAILURE + MESSAGE_FROM_CLIENT + MESSAGE_TO_CLIENT + DISCONNECT + CLOSE_REQUEST + +struct MockEvent: + type: MockEventType + timestamp: Time + data: Any # Event-specific data (PendingConnection, ProtocolMessage, ErrorInfo, etc.) + +interface PendingConnection: + url: URL + protocol: String # "application/json" or "application/x-msgpack" + timestamp: Time + + # Methods for test code to respond to the connection attempt + respond_with_success(connected_message: ProtocolMessage) + respond_with_refused() # Connection refused at network level + respond_with_timeout() # Connection times out (unresponsive) + respond_with_dns_error() # DNS resolution fails + respond_with_error(error_message: ProtocolMessage, then_close: bool = true) # WebSocket connects but server sends ERROR +``` + +## Handler-Based Configuration + +For simple test scenarios, implementations may support handler-based configuration: + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + # Handle messages from client + } +) +``` + +Handlers are called automatically when connection attempts or messages occur. The await-based API should always be available for tests that need to coordinate responses with test state. + +### When to Use Each Pattern + +**Handler pattern** (recommended for most tests): +- Response is predetermined based on request count or content +- Simple "first attempt fails, second succeeds" scenarios +- No need to coordinate with external test state + +**Await pattern** (for advanced scenarios): +- Need to inspect connection details before deciding how to respond +- Test logic depends on external state not known at setup time +- Complex coordination between multiple async operations + +**Important note on await pattern**: When awaiting multiple sequential connection attempts, you must set up the await for the next attempt BEFORE responding to the current one to avoid race conditions: + +```pseudo +# Correct pattern for sequential awaits +first_conn = AWAIT mock_ws.await_connection_attempt() +second_future = mock_ws.await_connection_attempt() # Set up BEFORE responding +first_conn.respond_with_error(...) # This triggers retry +second_conn = AWAIT second_future +``` + +## Connection Closing Semantics + +When simulating server behavior, use the correct method based on the scenario: + +| Scenario | Method | Description | +|----------|--------|-------------| +| Server sends DISCONNECTED | `send_to_client_and_close()` | Server sends message then closes connection | +| Server sends ERROR (connection-level) | `send_to_client_and_close()` | ERROR without channel = fatal, closes connection | +| Server sends ERROR (channel-level) | `send_to_client()` | ERROR with channel = attachment failure, connection stays open | +| Server sends CONNECTED, HEARTBEAT, ACK, MESSAGE | `send_to_client()` | Normal messages, connection stays open | +| Unexpected transport failure | `simulate_disconnect()` | Connection drops without server message | + +**Key rule:** Whenever the server sends DISCONNECTED, or ERROR without a specified channel, it will be accompanied by the server closing the WebSocket connection. An ERROR with a specified channel is an attachment failure and doesn't end the connection. + +## Protocol Message Templates + +Common protocol messages for testing: + +```pseudo +CONNECTED_MESSAGE = ProtocolMessage( + action: CONNECTED, + connectionId: "test-connection-id", + connectionDetails: ConnectionDetails( + connectionKey: "test-connection-key", + clientId: null, + connectionStateTtl: 120000, + maxIdleInterval: 15000 + ) +) + +CLOSED_MESSAGE = ProtocolMessage( + action: CLOSED +) + +DISCONNECTED_MESSAGE = ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo(code: 80003, message: "Connection disconnected") +) + +ERROR_MESSAGE(code, message) = ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: code, statusCode: code / 100, message: message) +) + +HEARTBEAT_MESSAGE = ProtocolMessage( + action: HEARTBEAT +) +``` + +## Example: Handler Pattern with State + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + + IF connection_attempt_count == 1: + # First attempt fails + conn.respond_with_refused() + ELSE: + # Second attempt succeeds + conn.respond_with_success(CONNECTED_MESSAGE) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +client.connect() + +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 2 +``` + +## Example: Server Sends Token Error + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + # Server sends token error and closes connection + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40142, + statusCode: 401, + message: "Token expired" + ) + )) + } +) +``` + +## Test Isolation + +Each test should: + +1. Create a fresh mock WebSocket +2. Install the mock +3. Create the Realtime client +4. Perform test steps and assertions +5. Close the client +6. Restore/cleanup the mock + +```pseudo +BEFORE EACH TEST: + mock_ws = MockWebSocket() + install_mock(mock_ws) + +AFTER EACH TEST: + IF client IS NOT null: + client.close() + uninstall_mock() +``` + +## Timer Mocking + +Tests that verify timeout behavior should use timer mocking where practical. See the Timer Mocking section below. + +**Pseudocode convention:** + +```pseudo +enable_fake_timers() + +# Start operation +client.connect() + +# Advance time to trigger timeout +ADVANCE_TIME(15000) # Advance 15 seconds instantly + +# Assert timeout behavior +ASSERT client.connection.state == ConnectionState.disconnected +``` + +**Implementation guidance:** + +- **Preferred**: Mock/fake the timer/clock mechanism (e.g., `jest.advanceTimersByTime()` in JavaScript) +- **Alternative**: Use dependency injection of clock/timer abstractions +- **Fallback**: Use actual time delays with short timeout values From e914b8d82f5e06924713dd2d773de00c37ce5a79 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:40 +0100 Subject: [PATCH 04/32] Refactor test specs to use EXPECT_THROW instead of TRY/CATCH Replace TRY/CATCH error characterisation patterns with declarative EXPECT_THROW assertions for clearer, more portable test specifications. --- uts/.claude/skills/write-test-spec.md | 21 +- uts/realtime/unit/channels/channel_options.md | 452 ++++++++++++++ .../unit/channels/channels_collection.md | 305 ++++++++++ uts/rest/integration/auth.md | 245 ++++++++ uts/rest/integration/presence.md | 555 ++++++++++++++++++ uts/rest/integration/publish.md | 236 ++++++++ 6 files changed, 1813 insertions(+), 1 deletion(-) create mode 100644 uts/realtime/unit/channels/channel_options.md create mode 100644 uts/realtime/unit/channels/channels_collection.md create mode 100644 uts/rest/integration/auth.md create mode 100644 uts/rest/integration/presence.md create mode 100644 uts/rest/integration/publish.md diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index 2fecd7df2..625060283 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -546,15 +546,34 @@ ASSERT value CONTAINS "substring" ## Error Testing Pattern +Use the `FAILS WITH error` pattern to test operations that should fail. This pattern: +- Explicitly ties the error to the specific operation that caused it +- Is language-agnostic (works for exceptions, Result types, error returns, etc.) +- Focuses on ErrorInfo fields rather than exception type names + +```pseudo +# Synchronous operation that fails +client.channels.get("channel", invalidOptions) FAILS WITH error +ASSERT error.code == 40000 + +# Async operation that fails +AWAIT client.auth.authorize(invalidParams) FAILS WITH error +ASSERT error.code == 40160 +ASSERT error.statusCode == 401 +``` + +**Do NOT use language-specific exception patterns:** ```pseudo +# BAD - assumes exceptions, names specific exception types TRY: AWAIT operation_that_fails() FAIL("Expected exception") CATCH AblyException as e: ASSERT e.code == 40160 - ASSERT e.statusCode == 401 ``` +The error object in `FAILS WITH error` represents the ErrorInfo associated with the failure. Implementations should verify the appropriate ErrorInfo fields (code, statusCode, message) regardless of how errors are propagated in that language. + ## Key Spec Points to Remember | Spec | Behavior | diff --git a/uts/realtime/unit/channels/channel_options.md b/uts/realtime/unit/channels/channel_options.md new file mode 100644 index 000000000..5406f6407 --- /dev/null +++ b/uts/realtime/unit/channels/channel_options.md @@ -0,0 +1,452 @@ +# ChannelOptions and Derived Channels Tests + +Spec points: `TB2`, `TB3`, `TB4`, `RTS3b`, `RTS3c`, `RTS3c1`, `RTS5`, `RTL16` + +## Test Type +Unit test - no network calls required for most tests + +These tests verify channel options and derived channel functionality. + +--- + +## TB2 - ChannelOptions attributes + +| Spec | Requirement | +|------|-------------| +| TB2b | `cipher` - CipherParams for encryption | +| TB2c | `params` - Dict of channel parameters | +| TB2d | `modes` - Array of ChannelMode | +| TB4 | `attachOnSubscribe` - boolean, defaults to true | + +Tests that ChannelOptions has all required attributes with correct defaults. + +### Setup +```pseudo +options = RealtimeChannelOptions() +``` + +### Assertions +```pseudo +ASSERT options.cipherParams IS null +ASSERT options.params IS null +ASSERT options.modes IS null +ASSERT options.attachOnSubscribe == true +``` + +--- + +## TB2c - ChannelOptions with params + +**Spec requirement:** `params` is a Dict of key/value pairs for channel parameters. + +Tests that channel options can be created with params. + +### Setup +```pseudo +options = RealtimeChannelOptions( + params: {"rewind": "1", "delta": "vcdiff"} +) +``` + +### Assertions +```pseudo +ASSERT options.params["rewind"] == "1" +ASSERT options.params["delta"] == "vcdiff" +``` + +--- + +## TB2d - ChannelOptions with modes + +**Spec requirement:** `modes` is an array of ChannelMode. + +Tests that channel options can be created with modes. + +### Setup +```pseudo +options = RealtimeChannelOptions( + modes: [ChannelMode.publish, ChannelMode.subscribe] +) +``` + +### Assertions +```pseudo +ASSERT options.modes CONTAINS ChannelMode.publish +ASSERT options.modes CONTAINS ChannelMode.subscribe +ASSERT length(options.modes) == 2 +``` + +--- + +## TB3 - withCipherKey constructor + +**Spec requirement:** Optional constructor that takes a key only. + +Tests the withCipherKey factory constructor. + +### Setup +```pseudo +# 256-bit key as base64 +key = "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=" +options = RealtimeChannelOptions.withCipherKey(key) +``` + +### Assertions +```pseudo +ASSERT options.cipherParams IS NOT null +ASSERT options.cipherParams.algorithm == "aes" +ASSERT options.cipherParams.keyLength == 256 +``` + +--- + +## TB4 - attachOnSubscribe default + +**Spec requirement:** `attachOnSubscribe` defaults to true. + +Tests the default value of attachOnSubscribe. + +### Setup +```pseudo +options1 = RealtimeChannelOptions() +options2 = RealtimeChannelOptions(attachOnSubscribe: false) +``` + +### Assertions +```pseudo +ASSERT options1.attachOnSubscribe == true +ASSERT options2.attachOnSubscribe == false +``` + +--- + +## RTS3b - Options set on new channel + +**Spec requirement:** If options are provided, the options are set on the RealtimeChannel when creating a new RealtimeChannel. + +Tests that get() with options sets them on new channels. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channelOptions = RealtimeChannelOptions( + params: {"rewind": "1"}, + modes: [ChannelMode.subscribe] +) +``` + +### Test Steps +```pseudo +channel = client.channels.get("test-channel", channelOptions) +``` + +### Assertions +```pseudo +ASSERT channel.options.params["rewind"] == "1" +ASSERT channel.options.modes CONTAINS ChannelMode.subscribe +``` + +--- + +## RTS3c - Options updated on existing channel (soft-deprecated) + +**Spec requirement:** Accessing an existing channel with options will update the options. + +Tests that get() with options updates existing channel (when no reattachment needed). + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +# Create channel with initial options +initialOptions = RealtimeChannelOptions(attachOnSubscribe: false) +channel = client.channels.get("test-channel", initialOptions) +``` + +### Test Steps +```pseudo +# Update with new options that don't require reattachment +newOptions = RealtimeChannelOptions( + cipherParams: CipherParams.fromKey(someKey), + attachOnSubscribe: true +) +sameChannel = client.channels.get("test-channel", newOptions) +``` + +### Assertions +```pseudo +ASSERT sameChannel IS SAME AS channel +ASSERT channel.options.cipherParams IS NOT null +ASSERT channel.options.attachOnSubscribe == true +``` + +--- + +## RTS3c1 - Error if options would trigger reattachment + +**Spec requirement:** If a new set of ChannelOptions is supplied that would trigger a reattachment, it must raise an error. + +Tests that get() throws error when params/modes change on attached channel. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +# Create and attach channel +channel = client.channels.get("test-channel") +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +``` + +### Test Steps +```pseudo +# Try to update with options that require reattachment +newOptions = RealtimeChannelOptions( + params: {"rewind": "1"} # params triggers reattachment +) + +client.channels.get("test-channel", newOptions) FAILS WITH error +ASSERT error.code == 40000 + +# Channel options should not have changed +ASSERT channel.options.params IS null +``` + +--- + +## RTS3c1 - Error if modes change on attaching channel + +**Spec requirement:** Must raise error if options would trigger reattachment on attaching channel. + +Tests error when modes change on attaching channel. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channel = client.channels.get("test-channel") +# Put channel in attaching state (implementation detail) +``` + +### Test Steps +```pseudo +newOptions = RealtimeChannelOptions( + modes: [ChannelMode.subscribe] # modes triggers reattachment +) + +client.channels.get("test-channel", newOptions) FAILS WITH error +ASSERT error.code == 40000 +``` + +--- + +## RTL16 - setOptions updates channel options + +**Spec requirement:** setOptions takes a ChannelOptions object and sets or updates the stored channel options. + +Tests that setOptions updates the channel options. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get("test-channel") +``` + +### Test Steps +```pseudo +newOptions = RealtimeChannelOptions( + params: {"delta": "vcdiff"}, + attachOnSubscribe: false +) +AWAIT channel.setOptions(newOptions) +``` + +### Assertions +```pseudo +ASSERT channel.options.params["delta"] == "vcdiff" +ASSERT channel.options.attachOnSubscribe == false +``` + +--- + +## RTL16a - setOptions triggers reattachment when needed + +**Spec requirement:** If params or modes are provided and channel is attached, setOptions triggers reattachment. + +Tests that setOptions with params/modes on attached channel triggers reattachment. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get("test-channel") +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +``` + +### Test Steps +```pseudo +stateChanges = [] +subscription = channel.on().listen((change) => stateChanges.append(change)) + +newOptions = RealtimeChannelOptions( + params: {"rewind": "1"} +) +AWAIT channel.setOptions(newOptions) +``` + +### Assertions +```pseudo +# Should have gone through attaching state +ASSERT stateChanges CONTAINS change WHERE change.current == ChannelState.attaching +ASSERT channel.state == ChannelState.attached +ASSERT channel.options.params["rewind"] == "1" +``` + +--- + +## RTS5a - getDerived creates derived channel + +**Spec requirement:** Takes RealtimeChannel name and DeriveOptions to create a derived channel. + +Tests that getDerived creates a channel with the correct derived name. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +deriveOptions = DeriveOptions(filter: "name == 'foo'") +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived("base-channel", deriveOptions) +``` + +### Assertions +```pseudo +# Channel name should be encoded with filter +ASSERT channel.name STARTS WITH "[filter=" +ASSERT channel.name ENDS WITH "]base-channel" +``` + +--- + +## RTS5a1 - Derived channel filter is base64 encoded + +**Spec requirement:** The filter should be synthesized as [filter=]channelName. + +Tests that the filter expression is base64 encoded in the channel name. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +filter = "name == 'test'" +deriveOptions = DeriveOptions(filter: filter) +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived("my-channel", deriveOptions) +expectedEncoded = base64_encode(filter) # "bmFtZSA9PSAndGVzdCc=" +``` + +### Assertions +```pseudo +ASSERT channel.name == "[filter=" + expectedEncoded + "]my-channel" +``` + +--- + +## RTS5a2 - Derived channel with params + +**Spec requirement:** If channel options are provided with params, they are included in the derived channel name. + +Tests that channel params are included in the derived channel name. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +deriveOptions = DeriveOptions(filter: "type == 'message'") +channelOptions = RealtimeChannelOptions( + params: {"rewind": "1", "delta": "vcdiff"} +) +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived("events", deriveOptions, channelOptions) +``` + +### Assertions +```pseudo +# Parse the channel name to extract the qualifier and base name +# Expected format: [filter=?param1=val1¶m2=val2]baseName +ASSERT channel.name ENDS WITH "]events" + +# Extract the qualifier (everything between [ and ]) +qualifier = extract_between(channel.name, "[", "]") + +# Verify filter is present +ASSERT qualifier STARTS WITH "filter=" + +# Extract and parse params from qualifier (after the ?) +IF qualifier CONTAINS "?": + paramsString = qualifier.split("?")[1] + parsedParams = parse_query_string(paramsString) + ASSERT parsedParams["rewind"] == "1" + ASSERT parsedParams["delta"] == "vcdiff" + ASSERT length(parsedParams) == 2 +ELSE: + FAIL("Expected params in qualifier") +``` + +--- + +## RTS5 - getDerived with options sets them on channel + +**Spec requirement:** ChannelOptions can be provided as an optional third argument. + +Tests that getDerived passes options to the created channel. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +deriveOptions = DeriveOptions(filter: "true") +channelOptions = RealtimeChannelOptions( + modes: [ChannelMode.subscribe], + attachOnSubscribe: false +) +``` + +### Test Steps +```pseudo +channel = client.channels.getDerived("test", deriveOptions, channelOptions) +``` + +### Assertions +```pseudo +ASSERT channel.options.modes CONTAINS ChannelMode.subscribe +ASSERT channel.options.attachOnSubscribe == false +``` + +--- + +## DO2a - DeriveOptions filter attribute + +**Spec requirement:** DeriveOptions has a filter attribute containing a JMESPath string expression. + +Tests the DeriveOptions class. + +### Setup +```pseudo +deriveOptions = DeriveOptions(filter: "name == 'event' && data.count > 10") +``` + +### Assertions +```pseudo +ASSERT deriveOptions.filter == "name == 'event' && data.count > 10" +``` diff --git a/uts/realtime/unit/channels/channels_collection.md b/uts/realtime/unit/channels/channels_collection.md new file mode 100644 index 000000000..8a7ba4ff1 --- /dev/null +++ b/uts/realtime/unit/channels/channels_collection.md @@ -0,0 +1,305 @@ +# RealtimeChannels Collection Tests + +Spec points: `RTS1`, `RTS2`, `RTS3a`, `RTS4a` + +## Test Type +Unit test - no network calls required + +These tests verify the channels collection management functionality. No mock infrastructure is needed as these tests focus on the in-memory collection behavior. + +--- + +## RTS1 - Channels collection accessible via RealtimeClient + +**Spec requirement:** `Channels` is a collection of `RealtimeChannel` objects accessible through `RealtimeClient#channels`. + +Tests that the Realtime client exposes a channels collection. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +channels = client.channels +``` + +### Assertions +```pseudo +ASSERT channels IS RealtimeChannels +ASSERT channels IS NOT null +``` + +--- + +## RTS2 - Check if channel exists + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests the `exists()` method returns correct boolean for existing and non-existing channels. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Before creating any channel +exists_before = client.channels.exists("test-channel") + +# Create the channel +channel = client.channels.get("test-channel") + +# After creating the channel +exists_after = client.channels.exists("test-channel") + +# Check for non-existent channel +exists_other = client.channels.exists("other-channel") +``` + +### Assertions +```pseudo +ASSERT exists_before == false +ASSERT exists_after == true +ASSERT exists_other == false +``` + +--- + +## RTS2 - Iterate through existing channels + +**Spec requirement:** Methods should exist to check if a channel exists or iterate through the existing channels. + +Tests that channel names can be iterated. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create several channels +client.channels.get("channel-a") +client.channels.get("channel-b") +client.channels.get("channel-c") + +# Get all channel names +names = client.channels.names +``` + +### Assertions +```pseudo +ASSERT "channel-a" IN names +ASSERT "channel-b" IN names +ASSERT "channel-c" IN names +ASSERT length(names) == 3 +``` + +--- + +## RTS3a - Get creates new channel if none exists + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. + +Tests that `get()` creates a new channel when called with a new name. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Get a channel that doesn't exist yet +channel = client.channels.get("new-channel") +``` + +### Assertions +```pseudo +ASSERT channel IS RealtimeChannel +ASSERT channel.name == "new-channel" +ASSERT client.channels.exists("new-channel") == true +``` + +--- + +## RTS3a - Get returns existing channel + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. + +Tests that `get()` returns the same channel instance when called multiple times. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Get a channel +channel1 = client.channels.get("test-channel") + +# Get the same channel again +channel2 = client.channels.get("test-channel") +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 # Same object reference +ASSERT channel1.name == "test-channel" +ASSERT channel2.name == "test-channel" +``` + +--- + +## RTS3a - Operator subscript creates or returns channel + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists, or returns the existing channel. + +Tests that the subscript operator `[]` behaves the same as `get()`. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Use subscript to get channel +channel1 = client.channels["test-channel"] + +# Use get() to get same channel +channel2 = client.channels.get("test-channel") + +# Use subscript again +channel3 = client.channels["test-channel"] +``` + +### Assertions +```pseudo +ASSERT channel1 IS SAME AS channel2 +ASSERT channel2 IS SAME AS channel3 +ASSERT channel1.name == "test-channel" +``` + +--- + +## RTS4a - Release detaches and removes channel + +**Spec requirement:** Detaches the channel and then releases the channel resource i.e. it's deleted and can then be garbage collected. + +Tests that `release()` removes the channel from the collection. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create a channel +channel = client.channels.get("test-channel") +ASSERT client.channels.exists("test-channel") == true + +# Release the channel +AWAIT client.channels.release("test-channel") +``` + +### Assertions +```pseudo +ASSERT client.channels.exists("test-channel") == false +``` + +--- + +## RTS4a - Release on non-existent channel is no-op + +**Spec requirement:** Detaches the channel and then releases the channel resource. + +Tests that releasing a channel that doesn't exist completes without error. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Release a channel that was never created +AWAIT client.channels.release("nonexistent-channel") +``` + +### Assertions +```pseudo +# Should complete without throwing +ASSERT client.channels.exists("nonexistent-channel") == false +``` + +--- + +## RTS4a - Release calls detach on attached channel + +**Spec requirement:** Detaches the channel and then releases the channel resource. + +Tests that releasing an attached channel detaches it first. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +``` + +### Test Steps +```pseudo +# Create and attach a channel +channel = client.channels.get("test-channel") +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Capture the state before release +state_before_release = channel.state + +# Release the channel +AWAIT client.channels.release("test-channel") +``` + +### Assertions +```pseudo +ASSERT state_before_release == ChannelState.attached +ASSERT client.channels.exists("test-channel") == false +# Channel should have been detached before removal +``` + +--- + +## RTS3a - Get after release creates new channel + +**Spec requirement:** Creates a new `RealtimeChannel` object for the specified channel if none exists. + +Tests that getting a channel after release creates a fresh instance. + +### Setup +```pseudo +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) +``` + +### Test Steps +```pseudo +# Create a channel +channel1 = client.channels.get("test-channel") + +# Release it +AWAIT client.channels.release("test-channel") + +# Get the same channel name again +channel2 = client.channels.get("test-channel") +``` + +### Assertions +```pseudo +ASSERT channel1 IS NOT SAME AS channel2 # Different object instances +ASSERT channel2.name == "test-channel" +ASSERT client.channels.exists("test-channel") == true +``` diff --git a/uts/rest/integration/auth.md b/uts/rest/integration/auth.md new file mode 100644 index 000000000..eb136b74d --- /dev/null +++ b/uts/rest/integration/auth.md @@ -0,0 +1,245 @@ +# Auth Integration Tests + +Spec points: `RSA4`, `RSA8` + +## Test Type +Integration test against Ably sandbox + +## Token Formats + +All tests in this file should be run with **both**: +1. **JWTs** (primary) - Generate using a third-party JWT library +2. **Ably native tokens** - Obtained using `requestToken()` + +JWT should be the primary token format. See README for details. + +## Test Environment + +### Prerequisites +- Ably sandbox app provisioned via `POST https://sandbox.realtime.ably-nonprod.net/apps` +- API key from provisioned app +- Channel names must be unique per test (see README for naming convention) + +### Setup Pattern +```pseudo +BEFORE ALL TESTS: + app_config = provision_sandbox_app() + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSA4 - Basic auth with API key + +**Spec requirement:** RSA4 - Client can authenticate using an API key via HTTP Basic Auth. + +Tests that API key authentication works against real server. + +### Setup +```pseudo +channel_name = "test-RSA4-" + random_id() +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +# Use channel status endpoint (requires authentication) +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +# Just verify the request succeeded - don't check response body +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - Token auth with JWT + +**Spec requirement:** RSA8 - Client can authenticate using a JWT token. + +Tests authentication using a JWT token. + +### Setup +```pseudo +# Generate a valid JWT using a third-party library +jwt = generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 +) + +channel_name = "test-RSA8-jwt-" + random_id() +client = Rest(options: ClientOptions( + token: jwt, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - Token auth with native token + +**Spec requirement:** RSA8 - Client can authenticate using an Ably native token obtained via `requestToken()`. + +Tests obtaining a native token and using it for authentication. + +### Setup +```pseudo +# First client with API key to obtain token +key_client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +# Obtain a native token +token_details = AWAIT key_client.auth.requestToken() + +# Create new client using only the token +channel_name = "test-RSA8-native-" + random_id() +token_client = Rest(options: ClientOptions( + token: token_details.token, + endpoint: "sandbox" +)) + +# Verify token works +result = AWAIT token_client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT token_details.token IS String +ASSERT token_details.token.length > 0 +ASSERT token_details.expires > now() +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - authCallback with TokenRequest + +**Spec requirement:** RSA8 - Client can use `authCallback` to obtain authentication via `TokenRequest`. + +Tests using an `authCallback` that returns a `TokenRequest`, which is then exchanged for a token. + +### Setup +```pseudo +# Client that generates token requests +token_request_client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +# authCallback that creates and returns a TokenRequest +auth_callback = FUNCTION(params): + RETURN AWAIT token_request_client.auth.createTokenRequest(params) + +channel_name = "test-RSA8-callback-" + random_id() +client = Rest(options: ClientOptions( + authCallback: auth_callback, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA8 - authCallback with JWT + +**Spec requirement:** RSA8 - Client can use `authCallback` to obtain JWT tokens dynamically. + +Tests using an `authCallback` that returns a JWT. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + client_id: params.clientId, + ttl: params.ttl OR 3600000 + ) + +channel_name = "test-RSA8-jwt-callback-" + random_id() +client = Rest(options: ClientOptions( + authCallback: auth_callback, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +ASSERT result.statusCode >= 200 AND result.statusCode < 300 +``` + +--- + +## RSA4 - Invalid credentials rejected + +**Spec requirement:** RSA4 - Server rejects requests with invalid API key credentials. + +Tests that invalid API keys are rejected by the server. + +### Setup +```pseudo +channel_name = "test-RSA4-invalid-" + random_id() +client = Rest(options: ClientOptions( + key: "invalid.key:secret", + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +AWAIT client.request("GET", "/channels/" + channel_name) FAILS WITH error +ASSERT error.statusCode == 401 +ASSERT error.code >= 40100 AND error.code < 40200 +``` + +--- + +## Notes + +### Tests moved to unit tests + +The following functionality is better tested via unit tests with a mocked HTTP client: + +- **`createTokenRequest()`** (RSA9) - This is a local signing operation that doesn't require server interaction +- **`authorize()` token renewal** (RSA14) - Unit tests can explicitly confirm that a new token is used on subsequent requests +- **Token expiry and renewal cycle** (RSA4b4) - See `unit/auth/token_renewal.md` diff --git a/uts/rest/integration/presence.md b/uts/rest/integration/presence.md new file mode 100644 index 000000000..922614cb4 --- /dev/null +++ b/uts/rest/integration/presence.md @@ -0,0 +1,555 @@ +# REST Presence Integration Tests + +Spec points: `RSP1`, `RSP3`, `RSP3a`, `RSP4`, `RSP4b`, `RSP5` + +## Test Type +Integration test against Ably sandbox + +## Test Environment + +### Prerequisites +- Ably sandbox app provisioned via `POST https://sandbox.realtime.ably-nonprod.net/apps` +- API key from provisioned app +- Channel names must be unique per test (see README for naming convention) + +### Sandbox Presence Fixtures + +The sandbox test app (from `ably-common/test-resources/test-app-setup.json`) includes pre-populated presence members on the channel `persisted:presence_fixtures`: + +| clientId | data | encoding | +|----------|------|----------| +| `client_bool` | `"true"` | none | +| `client_int` | `"24"` | none | +| `client_string` | `"This is a string clientData payload"` | none | +| `client_json` | `{"test": "This is a JSONObject clientData payload"}` | none | +| `client_decoded` | `{"example":{"json":"Object"}}` | `json` | +| `client_encoded` | (encrypted) | `json/utf-8/cipher+aes-128-cbc/base64` | + +**Cipher configuration** for `client_encoded`: +- Algorithm: `aes` +- Mode: `cbc` +- Key length: 128 +- Key (base64): `WUP6u0K7MXI5Zeo0VppPwg==` +- IV (base64): `HO4cYSP8LybPYBPZPHQOtg==` + +### Setup Pattern +```pseudo +BEFORE ALL TESTS: + app_config = provision_sandbox_app() + app_id = app_config.app_id + api_key = app_config.keys[0].key_str + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSP1 - RestPresence accessible via channel + +### RSP1_Integration - Access presence from channel + +**Spec requirement:** RSP1 - `RestPresence` object is accessible via `channel.presence`. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +presence = channel.presence + +ASSERT presence IS NOT null +ASSERT presence IS RestPresence +``` + +--- + +## RSP3 - RestPresence#get + +### RSP3_Integration_1 - Get presence members from fixture channel + +**Spec requirement:** RSP3 - `get()` returns a `PaginatedResult` containing current presence members. + +Retrieves the pre-populated presence members from the sandbox fixture channel. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get() + +ASSERT result IS PaginatedResult +ASSERT result.items.length >= 5 # At least the non-encrypted fixtures + +# Verify expected clients are present +client_ids = [msg.clientId FOR msg IN result.items] +ASSERT "client_bool" IN client_ids +ASSERT "client_string" IN client_ids +ASSERT "client_json" IN client_ids +``` + +### RSP3_Integration_2 - Get returns PresenceMessage with correct fields + +**Spec requirement:** RSP3 - Each item in the result is a `PresenceMessage` with action, clientId, data, and connectionId. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get() + +# Find client_string member +member = FIND msg IN result.items WHERE msg.clientId == "client_string" + +ASSERT member IS NOT null +ASSERT member IS PresenceMessage +ASSERT member.action == PresenceAction.present +ASSERT member.clientId == "client_string" +ASSERT member.data == "This is a string clientData payload" +ASSERT member.connectionId IS NOT null +``` + +### RSP3a1_Integration - Get with limit parameter + +**Spec requirement:** RSP3a1 - `limit` param restricts the number of presence members returned. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") + +# Request with small limit +result = AWAIT channel.presence.get(limit: 2) + +ASSERT result.items.length <= 2 +# If more members exist, pagination should be available +IF result.hasNext(): + ASSERT result.items.length == 2 +``` + +### RSP3a2_Integration - Get with clientId filter + +**Spec requirement:** RSP3a2 - `clientId` param filters results to specified client. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get(clientId: "client_json") + +ASSERT result.items.length == 1 +ASSERT result.items[0].clientId == "client_json" +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["test"] == "This is a JSONObject clientData payload" +``` + +### RSP3_Integration_Empty - Get on channel with no presence + +**Spec requirement:** RSP3 - `get()` returns empty `PaginatedResult` when no members are present. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +# Use a unique channel name that has no presence members +channel_name = "presence-empty-" + random_id() +channel = client.channels.get(channel_name) + +result = AWAIT channel.presence.get() + +ASSERT result.items IS List +ASSERT result.items.length == 0 +ASSERT result.hasNext() == false +``` + +--- + +## RSP4 - RestPresence#history + +### RSP4_Integration_1 - History returns presence events + +**Spec requirement:** RSP4 - `history()` returns a `PaginatedResult` containing presence event history. + +This test creates presence history by entering and leaving a channel. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel_name = "presence-history-" + random_id() + +# Use realtime client to generate presence history +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "test-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: "entered") +AWAIT realtime_channel.presence.update(data: "updated") +AWAIT realtime_channel.presence.leave(data: "left") +AWAIT realtime.close() + +# Poll REST history until events appear +rest_channel = client.channels.get(channel_name) + +history = poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 3, + interval: 500ms, + timeout: 10s +) + +ASSERT history.items.length >= 3 + +# Check for expected actions (order depends on direction) +actions = [msg.action FOR msg IN history.items] +ASSERT PresenceAction.enter IN actions +ASSERT PresenceAction.update IN actions +ASSERT PresenceAction.leave IN actions +``` + +### RSP4b1_Integration - History with start/end time range + +**Spec requirement:** RSP4b1 - `start` and `end` params filter history by timestamp range. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "test-client" +)) + +channel_name = "presence-history-time-" + random_id() + +# Record time before any presence events +time_before = now_millis() + +# Generate presence events via realtime +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "time-test-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: "test") +AWAIT realtime_channel.presence.leave() +AWAIT realtime.close() + +time_after = now_millis() + +# Poll until events appear +rest_channel = client.channels.get(channel_name) +poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 2, + interval: 500ms, + timeout: 10s +) + +# Query with time range +history = AWAIT rest_channel.presence.history( + start: time_before, + end: time_after +) + +ASSERT history.items.length >= 2 +``` + +### RSP4b2_Integration - History direction forwards + +**Spec requirement:** RSP4b2 - `direction` param controls event ordering (forwards = oldest first). + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel_name = "presence-direction-" + random_id() + +# Generate ordered presence events +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "direction-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: "first") +AWAIT realtime_channel.presence.update(data: "second") +AWAIT realtime_channel.presence.update(data: "third") +AWAIT realtime.close() + +# Poll until events appear +rest_channel = client.channels.get(channel_name) +poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 3, + interval: 500ms, + timeout: 10s +) + +# Get history forwards (oldest first) +history_forwards = AWAIT rest_channel.presence.history(direction: "forwards") + +ASSERT history_forwards.items.length >= 3 +ASSERT history_forwards.items[0].data == "first" + +# Get history backwards (newest first) - default +history_backwards = AWAIT rest_channel.presence.history(direction: "backwards") + +ASSERT history_backwards.items[0].data == "third" +``` + +### RSP4b3_Integration - History with limit and pagination + +**Spec requirement:** RSP4b3 - `limit` param restricts history results and enables pagination. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel_name = "presence-limit-" + random_id() + +# Generate multiple presence events +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "limit-client" +)) + +realtime_channel = realtime.channels.get(channel_name) +FOR i IN 1..5: + AWAIT realtime_channel.presence.update(data: "update-" + str(i)) +AWAIT realtime.close() + +# Poll until all events appear +rest_channel = client.channels.get(channel_name) +poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 5, + interval: 500ms, + timeout: 10s +) + +# Request with small limit +page1 = AWAIT rest_channel.presence.history(limit: 2) + +ASSERT page1.items.length == 2 +ASSERT page1.hasNext() == true + +# Get next page +page2 = AWAIT page1.next() + +ASSERT page2 IS NOT null +ASSERT page2.items.length >= 1 +``` + +--- + +## RSP5 - Presence message decoding + +### RSP5_Integration_1 - String data decoded correctly + +**Spec requirement:** RSP5 - Presence message `data` is decoded according to its encoding. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get(clientId: "client_string") + +ASSERT result.items.length == 1 +ASSERT result.items[0].data IS String +ASSERT result.items[0].data == "This is a string clientData payload" +``` + +### RSP5_Integration_2 - JSON data decoded to object + +**Spec requirement:** RSP5 - JSON-encoded presence data is decoded to native objects. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel = client.channels.get("persisted:presence_fixtures") +result = AWAIT channel.presence.get(clientId: "client_decoded") + +ASSERT result.items.length == 1 +ASSERT result.items[0].data IS Object/Map +ASSERT result.items[0].data["example"]["json"] == "Object" +``` + +### RSP5_Integration_3 - Encrypted data decoded with cipher + +**Spec requirement:** RSP5 - Encrypted presence data is automatically decrypted when cipher is configured. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +cipher_key = base64_decode("WUP6u0K7MXI5Zeo0VppPwg==") + +channel = client.channels.get("persisted:presence_fixtures", options: RestChannelOptions( + cipher: CipherParams( + key: cipher_key, + algorithm: "aes", + mode: "cbc", + keyLength: 128 + ) +)) + +result = AWAIT channel.presence.get(clientId: "client_encoded") + +# The encrypted fixture should be decrypted +ASSERT result.items.length == 1 +ASSERT result.items[0].data IS NOT null +# Actual decrypted value depends on fixture content +``` + +### RSP5_Integration_4 - History messages also decoded + +**Spec requirement:** RSP5 - Presence history messages are decoded the same way as current presence. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +channel_name = "presence-decode-history-" + random_id() + +# Generate presence event with JSON data +realtime = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "decode-client" +)) + +json_data = { "key": "value", "number": 123 } +realtime_channel = realtime.channels.get(channel_name) +AWAIT realtime_channel.presence.enter(data: json_data) +AWAIT realtime.close() + +# Poll and retrieve history +rest_channel = client.channels.get(channel_name) +history = poll_until( + condition: FUNCTION() => + result = AWAIT rest_channel.presence.history() + RETURN result.items.length >= 1, + interval: 500ms, + timeout: 10s +) + +ASSERT history.items[0].data IS Object/Map +ASSERT history.items[0].data["key"] == "value" +ASSERT history.items[0].data["number"] == 123 +``` + +--- + +## Pagination + +### RSP_Pagination_Integration - Full pagination through presence members + +**Spec requirement:** RSP3 - Presence `get()` supports pagination through all members. + +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +# The fixture channel has multiple members +channel = client.channels.get("persisted:presence_fixtures") + +# Request with small limit to force pagination +page1 = AWAIT channel.presence.get(limit: 2) + +all_members = [] +all_members.extend(page1.items) + +current_page = page1 +WHILE current_page.hasNext(): + current_page = AWAIT current_page.next() + all_members.extend(current_page.items) + +# Should have retrieved all fixture members +ASSERT all_members.length >= 5 + +# Verify no duplicates +client_ids = [m.clientId FOR m IN all_members] +ASSERT len(set(client_ids)) == len(client_ids) +``` + +--- + +## Error Handling + +### RSP_Error_Integration_1 - Invalid credentials rejected + +**Spec requirement:** RSP3 - Presence operations with invalid credentials return authentication errors. + +```pseudo +client = Rest(options: ClientOptions( + key: "invalid.key:secret", + endpoint: "sandbox" +)) + +AWAIT client.channels.get("test").presence.get() FAILS WITH error +ASSERT error.statusCode == 401 +ASSERT error.code >= 40100 AND error.code < 40200 +``` + +### RSP_Error_Integration_2 - Insufficient permissions rejected + +**Spec requirement:** RSP3 - Presence operations succeed with appropriate capabilities. + +```pseudo +# Use key with limited capabilities (keys[3] has subscribe only) +restricted_key = app_config.keys[3].key_str + +client = Rest(options: ClientOptions( + key: restricted_key, + endpoint: "sandbox" +)) + +# This should work - subscribe capability is sufficient for presence.get +result = AWAIT client.channels.get("persisted:presence_fixtures").presence.get() +ASSERT result IS NOT null +``` diff --git a/uts/rest/integration/publish.md b/uts/rest/integration/publish.md new file mode 100644 index 000000000..12ac309c1 --- /dev/null +++ b/uts/rest/integration/publish.md @@ -0,0 +1,236 @@ +# REST Channel Publish Integration Tests + +Spec points: `RSL1d`, `RSL1l1`, `RSL1m4`, `RSL1n` + +## Test Type +Integration test against Ably sandbox + +## Test Environment + +### Prerequisites +- Ably sandbox app provisioned via `POST https://sandbox.realtime.ably-nonprod.net/apps` +- App must include multiple keys with different capabilities (see below) +- Channel names must be unique per test (see README for naming convention) + +### App Configuration + +The sandbox app must be provisioned with keys that have different capabilities: + +```json +{ + "keys": [ + { + "name": "full-access", + "capability": "{\"*\":[\"*\"]}" + }, + { + "name": "restricted", + "capability": "{\"allowed-channel\":[\"publish\",\"subscribe\"]}" + } + ] +} +``` + +### Setup Pattern +```pseudo +BEFORE ALL TESTS: + app_config = provision_sandbox_app(config_with_multiple_keys) + app_id = app_config.app_id + full_access_key = app_config.keys[0].key_str + restricted_key = app_config.keys[1].key_str # Limited capabilities + +AFTER ALL TESTS: + DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +--- + +## RSL1d - Error indication on publish failure + +**Spec requirement:** RSL1d - Failed publish operations must indicate the error to the caller. + +Tests that errors are properly indicated when a publish fails due to insufficient permissions. + +### Setup +```pseudo +channel_name = "forbidden-channel-" + random_id() # Not in restricted key's capability + +restricted_client = Rest(options: ClientOptions( + key: restricted_key, # Key without publish capability for this channel + endpoint: "sandbox" +)) +restricted_channel = restricted_client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT restricted_channel.publish(name: "event", data: "data") FAILS WITH error +ASSERT error.code == 40160 # Not permitted +ASSERT error.statusCode == 401 +``` + +--- + +## RSL1n - PublishResult contains serials + +**Spec requirement:** RSL1n - Successful publish returns a `PublishResult` containing message serials. + +Tests that successful publish returns a result with message serials. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox" +)) +channel_name = "test-serials-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Single message +result1 = AWAIT channel.publish(name: "event1", data: "data1") + +ASSERT result1.serials IS List +ASSERT result1.serials.length == 1 +ASSERT result1.serials[0] IS String +ASSERT result1.serials[0].length > 0 + + +# Multiple messages +result2 = AWAIT channel.publish(messages: [ + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3"), + Message(name: "event4", data: "data4") +]) + +ASSERT result2.serials.length == 3 +ASSERT ALL serial IN result2.serials: serial IS String AND serial.length > 0 +ASSERT result2.serials ARE all unique +``` + +--- + +## RSL1k5 - Idempotent publish with client-supplied IDs + +**Spec requirement:** RSL1k5 - Messages with client-supplied IDs are idempotent (duplicate IDs don't create duplicate messages). + +Tests that multiple publishes with the same client-supplied ID result in single message. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox" +)) +channel_name = "idempotent-explicit-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +fixed_id = "client-supplied-id-" + random_id() + +# Publish same message ID multiple times +FOR i IN 1..3: + AWAIT channel.publish( + message: Message(id: fixed_id, name: "event", data: "data-" + str(i)) + ) + +# Poll history until message appears (avoid fixed wait) +history = poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length > 0, + interval: 500ms, + timeout: 10s +) + +# Verify only one message in history +ASSERT history.items.length == 1 +ASSERT history.items[0].id == fixed_id +# The data should be from the first publish (subsequent ones are no-ops) +ASSERT history.items[0].data == "data-1" +``` + +--- + +## RSL1l1 - Publish params with _forceNack + +**Spec requirement:** RSL1l1 - Additional publish params can be supplied and are transmitted to the server. + +Tests that publish params are correctly transmitted by using the `_forceNack` test param. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox" +)) +channel_name = "force-nack-test-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish( + message: Message(name: "event", data: "data"), + params: { "_forceNack": "true" } +) FAILS WITH error +ASSERT error.code == 40099 # Specific code for forced nack +``` + +--- + +## RSL1m4 - ClientId mismatch rejection + +**Spec requirement:** RSL1m4 - Server rejects messages where clientId doesn't match the authenticated client. + +Tests that server rejects message with clientId different from authenticated client. + +### Setup +```pseudo +# Create a token with a specific clientId +key_client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox" +)) + +token_details = AWAIT key_client.auth.requestToken( + tokenParams: TokenParams(clientId: "authenticated-client-id") +) + +# Client using token with clientId +token_client = Rest(options: ClientOptions( + token: token_details.token, + endpoint: "sandbox" +)) + +channel_name = "clientid-mismatch-" + random_id() +channel = token_client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +AWAIT channel.publish( + message: Message( + name: "event", + data: "data", + clientId: "different-client-id" # Doesn't match authenticated clientId + ) +) FAILS WITH error +ASSERT error.code == 40012 # Incompatible clientId +ASSERT error.statusCode == 400 +``` + +--- + +## Notes + +### Tests moved to unit tests + +The following functionality is better tested via unit tests with a mocked HTTP client: + +- **RSL1k4 - Idempotent retry verification**: Testing that automatic retry after failure doesn't duplicate messages requires HTTP-level interception. This is better done with a mock that can fail the first request and allow the retry. See `unit/channel/idempotency.md`. From f7e7a6ed5e4d088c5d1d2854e501b38afd32f6ab Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 05/32] Use unique channel names in test specs to prevent cross-test interference Add UNIQUE_CHANNEL_NAME() calls and randomised channel names throughout the test specs. Also adds new test specs for channel attach (RTL4), detach (RTL6), channel options, state events, and channels collection. --- uts/realtime/unit/channels/channel_attach.md | 840 ++++++++++++++++++ uts/realtime/unit/channels/channel_detach.md | 751 ++++++++++++++++ uts/realtime/unit/channels/channel_options.md | 52 +- .../unit/channels/channel_state_events.md | 633 +++++++++++++ .../unit/channels/channels_collection.md | 91 +- uts/realtime/unit/client/realtime_client.md | 12 +- .../unit/connection/fallback_hosts_test.md | 5 +- .../unit/connection/heartbeat_test.md | 8 +- 8 files changed, 2334 insertions(+), 58 deletions(-) create mode 100644 uts/realtime/unit/channels/channel_attach.md create mode 100644 uts/realtime/unit/channels/channel_detach.md create mode 100644 uts/realtime/unit/channels/channel_state_events.md diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md new file mode 100644 index 000000000..997ff464f --- /dev/null +++ b/uts/realtime/unit/channels/channel_attach.md @@ -0,0 +1,840 @@ +# RealtimeChannel Attach Tests + +Spec points: `RTL4`, `RTL4a`, `RTL4b`, `RTL4c`, `RTL4c1`, `RTL4f`, `RTL4g`, `RTL4h`, `RTL4i`, `RTL4j`, `RTL4k`, `RTL4l`, `RTL4m` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL4a - Attach when already attached is no-op + +**Spec requirement:** If already ATTACHED nothing is done. + +Tests that calling attach on an already-attached channel returns immediately. + +### Setup +```pseudo +channel_name = "test-RTL4a-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 + +# Second attach - should be no-op +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 # No additional ATTACH message sent +``` + +--- + +## RTL4h - Attach while attaching waits for completion + +**Spec requirement:** If the channel is in a pending state ATTACHING, do the attach operation after the completion of the pending request. + +Tests that calling attach while already attaching waits for the first attach to complete. + +### Setup +```pseudo +channel_name = "test-RTL4h-${random_id()}" +attach_message_count = 0 +attach_responses_sent = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + # Delay response to allow second attach call + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start first attach (don't await) +attach_future_1 = channel.attach() + +# Wait for channel to enter attaching state +AWAIT_STATE channel.state == ChannelState.attaching + +# Start second attach while first is pending +attach_future_2 = channel.attach() + +# Now send the ATTACHED response +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +# Both should complete +AWAIT attach_future_1 +AWAIT attach_future_2 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 # Only one ATTACH message sent +``` + +--- + +## RTL4h - Attach while detaching waits then attaches + +**Spec requirement:** If the channel is in a pending state DETACHING, do the attach operation after the completion of the pending request. + +Tests that calling attach while detaching waits for detach to complete, then attaches. + +### Setup +```pseudo +channel_name = "test-RTL4h-detaching-${random_id()}" +messages_from_client = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + messages_from_client.append(msg) + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + # Delay DETACHED response + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach first +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Start detach (don't await) +detach_future = channel.detach() +AWAIT_STATE channel.state == ChannelState.detaching + +# Start attach while detaching +attach_future = channel.attach() + +# Send DETACHED response +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name +)) + +# Wait for detach to complete +AWAIT detach_future + +# Now ATTACH should be sent and we wait for it +AWAIT attach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +# Should have: ATTACH, DETACH, ATTACH +attach_messages = filter(messages_from_client, (m) => m.action == ATTACH) +ASSERT length(attach_messages) == 2 +``` + +--- + +## RTL4g - Attach from failed state clears errorReason + +**Spec requirement:** If the channel is in the FAILED state, the attach request sets its errorReason to null, and proceeds with a channel attach. + +Tests that attaching from failed state clears the error and attempts attach. + +### Setup +```pseudo +channel_name = "test-RTL4g-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach fails + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Denied") + )) + ELSE: + # Second attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails +AWAIT channel.attach() FAILS WITH error +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +# Second attach from failed state +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT channel.errorReason IS null +``` + +--- + +## RTL4b - Attach fails when connection is closed + +**Spec requirement:** If the connection state is CLOSED, CLOSING, SUSPENDED or FAILED, the attach request results in an error. + +Tests that attach fails when connection is in closed state. + +### Setup +```pseudo +channel_name = "test-RTL4b-closed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Close the connection +AWAIT client.close() +ASSERT client.connection.state == ConnectionState.closed + +# Try to attach +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code IS NOT null +ASSERT channel.state != ChannelState.attached +``` + +--- + +## RTL4b - Attach fails when connection is failed + +**Spec requirement:** If the connection state is FAILED, the attach request results in an error. + +Tests that attach fails when connection is in failed state. + +### Setup +```pseudo +channel_name = "test-RTL4b-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED_MESSAGE) + # Server sends fatal error + mock_ws.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, message: "Fatal error") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +# Try to attach +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT channel.state != ChannelState.attached +``` + +--- + +## RTL4b - Attach fails when connection is suspended + +**Spec requirement:** If the connection state is SUSPENDED, the attach request results in an error. + +Tests that attach fails when connection is in suspended state. + +### Setup +```pseudo +channel_name = "test-RTL4b-suspended-${random_id()}" + +# Configure client with short suspend timeout for testing +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + suspendedRetryTimeout: 100 # Short timeout for testing +)) +channel = client.channels.get(channel_name) + +# Mock that refuses all connections to trigger suspended state +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +client.connect() + +# Wait for connection to enter suspended state after retries exhausted +AWAIT_STATE client.connection.state == ConnectionState.suspended + +# Try to attach +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT channel.state != ChannelState.attached +``` + +--- + +## RTL4i - Attach queued when connection is connecting + +**Spec requirement:** If the connection state is INITIALIZED, CONNECTING or DISCONNECTED, the channel should be put into the ATTACHING state. + +Tests that attach transitions channel to attaching when connection is connecting. + +### Setup +```pseudo +channel_name = "test-RTL4i-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay connection response + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Start connecting but don't complete +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Start attach while connection is still connecting +attach_future = channel.attach() + +# Channel should immediately enter attaching +AWAIT_STATE channel.state == ChannelState.attaching +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attaching +# Attach message not yet sent (connection not ready) +``` + +--- + +## RTL4i - Attach completes when connection becomes connected + +**Spec requirement:** Attach message will be sent once the connection becomes CONNECTED. + +Tests that queued attach completes when connection is established. + +### Setup +```pseudo +channel_name = "test-RTL4i-connected-${random_id()}" +attach_message_received = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay connection response + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_received = true + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Start connecting +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Start attach while connecting +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_message_received == false + +# Complete connection +pending_conn = AWAIT mock_ws.await_connection_attempt() +pending_conn.respond_with_success(CONNECTED_MESSAGE) + +# Wait for attach to complete +AWAIT attach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_received == true +``` + +--- + +## RTL4c - Attach sends ATTACH message and transitions to attaching + +**Spec requirement:** An ATTACH ProtocolMessage is sent to the server, the state transitions to ATTACHING. + +Tests the normal attach flow. + +### Setup +```pseudo +channel_name = "test-RTL4c-${random_id()}" +captured_attach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +state_during_attach = null +channel.on(ChannelEvent.attaching).listen((change) => { + state_during_attach = channel.state +}) + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT state_during_attach == ChannelState.attaching +ASSERT channel.state == ChannelState.attached +ASSERT captured_attach_message IS NOT null +ASSERT captured_attach_message.action == ATTACH +ASSERT captured_attach_message.channel == channel_name +``` + +--- + +## RTL4c1 - ATTACH message includes channelSerial when available + +**Spec requirement:** The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial. + +Tests that channelSerial is included in ATTACH message when available. + +### Setup +```pseudo +channel_name = "test-RTL4c1-${random_id()}" +captured_attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + channelSerial: "serial-from-server-1" + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach - no channelSerial yet +AWAIT channel.attach() + +# Detach +AWAIT channel.detach() + +# Second attach - should include channelSerial from previous ATTACHED +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT length(captured_attach_messages) == 2 +# First attach has no channelSerial (or null) +ASSERT captured_attach_messages[0].channelSerial IS null OR captured_attach_messages[0].channelSerial IS NOT SET +# Second attach includes channelSerial from previous attachment +ASSERT captured_attach_messages[1].channelSerial == "serial-from-server-1" +``` + +--- + +## RTL4f - Attach times out and transitions to suspended + +**Spec requirement:** If an ATTACHED ProtocolMessage is not received within realtimeRequestTimeout, the attach request should be treated as though it has failed and the channel should transition to the SUSPENDED state. + +Tests attach timeout behavior. + +### Setup +```pseudo +channel_name = "test-RTL4f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond - simulate timeout + } +) +install_mock(mock_ws) + +# Use short timeout for testing +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 # 100ms timeout for testing +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +attach_future = channel.attach() + +# Advance time past timeout +ADVANCE_TIME(150) + +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended +ASSERT error IS NOT null +``` + +--- + +## RTL4k - ATTACH includes params from ChannelOptions + +**Spec requirement:** If the user has specified a non-empty params object in the ChannelOptions, it must be included in a params field of the ATTACH ProtocolMessage. + +Tests that channel params are included in ATTACH message. + +### Setup +```pseudo +channel_name = "test-RTL4k-${random_id()}" +captured_attach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channel_options = RealtimeChannelOptions( + params: {"rewind": "1", "delta": "vcdiff"} +) +channel = client.channels.get(channel_name, channel_options) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_attach_message IS NOT null +ASSERT captured_attach_message.params IS NOT null +ASSERT captured_attach_message.params["rewind"] == "1" +ASSERT captured_attach_message.params["delta"] == "vcdiff" +``` + +--- + +## RTL4l - ATTACH includes modes as flags + +**Spec requirement:** If the user has specified a modes array in the ChannelOptions, it must be encoded as a bitfield and set as the flags field of the ATTACH ProtocolMessage. + +Tests that channel modes are encoded in ATTACH flags. + +### Setup +```pseudo +channel_name = "test-RTL4l-${random_id()}" +captured_attach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) + +channel_options = RealtimeChannelOptions( + modes: [ChannelMode.publish, ChannelMode.subscribe] +) +channel = client.channels.get(channel_name, channel_options) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_attach_message IS NOT null +ASSERT captured_attach_message.flags IS NOT null +# Flags should include PUBLISH (65536) and SUBSCRIBE (262144) bits +ASSERT (captured_attach_message.flags AND 65536) != 0 # PUBLISH bit set +ASSERT (captured_attach_message.flags AND 262144) != 0 # SUBSCRIBE bit set +``` + +--- + +## RTL4m - Channel modes populated from ATTACHED response + +**Spec requirement:** On receipt of an ATTACHED, the client library should decode the flags into an array of ChannelModes and expose it as a read-only modes field. + +Tests that modes are decoded from ATTACHED flags. + +### Setup +```pseudo +channel_name = "test-RTL4m-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 327680 # PUBLISH (65536) + SUBSCRIBE (262144) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.modes IS NOT null +ASSERT ChannelMode.publish IN channel.modes +ASSERT ChannelMode.subscribe IN channel.modes +``` + +--- + +## RTL4j - ATTACH_RESUME flag set for reattach + +**Spec requirement:** If the attach is not a clean attach, the library should set the ATTACH_RESUME flag in the ATTACH message. + +Tests that ATTACH_RESUME flag is set on reattachment. + +### Setup +```pseudo +channel_name = "test-RTL4j-${random_id()}" +captured_attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach - clean attach +AWAIT channel.attach() + +# Detach +AWAIT channel.detach() + +# Reattach - should have ATTACH_RESUME flag +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT length(captured_attach_messages) == 2 +# First attach should NOT have ATTACH_RESUME flag +ASSERT (captured_attach_messages[0].flags AND 32) == 0 # ATTACH_RESUME = 32 +# Second attach SHOULD have ATTACH_RESUME flag +ASSERT (captured_attach_messages[1].flags AND 32) != 0 # ATTACH_RESUME = 32 +``` diff --git a/uts/realtime/unit/channels/channel_detach.md b/uts/realtime/unit/channels/channel_detach.md new file mode 100644 index 000000000..2a2d734e0 --- /dev/null +++ b/uts/realtime/unit/channels/channel_detach.md @@ -0,0 +1,751 @@ +# RealtimeChannel Detach Tests + +Spec points: `RTL5`, `RTL5a`, `RTL5b`, `RTL5d`, `RTL5e`, `RTL5f`, `RTL5i`, `RTL5j`, `RTL5k`, `RTL5l` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL5a - Detach when initialized is no-op + +**Spec requirement:** If the channel state is INITIALIZED or DETACHED nothing is done. + +Tests that detach on an initialized channel returns immediately. + +### Setup +```pseudo +channel_name = "test-RTL5a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +ASSERT channel.state == ChannelState.initialized + +# Detach from initialized state - should be no-op +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.initialized OR channel.state == ChannelState.detached +# No state change events should have been emitted (or only to detached) +``` + +--- + +## RTL5a - Detach when already detached is no-op + +**Spec requirement:** If the channel state is INITIALIZED or DETACHED nothing is done. + +Tests that detach on an already-detached channel returns immediately. + +### Setup +```pseudo +channel_name = "test-RTL5a-detached-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach then detach +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 + +# Second detach - should be no-op +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 # No additional DETACH message sent +``` + +--- + +## RTL5i - Detach while detaching waits for completion + +**Spec requirement:** If the channel is in a pending state DETACHING, do the detach operation after the completion of the pending request. + +Tests that calling detach while already detaching waits for the first detach to complete. + +### Setup +```pseudo +channel_name = "test-RTL5i-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + # Delay response to allow second detach call + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() + +# Start first detach (don't await) +detach_future_1 = channel.detach() + +# Wait for channel to enter detaching state +AWAIT_STATE channel.state == ChannelState.detaching + +# Start second detach while first is pending +detach_future_2 = channel.detach() + +# Now send the DETACHED response +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name +)) + +# Both should complete +AWAIT detach_future_1 +AWAIT detach_future_2 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 # Only one DETACH message sent +``` + +--- + +## RTL5i - Detach while attaching waits then detaches + +**Spec requirement:** If the channel is in a pending state ATTACHING, do the detach operation after the completion of the pending request. + +Tests that calling detach while attaching waits for attach to complete, then detaches. + +### Setup +```pseudo +channel_name = "test-RTL5i-attaching-${random_id()}" +messages_from_client = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + messages_from_client.append(msg) + IF msg.action == ATTACH: + # Delay response + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach (don't await) +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Start detach while attaching +detach_future = channel.detach() + +# Send ATTACHED response - attach completes +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +# Wait for both operations +AWAIT attach_future +AWAIT detach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +# Should have: ATTACH, DETACH +ASSERT length(messages_from_client) == 2 +ASSERT messages_from_client[0].action == ATTACH +ASSERT messages_from_client[1].action == DETACH +``` + +--- + +## RTL5b - Detach from failed state results in error + +**Spec requirement:** If the channel state is FAILED, the detach request results in an error. + +Tests that detach fails when channel is in failed state. + +### Setup +```pseudo +channel_name = "test-RTL5b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Fail the attachment + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach fails - channel enters failed state +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.failed + +# Try to detach from failed state +AWAIT channel.detach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT channel.state == ChannelState.failed # State unchanged +``` + +--- + +## RTL5j - Detach from suspended transitions to detached + +**Spec requirement:** If the channel state is SUSPENDED, the detach request transitions the channel immediately to the DETACHED state. + +Tests that detach from suspended state transitions directly to detached without sending DETACH message. + +### Setup +```pseudo +channel_name = "test-RTL5j-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond - let it timeout to suspended + ELSE IF msg.action == DETACH: + detach_message_count++ + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 # Short timeout +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach +attach_future = channel.attach() + +# Let it timeout to suspended +ADVANCE_TIME(150) +AWAIT attach_future FAILS WITH error +ASSERT channel.state == ChannelState.suspended + +# Detach from suspended +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 0 # No DETACH message sent - immediate transition +``` + +--- + +## RTL5l - Detach when connection not connected transitions immediately + +**Spec requirement:** If the connection state is anything other than CONNECTED and none of the preceding channel state conditions apply, the channel transitions immediately to the DETACHED state. + +Tests that detach transitions immediately to detached when connection is not connected. + +### Setup +```pseudo +channel_name = "test-RTL5l-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay connection + }, + onMessageFromClient: (msg) => { + IF msg.action == DETACH: + detach_message_count++ + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Start connecting but don't complete +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Put channel into attaching state +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Now detach while connection is still connecting +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 0 # No DETACH message sent +``` + +--- + +## RTL5d - Normal detach flow + +**Spec requirement:** A DETACH ProtocolMessage is sent to the server, the state transitions to DETACHING and the channel becomes DETACHED when the confirmation DETACHED ProtocolMessage is received. + +Tests the normal detach flow when connection is connected. + +### Setup +```pseudo +channel_name = "test-RTL5d-${random_id()}" +captured_detach_message = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + captured_detach_message = msg + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() + +state_during_detach = null +channel.on(ChannelEvent.detaching).listen((change) => { + state_during_detach = channel.state +}) + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT state_during_detach == ChannelState.detaching +ASSERT channel.state == ChannelState.detached +ASSERT captured_detach_message IS NOT null +ASSERT captured_detach_message.action == DETACH +ASSERT captured_detach_message.channel == channel_name +``` + +--- + +## RTL5f - Detach timeout returns to previous state + +**Spec requirement:** If a DETACHED ProtocolMessage is not received within realtimeRequestTimeout, the detach request should be treated as though it has failed and the channel will return to its previous state. + +Tests detach timeout behavior. + +### Setup +```pseudo +channel_name = "test-RTL5f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + # Don't respond - simulate timeout + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 # Short timeout +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +detach_future = channel.detach() + +# Advance time past timeout +ADVANCE_TIME(150) + +AWAIT detach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached # Returns to previous state +ASSERT error IS NOT null +``` + +--- + +## RTL5k - ATTACHED received while detaching sends new DETACH + +**Spec requirement:** If the channel receives an ATTACHED message while in the DETACHING or DETACHED state, it should send a new DETACH message and remain in (or transition to) the DETACHING state. + +Tests that unexpected ATTACHED message during detach triggers new DETACH. + +### Setup +```pseudo +channel_name = "test-RTL5k-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + IF detach_message_count == 1: + # First DETACH: server sends ATTACHED instead of DETACHED + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE: + # Second DETACH: respond correctly + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() + +# Start detach +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 2 # Two DETACH messages sent +``` + +--- + +## RTL5k - ATTACHED received while detached sends DETACH + +**Spec requirement:** If the channel receives an ATTACHED message while in the DETACHED state, it should send a new DETACH message. + +Tests that unexpected ATTACHED message while detached triggers DETACH. + +### Setup +```pseudo +channel_name = "test-RTL5k-detached-${random_id()}" +detach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + detach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +ASSERT detach_message_count == 1 + +# Server unexpectedly sends ATTACHED while detached +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +# Wait for client to respond +AWAIT Future.delayed(Duration(milliseconds: 100)) +``` + +### Assertions +```pseudo +ASSERT detach_message_count == 2 # Client sent another DETACH +ASSERT channel.state == ChannelState.detached +``` + +--- + +## RTL5 - Detach emits state change events + +**Spec requirement:** Channel emits state change events during detach. + +Tests that appropriate state change events are emitted during detach. + +### Setup +```pseudo +channel_name = "test-RTL5-events-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +state_changes = [] +channel.on().listen((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +state_changes.clear() # Clear attach state changes + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT length(state_changes) >= 2 + +# First event: detaching +ASSERT state_changes[0].current == ChannelState.detaching +ASSERT state_changes[0].previous == ChannelState.attached +ASSERT state_changes[0].event == ChannelEvent.detaching + +# Second event: detached +ASSERT state_changes[1].current == ChannelState.detached +ASSERT state_changes[1].previous == ChannelState.detaching +ASSERT state_changes[1].event == ChannelEvent.detached +``` + +--- + +## RTL5 - Detach clears errorReason + +**Spec requirement:** Successful detach should clear any previous error. + +Tests that errorReason is cleared after successful detach. + +### Setup +```pseudo +channel_name = "test-RTL5-error-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach fails + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Denied") + )) + ELSE: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails +AWAIT channel.attach() FAILS WITH error +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +# Attach again succeeds +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Detach +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT channel.errorReason IS null +``` diff --git a/uts/realtime/unit/channels/channel_options.md b/uts/realtime/unit/channels/channel_options.md index 5406f6407..dd4e53ca2 100644 --- a/uts/realtime/unit/channels/channel_options.md +++ b/uts/realtime/unit/channels/channel_options.md @@ -128,6 +128,8 @@ Tests that get() with options sets them on new channels. ### Setup ```pseudo +channel_name = "test-RTS3b-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) channelOptions = RealtimeChannelOptions( @@ -138,7 +140,7 @@ channelOptions = RealtimeChannelOptions( ### Test Steps ```pseudo -channel = client.channels.get("test-channel", channelOptions) +channel = client.channels.get(channel_name, channelOptions) ``` ### Assertions @@ -157,11 +159,13 @@ Tests that get() with options updates existing channel (when no reattachment nee ### Setup ```pseudo +channel_name = "test-RTS3c-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) # Create channel with initial options initialOptions = RealtimeChannelOptions(attachOnSubscribe: false) -channel = client.channels.get("test-channel", initialOptions) +channel = client.channels.get(channel_name, initialOptions) ``` ### Test Steps @@ -171,7 +175,7 @@ newOptions = RealtimeChannelOptions( cipherParams: CipherParams.fromKey(someKey), attachOnSubscribe: true ) -sameChannel = client.channels.get("test-channel", newOptions) +sameChannel = client.channels.get(channel_name, newOptions) ``` ### Assertions @@ -191,10 +195,12 @@ Tests that get() throws error when params/modes change on attached channel. ### Setup ```pseudo +channel_name = "test-RTS3c1-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) # Create and attach channel -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) AWAIT channel.attach() ASSERT channel.state == ChannelState.attached ``` @@ -206,7 +212,7 @@ newOptions = RealtimeChannelOptions( params: {"rewind": "1"} # params triggers reattachment ) -client.channels.get("test-channel", newOptions) FAILS WITH error +client.channels.get(channel_name, newOptions) FAILS WITH error ASSERT error.code == 40000 # Channel options should not have changed @@ -223,9 +229,11 @@ Tests error when modes change on attaching channel. ### Setup ```pseudo +channel_name = "test-RTS3c1-attaching-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) # Put channel in attaching state (implementation detail) ``` @@ -235,7 +243,7 @@ newOptions = RealtimeChannelOptions( modes: [ChannelMode.subscribe] # modes triggers reattachment ) -client.channels.get("test-channel", newOptions) FAILS WITH error +client.channels.get(channel_name, newOptions) FAILS WITH error ASSERT error.code == 40000 ``` @@ -249,8 +257,10 @@ Tests that setOptions updates the channel options. ### Setup ```pseudo +channel_name = "test-RTL16-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) ``` ### Test Steps @@ -278,8 +288,10 @@ Tests that setOptions with params/modes on attached channel triggers reattachmen ### Setup ```pseudo +channel_name = "test-RTL16a-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) AWAIT channel.attach() ASSERT channel.state == ChannelState.attached ``` @@ -313,6 +325,8 @@ Tests that getDerived creates a channel with the correct derived name. ### Setup ```pseudo +base_channel_name = "test-RTS5a-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) deriveOptions = DeriveOptions(filter: "name == 'foo'") @@ -320,14 +334,14 @@ deriveOptions = DeriveOptions(filter: "name == 'foo'") ### Test Steps ```pseudo -channel = client.channels.getDerived("base-channel", deriveOptions) +channel = client.channels.getDerived(base_channel_name, deriveOptions) ``` ### Assertions ```pseudo # Channel name should be encoded with filter ASSERT channel.name STARTS WITH "[filter=" -ASSERT channel.name ENDS WITH "]base-channel" +ASSERT channel.name ENDS WITH "]" + base_channel_name ``` --- @@ -340,6 +354,8 @@ Tests that the filter expression is base64 encoded in the channel name. ### Setup ```pseudo +base_channel_name = "test-RTS5a1-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) filter = "name == 'test'" @@ -348,13 +364,13 @@ deriveOptions = DeriveOptions(filter: filter) ### Test Steps ```pseudo -channel = client.channels.getDerived("my-channel", deriveOptions) +channel = client.channels.getDerived(base_channel_name, deriveOptions) expectedEncoded = base64_encode(filter) # "bmFtZSA9PSAndGVzdCc=" ``` ### Assertions ```pseudo -ASSERT channel.name == "[filter=" + expectedEncoded + "]my-channel" +ASSERT channel.name == "[filter=" + expectedEncoded + "]" + base_channel_name ``` --- @@ -367,6 +383,8 @@ Tests that channel params are included in the derived channel name. ### Setup ```pseudo +base_channel_name = "test-RTS5a2-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) deriveOptions = DeriveOptions(filter: "type == 'message'") @@ -377,14 +395,14 @@ channelOptions = RealtimeChannelOptions( ### Test Steps ```pseudo -channel = client.channels.getDerived("events", deriveOptions, channelOptions) +channel = client.channels.getDerived(base_channel_name, deriveOptions, channelOptions) ``` ### Assertions ```pseudo # Parse the channel name to extract the qualifier and base name # Expected format: [filter=?param1=val1¶m2=val2]baseName -ASSERT channel.name ENDS WITH "]events" +ASSERT channel.name ENDS WITH "]" + base_channel_name # Extract the qualifier (everything between [ and ]) qualifier = extract_between(channel.name, "[", "]") @@ -413,6 +431,8 @@ Tests that getDerived passes options to the created channel. ### Setup ```pseudo +base_channel_name = "test-RTS5-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) deriveOptions = DeriveOptions(filter: "true") @@ -424,7 +444,7 @@ channelOptions = RealtimeChannelOptions( ### Test Steps ```pseudo -channel = client.channels.getDerived("test", deriveOptions, channelOptions) +channel = client.channels.getDerived(base_channel_name, deriveOptions, channelOptions) ``` ### Assertions diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md new file mode 100644 index 000000000..7fff18163 --- /dev/null +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -0,0 +1,633 @@ +# RealtimeChannel State and Events Tests + +Spec points: `RTL2`, `RTL2a`, `RTL2b`, `RTL2d`, `RTL2g`, `RTL2i`, `TH1`, `TH2`, `TH3`, `TH5`, `TH6` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL2b - Channel state attribute + +**Spec requirement:** `RealtimeChannel#state` attribute is the current state of the channel, of type `ChannelState`. + +Tests that channel has a state attribute of type ChannelState. + +### Setup +```pseudo +channel_name = "test-RTL2b-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel.state IS ChannelState +ASSERT channel.state == ChannelState.initialized +``` + +--- + +## RTL2b - Channel initial state is initialized + +**Spec requirement:** Channel state attribute reflects the current state. + +Tests that a newly created channel starts in the initialized state. + +### Setup +```pseudo +channel_name = "test-RTL2b-init-${random_id()}" + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +``` + +### Test Steps +```pseudo +channel = client.channels.get(channel_name) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.initialized +``` + +--- + +## RTL2a - State change events emitted for every state change + +**Spec requirement:** It emits a `ChannelState` `ChannelEvent` for every channel state change. + +Tests that state changes emit corresponding events. + +### Setup +```pseudo +channel_name = "test-RTL2a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +state_changes = [] +channel.on().listen((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Trigger attach - should emit attaching then attached +mock_ws.onMessageFromClient = (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) +} + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +# Should have emitted attaching and attached state changes +ASSERT length(state_changes) >= 2 +ASSERT state_changes[0].current == ChannelState.attaching +ASSERT state_changes[0].previous == ChannelState.initialized +ASSERT state_changes[1].current == ChannelState.attached +ASSERT state_changes[1].previous == ChannelState.attaching +``` + +--- + +## RTL2d, TH1, TH2, TH5 - ChannelStateChange object structure + +| Spec | Requirement | +|------|-------------| +| RTL2d | A ChannelStateChange object is emitted as the first argument for every ChannelEvent | +| TH1 | Whenever the channel state changes, a ChannelStateChange object is emitted | +| TH2 | Contains current state and previous state attributes | +| TH5 | Contains the event that generated the state change | + +Tests the structure of ChannelStateChange objects. + +### Setup +```pseudo +channel_name = "test-RTL2d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on().listen((change) => { + IF change.current == ChannelState.attaching: + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change IS ChannelStateChange +ASSERT captured_change.current == ChannelState.attaching +ASSERT captured_change.previous == ChannelState.initialized +ASSERT captured_change.event == ChannelEvent.attaching +``` + +--- + +## RTL2d, TH3 - ChannelStateChange includes error reason when applicable + +**Spec requirement:** Any state change triggered by a ProtocolMessage that contains an error member should populate the reason with that error. + +Tests that error information is included in state change when present. + +### Setup +```pseudo +channel_name = "test-RTL2d-error-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Server rejects attachment with error + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + code: 40160, + statusCode: 401, + message: "Channel denied" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.failed).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.current == ChannelState.failed +ASSERT captured_change.reason IS NOT null +ASSERT captured_change.reason.code == 40160 +ASSERT captured_change.reason.message == "Channel denied" +``` + +--- + +## RTL2 - Filtered event subscription + +**Spec requirement:** RealtimeChannel implements EventEmitter and emits ChannelEvent events. + +Tests that subscribing to a specific event only receives that event. + +### Setup +```pseudo +channel_name = "test-RTL2-filtered-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +attached_events = [] +channel.on(ChannelEvent.attached).listen((change) => attached_events.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +# Should only receive attached event, not attaching +ASSERT length(attached_events) == 1 +ASSERT attached_events[0].current == ChannelState.attached +ASSERT attached_events[0].event == ChannelEvent.attached +``` + +--- + +## RTL2g - UPDATE event for condition changes without state change + +**Spec requirement:** It emits an UPDATE ChannelEvent for changes to channel conditions for which the ChannelState does not change. + +Tests that UPDATE events are emitted when channel conditions change without state change. + +### Setup +```pseudo +channel_name = "test-RTL2g-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Server sends another ATTACHED message (e.g., after resume) +# This should trigger UPDATE, not a state change +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_RESUME # Indicates resumed attachment +)) + +# Wait for the event to be processed +AWAIT Future.delayed(Duration(milliseconds: 100)) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached # State unchanged +ASSERT length(update_events) >= 1 +ASSERT update_events[0].event == ChannelEvent.update +ASSERT update_events[0].current == ChannelState.attached +ASSERT update_events[0].previous == ChannelState.attached +``` + +--- + +## RTL2g - No duplicate state events + +**Spec requirement:** The library must never emit a ChannelState ChannelEvent for a state equal to the previous state. + +Tests that state events are not emitted when state doesn't actually change. + +### Setup +```pseudo +channel_name = "test-RTL2g-nodup-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +all_events = [] +channel.on().listen((change) => all_events.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +initial_count = length(all_events) + +# Server sends another ATTACHED message +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +AWAIT Future.delayed(Duration(milliseconds: 100)) +``` + +### Assertions +```pseudo +# Should have received UPDATE event, not another ATTACHED state event +# Count all events where current == attached AND event == attached (state event) +attached_state_events = filter(all_events, (e) => + e.current == ChannelState.attached AND e.event == ChannelEvent.attached +) +ASSERT length(attached_state_events) == 1 # Only the original attach +``` + +--- + +## RTL2i, TH6 - hasBacklog flag in ChannelStateChange + +| Spec | Requirement | +|------|-------------| +| RTL2i | ChannelStateChange may expose hasBacklog property | +| TH6 | hasBacklog indicates whether channel should expect backlog from resume/rewind | + +Tests that hasBacklog is set when ATTACHED message contains HAS_BACKLOG flag. + +### Setup +```pseudo +channel_name = "test-RTL2i-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_BACKLOG + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.attached).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.hasBacklog == true +``` + +--- + +## RTL2i - hasBacklog false when flag not present + +**Spec requirement:** hasBacklog should only be true when ATTACHED message contains HAS_BACKLOG flag. + +Tests that hasBacklog is false when the flag is not present. + +### Setup +```pseudo +channel_name = "test-RTL2i-false-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + # No HAS_BACKLOG flag + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.attached).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.hasBacklog == false OR captured_change.hasBacklog IS null +``` + +--- + +## RTL2d - resumed flag in ChannelStateChange + +**Spec requirement:** ChannelStateChange has a resumed property indicating whether the ATTACHED message had the RESUMED flag set. + +Tests that resumed flag is correctly propagated. + +### Setup +```pseudo +channel_name = "test-RTL2d-resumed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: RESUMED + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) + +captured_change = null +channel.on(ChannelEvent.attached).listen((change) => { + captured_change = change +}) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT captured_change IS NOT null +ASSERT captured_change.resumed == true +``` + +--- + +## Channel errorReason attribute + +**Spec requirement:** Channel should expose error information when in failed state. + +Tests that errorReason is populated when channel enters failed state. + +### Setup +```pseudo +channel_name = "test-errorReason-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + code: 40160, + statusCode: 401, + message: "Not authorized" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.message == "Not authorized" +``` + +--- + +## Channel errorReason cleared on successful attach + +**Spec requirement:** Error reason should be cleared when channel successfully attaches. + +Tests that errorReason is cleared after successful attach following a failure. + +### Setup +```pseudo +channel_name = "test-errorReason-clear-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach fails + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Denied") + )) + ELSE: + # Second attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails +AWAIT channel.attach() FAILS WITH error +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +# Second attach succeeds +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT channel.errorReason IS null +``` diff --git a/uts/realtime/unit/channels/channels_collection.md b/uts/realtime/unit/channels/channels_collection.md index 8a7ba4ff1..a1924bf93 100644 --- a/uts/realtime/unit/channels/channels_collection.md +++ b/uts/realtime/unit/channels/channels_collection.md @@ -41,22 +41,25 @@ Tests the `exists()` method returns correct boolean for existing and non-existin ### Setup ```pseudo +channel_name = "test-RTS2-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Before creating any channel -exists_before = client.channels.exists("test-channel") +exists_before = client.channels.exists(channel_name) # Create the channel -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) # After creating the channel -exists_after = client.channels.exists("test-channel") +exists_after = client.channels.exists(channel_name) # Check for non-existent channel -exists_other = client.channels.exists("other-channel") +other_channel_name = "test-RTS2-other-${random_id()}" +exists_other = client.channels.exists(other_channel_name) ``` ### Assertions @@ -76,15 +79,19 @@ Tests that channel names can be iterated. ### Setup ```pseudo +channel_name_a = "test-RTS2-a-${random_id()}" +channel_name_b = "test-RTS2-b-${random_id()}" +channel_name_c = "test-RTS2-c-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Create several channels -client.channels.get("channel-a") -client.channels.get("channel-b") -client.channels.get("channel-c") +client.channels.get(channel_name_a) +client.channels.get(channel_name_b) +client.channels.get(channel_name_c) # Get all channel names names = client.channels.names @@ -92,9 +99,9 @@ names = client.channels.names ### Assertions ```pseudo -ASSERT "channel-a" IN names -ASSERT "channel-b" IN names -ASSERT "channel-c" IN names +ASSERT channel_name_a IN names +ASSERT channel_name_b IN names +ASSERT channel_name_c IN names ASSERT length(names) == 3 ``` @@ -108,20 +115,22 @@ Tests that `get()` creates a new channel when called with a new name. ### Setup ```pseudo +channel_name = "test-RTS3a-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Get a channel that doesn't exist yet -channel = client.channels.get("new-channel") +channel = client.channels.get(channel_name) ``` ### Assertions ```pseudo ASSERT channel IS RealtimeChannel -ASSERT channel.name == "new-channel" -ASSERT client.channels.exists("new-channel") == true +ASSERT channel.name == channel_name +ASSERT client.channels.exists(channel_name) == true ``` --- @@ -134,23 +143,25 @@ Tests that `get()` returns the same channel instance when called multiple times. ### Setup ```pseudo +channel_name = "test-RTS3a-existing-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Get a channel -channel1 = client.channels.get("test-channel") +channel1 = client.channels.get(channel_name) # Get the same channel again -channel2 = client.channels.get("test-channel") +channel2 = client.channels.get(channel_name) ``` ### Assertions ```pseudo ASSERT channel1 IS SAME AS channel2 # Same object reference -ASSERT channel1.name == "test-channel" -ASSERT channel2.name == "test-channel" +ASSERT channel1.name == channel_name +ASSERT channel2.name == channel_name ``` --- @@ -163,26 +174,28 @@ Tests that the subscript operator `[]` behaves the same as `get()`. ### Setup ```pseudo +channel_name = "test-RTS3a-subscript-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Use subscript to get channel -channel1 = client.channels["test-channel"] +channel1 = client.channels[channel_name] # Use get() to get same channel -channel2 = client.channels.get("test-channel") +channel2 = client.channels.get(channel_name) # Use subscript again -channel3 = client.channels["test-channel"] +channel3 = client.channels[channel_name] ``` ### Assertions ```pseudo ASSERT channel1 IS SAME AS channel2 ASSERT channel2 IS SAME AS channel3 -ASSERT channel1.name == "test-channel" +ASSERT channel1.name == channel_name ``` --- @@ -195,22 +208,24 @@ Tests that `release()` removes the channel from the collection. ### Setup ```pseudo +channel_name = "test-RTS4a-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Create a channel -channel = client.channels.get("test-channel") -ASSERT client.channels.exists("test-channel") == true +channel = client.channels.get(channel_name) +ASSERT client.channels.exists(channel_name) == true # Release the channel -AWAIT client.channels.release("test-channel") +AWAIT client.channels.release(channel_name) ``` ### Assertions ```pseudo -ASSERT client.channels.exists("test-channel") == false +ASSERT client.channels.exists(channel_name) == false ``` --- @@ -223,19 +238,21 @@ Tests that releasing a channel that doesn't exist completes without error. ### Setup ```pseudo +channel_name = "test-RTS4a-nonexistent-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Release a channel that was never created -AWAIT client.channels.release("nonexistent-channel") +AWAIT client.channels.release(channel_name) ``` ### Assertions ```pseudo # Should complete without throwing -ASSERT client.channels.exists("nonexistent-channel") == false +ASSERT client.channels.exists(channel_name) == false ``` --- @@ -248,13 +265,15 @@ Tests that releasing an attached channel detaches it first. ### Setup ```pseudo +channel_name = "test-RTS4a-attached-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) ``` ### Test Steps ```pseudo # Create and attach a channel -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) AWAIT channel.attach() ASSERT channel.state == ChannelState.attached @@ -262,13 +281,13 @@ ASSERT channel.state == ChannelState.attached state_before_release = channel.state # Release the channel -AWAIT client.channels.release("test-channel") +AWAIT client.channels.release(channel_name) ``` ### Assertions ```pseudo ASSERT state_before_release == ChannelState.attached -ASSERT client.channels.exists("test-channel") == false +ASSERT client.channels.exists(channel_name) == false # Channel should have been detached before removal ``` @@ -282,24 +301,26 @@ Tests that getting a channel after release creates a fresh instance. ### Setup ```pseudo +channel_name = "test-RTS3a-release-${random_id()}" + client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret")) ``` ### Test Steps ```pseudo # Create a channel -channel1 = client.channels.get("test-channel") +channel1 = client.channels.get(channel_name) # Release it -AWAIT client.channels.release("test-channel") +AWAIT client.channels.release(channel_name) # Get the same channel name again -channel2 = client.channels.get("test-channel") +channel2 = client.channels.get(channel_name) ``` ### Assertions ```pseudo ASSERT channel1 IS NOT SAME AS channel2 # Different object instances -ASSERT channel2.name == "test-channel" -ASSERT client.channels.exists("test-channel") == true +ASSERT channel2.name == channel_name +ASSERT client.channels.exists(channel_name) == true ``` diff --git a/uts/realtime/unit/client/realtime_client.md b/uts/realtime/unit/client/realtime_client.md index 18f7c247f..8843a9dc8 100644 --- a/uts/realtime/unit/client/realtime_client.md +++ b/uts/realtime/unit/client/realtime_client.md @@ -56,9 +56,9 @@ The Realtime client has the same constructors as the REST client. **See:** `uts/test/realtime/unit/client/client_options.md` - RSC1, RSC1a, RSC1c The same test cases apply: -- API key string (`"appId.keyId:keySecret"`) → Basic auth -- Token string (no `:` delimiter) → Token auth -- Empty string → Error +- API key string (`"appId.keyId:keySecret"`) -> Basic auth +- Token string (no `:` delimiter) -> Token auth +- Empty string -> Error --- @@ -109,6 +109,8 @@ Tests that `RealtimeClient#channels` provides access to the Channels collection. ### Setup ```pseudo +channel_name = "test-RTC3-${random_id()}" + mock_ws = create_mock_websocket() install_mock(mock_ws) @@ -124,9 +126,9 @@ ASSERT client.channels IS NOT null ASSERT client.channels IS Channels # Should be able to get/create channels -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) ASSERT channel IS RealtimeChannel -ASSERT channel.name == "test-channel" +ASSERT channel.name == channel_name ``` --- diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md index 10560f7a8..7cd52f05d 100644 --- a/uts/realtime/unit/connection/fallback_hosts_test.md +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -21,6 +21,7 @@ Tests that the client always tries the primary domain first, even after failures ### Setup ```pseudo +channel_name = "test-RTN17i-${random_id()}" connection_attempts = [] mock_ws = MockWebSocket( @@ -261,6 +262,7 @@ Tests that connectivity check is performed before trying fallback hosts. ### Setup ```pseudo +channel_name = "test-RTN17j-${random_id()}" http_requests = [] connection_attempts = [] @@ -552,6 +554,7 @@ Tests that HTTP requests prefer the same host as the active realtime connection. ### Setup ```pseudo +channel_name = "test-RTN17e-${random_id()}" connection_attempts = [] http_requests = [] @@ -619,7 +622,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected connected_fallback_host = connection_attempts[1] # Make an HTTP request (e.g., channel history) -channel = client.channels.get("test-channel") +channel = client.channels.get(channel_name) await channel.history() # Wait for HTTP request to complete diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index 67a4f40e1..e71f0ca88 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -20,6 +20,8 @@ Tests that the client disconnects when no server activity is detected. ### Setup ```pseudo +channel_name = "test-RTN23a-${random_id()}" + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() @@ -90,6 +92,8 @@ Tests that receiving HEARTBEAT messages keeps the connection alive. ### Setup ```pseudo +channel_name = "test-RTN23a-heartbeat-${random_id()}" + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() @@ -171,6 +175,8 @@ Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connect ### Setup ```pseudo +channel_name = "test-RTN23a-message-${random_id()}" + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { conn.respond_with_success() @@ -224,7 +230,7 @@ ASSERT client.connection.state == ConnectionState.connected # Send MESSAGE from server mock_ws.active_connection.send_to_client(ProtocolMessage( action: MESSAGE, - channel: "test-channel", + channel: channel_name, messages: [ Message(name: "event", data: "data") ] From 2ad36e96315d3557c08ae6f8518c74e89970e346 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 06/32] Rewrite heartbeat test specs and extend mock WebSocket helper Substantially rework the heartbeat test specs for better coverage of RTN23 (heartbeat monitoring) and extend the mock WebSocket helper with additional transport simulation capabilities. Update the write-test-spec skill with improved patterns. --- uts/.claude/skills/write-test-spec.md | 56 +- .../unit/connection/heartbeat_test.md | 794 +++++++++++++++--- uts/realtime/unit/helpers/mock_websocket.md | 209 ++++- 3 files changed, 934 insertions(+), 125 deletions(-) diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index 625060283..517535dfc 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -430,21 +430,73 @@ For unit tests, any string works as a token value since tokens are opaque to the ## Avoiding Flaky Tests -**Never use fixed WAITs.** Use polling instead: +**Never use fixed WAITs or arbitrary real-time delays.** ```pseudo # Bad - flaky WAIT 5 seconds ASSERT condition -# Good - reliable +# Bad - arbitrary delay that may not be enough (or may be too slow) +ADVANCE_TIME(3000) +WAIT 100ms # Real-time delay - flaky! +ASSERT state == disconnected + +# Good - poll until condition poll_until( condition, interval: 500ms, timeout: 10s ) + +# Good - pump event queue and wait for state +ADVANCE_TIME(3000) +PUMP_EVENT_QUEUE() +AWAIT_STATE state == disconnected +``` + +### Pumping the Event Queue + +After advancing fake timers, async callbacks may be scheduled but not yet executed. Use `PUMP_EVENT_QUEUE()` to process pending microtasks and timer events: + +```pseudo +ADVANCE_TIME(5000) # Schedules timeout callback +PUMP_EVENT_QUEUE() # Executes scheduled callbacks +AWAIT_STATE state == x # Wait for resulting state change ``` +In Dart, this is typically `await Future.delayed(Duration.zero)`. Multiple chained async operations may require multiple pumps. + +### Verifying Transient States + +When testing behavior involving transient states (e.g., DISCONNECTED during reconnection), **do not** try to catch the state at a specific moment. Instead, record the full sequence of state changes and verify it at the end: + +```pseudo +state_changes = [] +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) + +# Trigger behavior +ADVANCE_TIME(timeout_duration) +PUMP_EVENT_QUEUE() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Verify sequence included expected states +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, # Transient state we want to verify + ConnectionState.connecting, + ConnectionState.connected +] +``` + +This approach is robust because: +- It doesn't depend on catching a transient state at exactly the right moment +- It works even when immediate reconnection (RTN15a) causes rapid state transitions +- It verifies the complete behavior, not just the final state + ## Test Structure Each test should have three sections: diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index e71f0ca88..9a89b1644 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -9,23 +9,88 @@ Unit test with mocked WebSocket client See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. +## Overview + +RTN23 defines how the client detects connection liveness: + +- **RTN23a**: The client must disconnect if no activity is received for `maxIdleInterval + realtimeRequestTimeout`. Any received message (or ping frame, per RTN23b) resets this timer. + +- **RTN23b**: The client may use either: + 1. **HEARTBEAT protocol messages** (`heartbeats=true` in connection URL) - for platforms where the WebSocket client does NOT surface ping events + 2. **WebSocket ping frames** (`heartbeats=false` or omitted) - for platforms where the WebSocket client CAN surface ping events + +A concrete implementation should implement either RTN23a with HEARTBEAT messages OR RTN23b with ping frames, depending on platform capabilities. The test cases below cover both approaches. + --- -## RTN23a - Disconnect after maxIdleInterval + realtimeRequestTimeout +# RTN23a Tests (HEARTBEAT Protocol Messages) -**Spec requirement:** If no message is received from the server for maxIdleInterval + realtimeRequestTimeout milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state. +These tests apply to platforms where the WebSocket client does NOT surface ping frame events. The client must send `heartbeats=true` in the connection URL. + +--- -Tests that the client disconnects when no server activity is detected. +## RTN23a - Client sends heartbeats=true when ping frames not observable + +**Spec requirement:** If the client cannot observe WebSocket ping frames, it should send `heartbeats=true` in the connection query parameters. + +Tests that the client requests HEARTBEAT protocol messages. ### Setup ```pseudo -channel_name = "test-RTN23a-${random_id()}" +captured_url = null mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { - conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + captured_url = conn.url + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Client should request heartbeats if it cannot observe ping frames +ASSERT captured_url.query_params["heartbeats"] == "true" +``` + +--- + +## RTN23a - Disconnect after maxIdleInterval + realtimeRequestTimeout + +**Spec requirement:** If no message is received from the server for `maxIdleInterval + realtimeRequestTimeout` milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state. + +Tests that the client disconnects and closes the WebSocket when no server activity is detected. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( action: CONNECTED, connectionId: "connection-id", connectionKey: "connection-key", @@ -52,33 +117,25 @@ client = Realtime(options: ClientOptions( ```pseudo enable_fake_timers() -# Start connection client.connect() - -# Wait for CONNECTED state AWAIT_STATE client.connection.state == ConnectionState.connected - WITH timeout: 5 seconds # Advance time past maxIdleInterval + realtimeRequestTimeout # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) -# Should transition to DISCONNECTED AWAIT_STATE client.connection.state == ConnectionState.disconnected - WITH timeout: 1 second ``` ### Assertions ```pseudo -# Connection transitioned to DISCONNECTED ASSERT client.connection.state == ConnectionState.disconnected - -# Error reason indicates timeout/inactivity ASSERT client.connection.errorReason IS NOT null -ASSERT client.connection.errorReason.message CONTAINS "idle" - OR client.connection.errorReason.message CONTAINS "heartbeat" - OR client.connection.errorReason.message CONTAINS "timeout" + +# Verify the client closed the WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 ``` --- @@ -87,17 +144,14 @@ ASSERT client.connection.errorReason.message CONTAINS "idle" **Spec requirement:** Any message from the server, including HEARTBEAT messages, resets the idle timer. -Tests that receiving HEARTBEAT messages keeps the connection alive. +Tests that receiving HEARTBEAT messages keeps the connection alive, and that the client closes the WebSocket when it eventually times out. ### Setup ```pseudo -channel_name = "test-RTN23a-heartbeat-${random_id()}" - mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { - conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.respond_with_success(ProtocolMessage( action: CONNECTED, connectionId: "connection-id", connectionKey: "connection-key", @@ -123,45 +177,37 @@ client = Realtime(options: ClientOptions( ```pseudo enable_fake_timers() -# Start connection client.connect() - -# Wait for CONNECTED state AWAIT_STATE client.connection.state == ConnectionState.connected -# Advance time (not enough to trigger timeout) -ADVANCE_TIME(2000) # 2 seconds +# Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) +ADVANCE_TIME(2000) -# Send HEARTBEAT from server +# Send HEARTBEAT from server - resets timer mock_ws.active_connection.send_to_client(ProtocolMessage( action: HEARTBEAT )) -# Advance time again (should still be connected) -ADVANCE_TIME(2000) # Total 4 seconds, but timer reset at 2 seconds +# Advance time again (2000ms since HEARTBEAT, still within threshold) +ADVANCE_TIME(2000) # Connection should still be alive -WAIT(500) - ASSERT client.connection.state == ConnectionState.connected -# Advance time past the new timeout window -ADVANCE_TIME(2100) # Now 2100ms since last HEARTBEAT +# Advance time past the timeout window (4100ms since last HEARTBEAT) +ADVANCE_TIME(2100) -# Should disconnect now AWAIT_STATE client.connection.state == ConnectionState.disconnected - WITH timeout: 1 second ``` ### Assertions ```pseudo -# Connection stayed alive after HEARTBEAT -# Then disconnected after no more messages ASSERT client.connection.state == ConnectionState.disconnected -# Error reason indicates timeout -ASSERT client.connection.errorReason IS NOT null +# Verify the client closed the WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 ``` --- @@ -170,7 +216,7 @@ ASSERT client.connection.errorReason IS NOT null **Spec requirement:** Any message from the server resets the idle timer, not just HEARTBEAT messages. -Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive. +Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive, and that the client closes the WebSocket when it eventually times out. ### Setup @@ -179,8 +225,7 @@ channel_name = "test-RTN23a-message-${random_id()}" mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { - conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.respond_with_success(ProtocolMessage( action: CONNECTED, connectionId: "connection-id", connectionKey: "connection-key", @@ -206,16 +251,13 @@ client = Realtime(options: ClientOptions( ```pseudo enable_fake_timers() -# Start connection client.connect() - -# Wait for CONNECTED state AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time ADVANCE_TIME(1500) -# Send ACK message from server +# Send ACK message from server - resets timer mock_ws.active_connection.send_to_client(ProtocolMessage( action: ACK, msgSerial: 0 @@ -227,7 +269,7 @@ ADVANCE_TIME(1500) # Connection should still be alive (timer was reset) ASSERT client.connection.state == ConnectionState.connected -# Send MESSAGE from server +# Send MESSAGE from server - resets timer again mock_ws.active_connection.send_to_client(ProtocolMessage( action: MESSAGE, channel: channel_name, @@ -245,39 +287,191 @@ ASSERT client.connection.state == ConnectionState.connected # Advance time past timeout without any message ADVANCE_TIME(1600) -# Should disconnect now AWAIT_STATE client.connection.state == ConnectionState.disconnected - WITH timeout: 1 second ``` ### Assertions ```pseudo -# Connection stayed alive with various message types -# Then disconnected after no more messages ASSERT client.connection.state == ConnectionState.disconnected + +# Verify the client closed the WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +``` + +--- + +## RTN23a - Heartbeat timeout triggers immediate reconnection + +**Spec requirement:** When a heartbeat timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). + +Tests that the client attempts to reconnect after a heartbeat timeout. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT connection_attempt_count == 1 + +# Advance time past maxIdleInterval + realtimeRequestTimeout to trigger timeout +# = 2000 + 1000 = 3000ms +ADVANCE_TIME(3100) + +# Client should disconnect +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Client should immediately attempt to reconnect (RTN15a) +# Allow time for the reconnection attempt +ADVANCE_TIME(100) + +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify the client is now connected with new connection details +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "connection-id-2" + +# Verify the first connection was closed by the client +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 ``` --- -## RTN23b - Client can request heartbeats in query params +## RTN23a - Reconnection after heartbeat timeout uses resume -**Spec requirement:** The client can request heartbeats by including heartbeats=true in the connection query parameters. +**Spec requirement:** When reconnecting after a heartbeat timeout, the client should attempt to resume the connection using the previous connectionKey (per RTN15c). -Tests that the client can enable/disable heartbeats via query parameters. +Tests that the reconnection attempt includes the resume parameters. ### Setup ```pseudo -connection_urls = [] +connection_attempts = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { - # Record the connection URL - connection_urls.push(conn.url) - - conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + connection_attempts.append({ + url: conn.url, + attempt_number: connection_attempts.length + 1 + }) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempts.length, + connectionKey: "connection-key-" + connection_attempts.length, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempts.length, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past timeout to trigger disconnection +ADVANCE_TIME(3100) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Allow reconnection +ADVANCE_TIME(100) + +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +ASSERT connection_attempts.length == 2 + +# First connection should not have resume parameter +first_url = connection_attempts[0].url +ASSERT "resume" NOT IN first_url.query_params + +# Second connection should include resume parameter with first connectionKey +second_url = connection_attempts[1].url +ASSERT second_url.query_params["resume"] == "connection-key-1" +``` + +--- + +# RTN23b Tests (WebSocket Ping Frames) + +These tests apply to platforms where the WebSocket client CAN surface ping frame events. The client should send `heartbeats=false` (or omit the parameter) in the connection URL. + +--- + +## RTN23b - Client sends heartbeats=false when ping frames observable + +**Spec requirement:** If the client can observe WebSocket ping frames, it should send `heartbeats=false` (or omit the parameter) in the connection query parameters. + +Tests that the client does not request HEARTBEAT protocol messages when it can observe ping frames. + +### Setup + +```pseudo +captured_url = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + captured_url = conn.url + conn.respond_with_success(ProtocolMessage( action: CONNECTED, connectionId: "connection-id", connectionKey: "connection-key", @@ -291,16 +485,118 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Client with default behavior (heartbeats enabled) -client1 = Realtime(options: ClientOptions( +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Client should NOT request heartbeats if it can observe ping frames +ASSERT captured_url.query_params["heartbeats"] == "false" + OR "heartbeats" NOT IN captured_url.query_params +``` + +--- + +## RTN23b - Disconnect after maxIdleInterval + realtimeRequestTimeout (no ping frames) + +**Spec requirement:** If no activity (including ping frames) is received for `maxIdleInterval + realtimeRequestTimeout`, disconnect. + +Tests that the client disconnects and closes the WebSocket when no ping frames or messages are received. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 5000, # 5 seconds + connectionStateTtl: 120000 + ) + )) + # Server sends CONNECTED but then no further messages or ping frames + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, # 2 seconds autoConnect: false )) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past maxIdleInterval + realtimeRequestTimeout +# = 5000 + 2000 = 7000ms +ADVANCE_TIME(7100) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions + +```pseudo +ASSERT client.connection.state == ConnectionState.disconnected +ASSERT client.connection.errorReason IS NOT null + +# Verify the client closed the WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +``` + +--- + +## RTN23b - Ping frame resets idle timer + +**Spec requirement:** WebSocket ping frames count as activity indication and reset the idle timer. + +Tests that receiving ping frames keeps the connection alive, and that the client closes the WebSocket when it eventually times out. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 3000, # 3 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) -# Client with heartbeats explicitly disabled -client2 = Realtime(options: ClientOptions( +client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", - closeOnUnload: false, # Or another option that disables heartbeats + realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) ``` @@ -308,54 +604,55 @@ client2 = Realtime(options: ClientOptions( ### Test Steps ```pseudo -# Connect first client (default, heartbeats enabled) -client1.connect() +enable_fake_timers() -AWAIT_STATE client1.connection.state == ConnectionState.connected - WITH timeout: 5 seconds +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected -# Check URL includes heartbeats=true -url1 = connection_urls[0] +# Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) +ADVANCE_TIME(2000) -await client1.close() +# Server sends ping frame - resets timer +mock_ws.active_connection.send_ping_frame() -# Connect second client (heartbeats disabled) -client2.connect() +# Advance time again (2000ms since ping, still within threshold) +ADVANCE_TIME(2000) -AWAIT_STATE client2.connection.state == ConnectionState.connected - WITH timeout: 5 seconds +# Connection should still be alive +ASSERT client.connection.state == ConnectionState.connected + +# Advance time past the timeout window (4100ms since last ping) +ADVANCE_TIME(2100) -# Check URL includes heartbeats=false -url2 = connection_urls[1] +AWAIT_STATE client.connection.state == ConnectionState.disconnected ``` ### Assertions ```pseudo -# First client requested heartbeats -ASSERT url1.query_params CONTAINS "heartbeats=true" - OR "heartbeats" NOT IN url1.query_params # Default is true +ASSERT client.connection.state == ConnectionState.disconnected -# Second client disabled heartbeats -ASSERT url2.query_params CONTAINS "heartbeats=false" - OR (implementation specific way to disable) +# Verify the client closed the WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 ``` --- -## RTN23b - Server respects heartbeats=false +## RTN23b - Any protocol message also resets idle timer -**Spec requirement:** If the client sends heartbeats=false, the server should not send HEARTBEAT messages and the client should not expect them. +**Spec requirement:** Any message from the server resets the idle timer, not just ping frames. -Tests that disabling heartbeats prevents timeout when no HEARTBEATs are sent. +Tests that both ping frames AND protocol messages reset the timer, and that the client closes the WebSocket when it eventually times out. ### Setup ```pseudo +channel_name = "test-RTN23b-message-${random_id()}" + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { - conn.respond_with_success() - conn.send_to_client(ProtocolMessage( + conn.respond_with_success(ProtocolMessage( action: CONNECTED, connectionId: "connection-id", connectionKey: "connection-key", @@ -365,14 +662,13 @@ mock_ws = MockWebSocket( connectionStateTtl: 120000 ) )) - # Server sends no HEARTBEAT messages } ) install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", - # Configure to disable heartbeats (implementation-specific) + realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) ``` @@ -382,36 +678,291 @@ client = Realtime(options: ClientOptions( ```pseudo enable_fake_timers() -# Start connection client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected -# Wait for CONNECTED state +# Advance time +ADVANCE_TIME(1500) + +# Send ping frame - resets timer +mock_ws.active_connection.send_ping_frame() + +# Advance time +ADVANCE_TIME(1500) + +# Still connected +ASSERT client.connection.state == ConnectionState.connected + +# Send MESSAGE from server - also resets timer +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event", data: "data") + ] +)) + +# Advance time +ADVANCE_TIME(1500) + +# Still connected +ASSERT client.connection.state == ConnectionState.connected + +# Send another ping frame +mock_ws.active_connection.send_ping_frame() + +# Advance time +ADVANCE_TIME(1500) + +# Still connected +ASSERT client.connection.state == ConnectionState.connected + +# Advance time past timeout without any activity +ADVANCE_TIME(1600) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions + +```pseudo +ASSERT client.connection.state == ConnectionState.disconnected + +# Verify the client closed the WebSocket connection +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +``` + +--- + +## RTN23b - Ping frame timeout triggers immediate reconnection + +**Spec requirement:** When a ping frame timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). + +Tests that the client attempts to reconnect after a ping frame timeout. + +### Setup + +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempt_count, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Advance time well past maxIdleInterval -ADVANCE_TIME(10000) # 10 seconds +ASSERT connection_attempt_count == 1 + +# Advance time past maxIdleInterval + realtimeRequestTimeout to trigger timeout +# = 2000 + 1000 = 3000ms +ADVANCE_TIME(3100) + +# Client should disconnect +AWAIT_STATE client.connection.state == ConnectionState.disconnected -# Connection should remain CONNECTED (no heartbeat expectation) -# Note: This test may vary by implementation - some SDKs always -# expect some server activity even with heartbeats=false +# Client should immediately attempt to reconnect (RTN15a) +# Allow time for the reconnection attempt +ADVANCE_TIME(100) + +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify the client is now connected with new connection details +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "connection-id-2" + +# Verify the first connection was closed by the client +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +``` + +--- + +## RTN23b - Reconnection after ping frame timeout uses resume + +**Spec requirement:** When reconnecting after a ping frame timeout, the client should attempt to resume the connection using the previous connectionKey (per RTN15c). + +Tests that the reconnection attempt includes the resume parameters. + +### Setup + +```pseudo +connection_attempts = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempts.append({ + url: conn.url, + attempt_number: connection_attempts.length + 1 + }) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + connection_attempts.length, + connectionKey: "connection-key-" + connection_attempts.length, + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + connection_attempts.length, + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past timeout to trigger disconnection +ADVANCE_TIME(3100) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Allow reconnection +ADVANCE_TIME(100) + +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -# Connection behavior when heartbeats disabled is implementation-specific -# Either: -# A) Connection stays alive indefinitely without messages -# B) Connection has a much longer timeout -# C) Connection still times out but with different threshold +ASSERT connection_attempts.length == 2 + +# First connection should not have resume parameter +first_url = connection_attempts[0].url +ASSERT "resume" NOT IN first_url.query_params + +# Second connection should include resume parameter with first connectionKey +second_url = connection_attempts[1].url +ASSERT second_url.query_params["resume"] == "connection-key-1" +``` + +--- + +## RTN23b - Multiple ping frames keep connection alive + +**Spec requirement:** Continuous ping frame activity keeps the connection alive indefinitely. + +Tests that regular ping frames prevent timeout. + +### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 2000, # 2 seconds + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 1000, # 1 second + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +enable_fake_timers() -# Verify the implementation's documented behavior -ASSERT client.connection.state IN [ConnectionState.connected, ConnectionState.disconnected] +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate regular ping frames every 1.5 seconds for 10 seconds +FOR i IN 1..7: + ADVANCE_TIME(1500) + mock_ws.active_connection.send_ping_frame() + ASSERT client.connection.state == ConnectionState.connected +``` + +### Assertions + +```pseudo +# Connection stayed alive through all ping frames +ASSERT client.connection.state == ConnectionState.connected ``` --- -## Timer Mocking Note +# Implementation Notes + +## Choosing Between RTN23a and RTN23b + +A concrete SDK implementation should: + +1. **Determine platform capability**: Can the WebSocket client surface ping frame events? + +2. **If YES (ping frames observable)**: + - Send `heartbeats=false` (or omit) in connection URL + - Listen for ping frame events as heartbeat indicators + - Implement RTN23b tests + +3. **If NO (ping frames not observable)**: + - Send `heartbeats=true` in connection URL + - Expect HEARTBEAT protocol messages from server + - Implement RTN23a tests + +### Platform-Specific Notes + +**Dart:** The standard `dart:io` WebSocket does **not** surface ping frames to the application layer. The ping/pong mechanism is handled automatically and internally - there is no `onPing` callback. Therefore, Dart implementations must use **RTN23a** (HEARTBEAT protocol messages) for idle timeout detection. The RTN23b tests do not apply to Dart. + +## Timer Mocking These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. Implementations should: @@ -420,4 +971,31 @@ These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. 3. **Or use very short timeout values** (e.g., 50ms instead of 5s) 4. **Last resort:** Use actual delays with generous test timeouts +## Verifying Transient States + +When testing heartbeat timeout behavior, the connection may pass through DISCONNECTED state very quickly due to immediate reconnection (RTN15a). Do not attempt to catch the DISCONNECTED state directly - instead, record the full sequence of state changes and verify it at the end: + +```pseudo +state_changes = [] +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) + +# Trigger timeout and reconnection +ADVANCE_TIME(maxIdleInterval + realtimeRequestTimeout + buffer) +PUMP_EVENT_QUEUE() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Verify the sequence included DISCONNECTED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] +``` + +See `mock_websocket.md` for more details on event sequence verification. + See the "Timer Mocking" section in `write-test-spec.md` for detailed guidance. diff --git a/uts/realtime/unit/helpers/mock_websocket.md b/uts/realtime/unit/helpers/mock_websocket.md index 39fb9b7ea..cb8a94b95 100644 --- a/uts/realtime/unit/helpers/mock_websocket.md +++ b/uts/realtime/unit/helpers/mock_websocket.md @@ -33,28 +33,40 @@ interface MockWebSocket: send_to_client_and_close(message: ProtocolMessage) # Send then close connection simulate_disconnect(error?: ErrorInfo) # Close without sending a message + # WebSocket ping frame simulation (for RTN23b) + # Simulates the server sending a WebSocket ping frame. + # On platforms where the WebSocket client surfaces ping events, + # this allows testing heartbeat behavior via ping frames instead of + # HEARTBEAT protocol messages. + send_ping_frame() + # Awaitable event triggers for test code await_next_message_from_client(timeout?: Duration): Future await_connection_attempt(timeout?: Duration): Future - await_close_request(timeout?: Duration): Future + await_client_close(timeout?: Duration): Future # Wait for client to close WebSocket # Test management reset() # Clear all state enum MockEventType: - CONNECTION_ATTEMPT - CONNECTION_SUCCESS - CONNECTION_FAILURE - MESSAGE_FROM_CLIENT - MESSAGE_TO_CLIENT - DISCONNECT - CLOSE_REQUEST + CONNECTION_ATTEMPT # Client attempted to connect + CONNECTION_SUCCESS # Connection established successfully + CONNECTION_FAILURE # Connection failed (refused, timeout, DNS error, etc.) + MESSAGE_FROM_CLIENT # Client sent a protocol message + MESSAGE_TO_CLIENT # Server sent a protocol message (test injected) + PING_FRAME # WebSocket ping frame sent to client (test injected) + SERVER_DISCONNECT # Server closed the connection or transport failure + CLIENT_CLOSE # Client initiated WebSocket close struct MockEvent: type: MockEventType timestamp: Time data: Any # Event-specific data (PendingConnection, ProtocolMessage, ErrorInfo, etc.) +struct ClientCloseEvent: + code: Int? # WebSocket close code (e.g., 1000 for normal closure) + reason: String? # Optional close reason + interface PendingConnection: url: URL protocol: String # "application/json" or "application/x-msgpack" @@ -109,18 +121,63 @@ second_conn = AWAIT second_future ## Connection Closing Semantics +### Server-Initiated Close (Test Simulating Server) + When simulating server behavior, use the correct method based on the scenario: -| Scenario | Method | Description | -|----------|--------|-------------| -| Server sends DISCONNECTED | `send_to_client_and_close()` | Server sends message then closes connection | -| Server sends ERROR (connection-level) | `send_to_client_and_close()` | ERROR without channel = fatal, closes connection | -| Server sends ERROR (channel-level) | `send_to_client()` | ERROR with channel = attachment failure, connection stays open | -| Server sends CONNECTED, HEARTBEAT, ACK, MESSAGE | `send_to_client()` | Normal messages, connection stays open | -| Unexpected transport failure | `simulate_disconnect()` | Connection drops without server message | +| Scenario | Method | Event Recorded | +|----------|--------|----------------| +| Server sends DISCONNECTED | `send_to_client_and_close()` | `SERVER_DISCONNECT` | +| Server sends ERROR (connection-level) | `send_to_client_and_close()` | `SERVER_DISCONNECT` | +| Server sends ERROR (channel-level) | `send_to_client()` | (none - connection stays open) | +| Server sends CONNECTED, HEARTBEAT, ACK, MESSAGE | `send_to_client()` | (none - connection stays open) | +| Unexpected transport failure | `simulate_disconnect()` | `SERVER_DISCONNECT` | **Key rule:** Whenever the server sends DISCONNECTED, or ERROR without a specified channel, it will be accompanied by the server closing the WebSocket connection. An ERROR with a specified channel is an attachment failure and doesn't end the connection. +### Client-Initiated Close (Library Closing Connection) + +When the Ably library closes the WebSocket connection (e.g., due to heartbeat timeout, explicit close, or fatal error), a `CLIENT_CLOSE` event is recorded. Tests can: + +1. **Inspect events list:** Check `mock_ws.events` for `CLIENT_CLOSE` event +2. **Await the close:** Use `await_client_close()` to wait for the library to close + +```pseudo +# Example: Assert client closed the connection after heartbeat timeout +AWAIT mock_ws.await_client_close(timeout: 1000) + +# Or inspect the events list +client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) +ASSERT client_close_events.length == 1 +``` + +The `ClientCloseEvent` contains: +- `code`: WebSocket close code (e.g., 1000 for normal, 1001 for going away) +- `reason`: Optional human-readable close reason + +## WebSocket Ping Frame Simulation (RTN23b) + +Some WebSocket client implementations surface ping frame events to the application layer. Per RTN23b, if the WebSocket client can observe ping frames, the Ably library can use them as heartbeat indicators instead of requiring HEARTBEAT protocol messages. + +Use `send_ping_frame()` to simulate the server sending a WebSocket ping frame: + +```pseudo +# Simulate server sending a ping frame (transport-level heartbeat) +mock_ws.active_connection.send_ping_frame() +``` + +**When to use ping frames vs HEARTBEAT messages:** + +| Scenario | Method | Use Case | +|----------|--------|----------| +| Platform surfaces ping events | `send_ping_frame()` | RTN23b - Test heartbeat via ping frames | +| Platform doesn't surface pings | `send_to_client(HEARTBEAT_MESSAGE)` | RTN23a - Test heartbeat via protocol messages | + +**Connection URL query parameter:** +- If the client sends `heartbeats=true`, it expects HEARTBEAT protocol messages +- If the client sends `heartbeats=false` (or omits it), the server may use ping frames +- The test should verify which parameter the client sends based on platform capabilities + ## Protocol Message Templates Common protocol messages for testing: @@ -252,3 +309,125 @@ ASSERT client.connection.state == ConnectionState.disconnected - **Preferred**: Mock/fake the timer/clock mechanism (e.g., `jest.advanceTimersByTime()` in JavaScript) - **Alternative**: Use dependency injection of clock/timer abstractions - **Fallback**: Use actual time delays with short timeout values + +## Async Behavior and Event Loop Considerations + +### Mock close() Must Be Asynchronous + +The mock WebSocket's `close()` method must call `listener.onClose()` **asynchronously** (e.g., via `scheduleMicrotask` or `setTimeout(..., 0)`), not synchronously. This matches the behavior of real WebSocket implementations where `onClose` is triggered via the stream's `onDone` callback. + +```pseudo +# CORRECT - matches real WebSocket behavior +close(code, reason): + IF already_closed: RETURN + closed = true + record_event(CLIENT_CLOSE, {code, reason}) + schedule_microtask(() => listener.onClose(code, reason)) + +# WRONG - would cause issues with state machine timing +close(code, reason): + IF already_closed: RETURN + closed = true + listener.onClose(code, reason) # Synchronous - BAD +``` + +### respondWithSuccess() Ordering + +When a connection attempt succeeds, `respondWithSuccess()` must: +1. **First** - Complete the connection future (so `connect()` returns) +2. **Then** - Deliver the CONNECTED message asynchronously + +This ensures the library has stored the WebSocket connection reference before processing the CONNECTED message (which may start timers that reference the connection). + +```pseudo +respond_with_success(connected_message): + connection = create_mock_connection(listener) + completer.complete(connection) # 1. Connection established + schedule_microtask(() => { + listener.onMessage(connected_message) # 2. Then deliver message + }) +``` + +### Pumping the Event Queue + +After advancing fake timers or triggering async operations, tests may need to "pump" the event queue to allow scheduled callbacks to execute: + +```pseudo +# Pump the event queue to process pending microtasks and timer events +PUMP_EVENT_QUEUE() +``` + +**Implementation notes:** + +- **Microtasks** (e.g., `scheduleMicrotask`, `Future.value().then()`) run before timer events +- **Timer events** (e.g., `Timer.run`, `Future.delayed(Duration.zero)`) run after all microtasks +- Multiple chained async operations may require multiple pumps + +In Dart, `await Future.delayed(Duration.zero)` yields to the event loop and allows pending timer events to fire. For nested async chains, multiple pumps may be needed: + +```dart +// Pump the event queue multiple times for nested async operations +Future pumpEventQueue([int times = 5]) async { + for (var i = 0; i < times; i++) { + await Future.delayed(Duration.zero); + } +} +``` + +### Avoiding Arbitrary Real-Time Delays + +Tests should **never** use fixed real-time delays like `await Future.delayed(Duration(milliseconds: 100))`. These cause: +- Slow tests +- Flaky tests (timing varies by machine load) +- Non-deterministic behavior + +Instead: +- Use fake timers with `ADVANCE_TIME()` +- Pump the event queue with `PUMP_EVENT_QUEUE()` or `await Future.delayed(Duration.zero)` +- Wait for specific state changes with `AWAIT_STATE` + +```pseudo +# BAD - arbitrary real-time delay +ADVANCE_TIME(3000) +WAIT 100ms # Real-time delay - flaky! +ASSERT state == disconnected + +# GOOD - pump event queue and wait for state +ADVANCE_TIME(3000) +PUMP_EVENT_QUEUE() +AWAIT_STATE state == disconnected +``` + +## Verifying State Transitions with Event Sequences + +When testing behavior that involves transient states (e.g., DISCONNECTED during reconnection), **do not** try to catch the state at a specific moment. Instead, record the full sequence of state changes and verify it at the end: + +```pseudo +state_changes = [] +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) + +# Trigger the behavior +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Trigger disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +PUMP_EVENT_QUEUE() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Verify the sequence included the expected states +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] +``` + +This approach is more robust because: +- It doesn't depend on catching a transient state at exactly the right moment +- It works even when immediate reconnection (RTN15a) causes rapid state transitions +- It verifies the complete behavior, not just the final state From 492434387783dc0fd7e46795f9b2aa3292d7fc25 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 07/32] Fix RTN15a (immediate reconnection) test approach and update skill Correct the test approach for RTN15a immediate reconnection behaviour and update the write-test-spec skill with refined patterns. --- uts/.claude/skills/write-test-spec.md | 84 +++- .../connection/connection_failures_test.md | 87 +++- .../unit/connection/heartbeat_test.md | 384 +++++++++++++----- 3 files changed, 409 insertions(+), 146 deletions(-) diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index 517535dfc..b0db95a34 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -467,35 +467,87 @@ AWAIT_STATE state == x # Wait for resulting state change In Dart, this is typically `await Future.delayed(Duration.zero)`. Multiple chained async operations may require multiple pumps. -### Verifying Transient States +### Verifying Transient States (Record-and-Verify Pattern) -When testing behavior involving transient states (e.g., DISCONNECTED during reconnection), **do not** try to catch the state at a specific moment. Instead, record the full sequence of state changes and verify it at the end: +**When testing disconnect/reconnect behavior, always use the record-and-verify pattern.** Do not use intermediate `AWAIT_STATE` calls to observe transient states like DISCONNECTED or SUSPENDED mid-test. The Ably spec mandates immediate reconnection on unexpected disconnect (RTN15a), which means transient states pass too quickly to be reliably observed between test steps. + +**The pattern:** + +1. Start recording state changes before triggering the behavior +2. Let the full cycle play out (disconnect → reconnect) +3. Assert the recorded sequence at the end with `CONTAINS_IN_ORDER` ```pseudo +# 1. Record state changes state_changes = [] -client.connection.on().listen((change) => { +client.connection.on((change) => { state_changes.append(change.current) }) -# Trigger behavior -ADVANCE_TIME(timeout_duration) +# 2. Trigger disconnect and let cycle complete +ws_connection.simulate_disconnect() PUMP_EVENT_QUEUE() AWAIT_STATE client.connection.state == ConnectionState.connected -# Verify sequence included expected states +# 3. Verify the full sequence at the end ASSERT state_changes CONTAINS_IN_ORDER [ - ConnectionState.connecting, - ConnectionState.connected, - ConnectionState.disconnected, # Transient state we want to verify + ConnectionState.disconnected, ConnectionState.connecting, ConnectionState.connected ] ``` -This approach is robust because: -- It doesn't depend on catching a transient state at exactly the right moment -- It works even when immediate reconnection (RTN15a) causes rapid state transitions -- It verifies the complete behavior, not just the final state +**`CONTAINS_IN_ORDER` semantics:** This assertion verifies that the listed states appear in the recorded sequence in the correct order, but does not require them to be the *only* states present. This allows for implementation-specific intermediate states (e.g., additional CONNECTING states between retries) without causing false failures. + +**Why NOT intermediate `AWAIT_STATE`:** + +```pseudo +# BAD - unreliable, DISCONNECTED may pass before this line executes +ws_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected # May miss it! +ADVANCE_TIME(6000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# GOOD - record everything, verify at the end +state_changes = [] +client.connection.on((change) => { state_changes.append(change.current) }) +ws_connection.simulate_disconnect() +PUMP_EVENT_QUEUE() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT state_changes CONTAINS_IN_ORDER [disconnected, connecting, connected] +``` + +### Time-Advancement Loops for Retry Scenarios + +When tests involve multiple retries with fake timers (e.g., reconnection attempts that fail before eventually succeeding, or waiting for TTL expiry), use a **time-advancement loop** rather than calculating exact `ADVANCE_TIME` durations. This is more robust because: + +- The exact timing of retries, backoff, and state transitions is implementation-dependent +- A loop naturally accommodates varying numbers of retries +- It mirrors what the real-world clock does: time passes continuously, not in exact jumps + +```pseudo +enable_fake_timers() + +# Trigger disconnect, then advance time in increments +# until the client reconnects or we give up +ws_connection.simulate_disconnect() +PUMP_EVENT_QUEUE() + +LOOP up to 15 times: + ADVANCE_TIME(2500) + PUMP_EVENT_QUEUE() + IF client.connection.state == ConnectionState.connected: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +Use this pattern when: +- Reconnection attempts may fail multiple times before succeeding +- The test needs to advance through multiple retry/backoff cycles +- State transitions depend on cumulative elapsed time (e.g., `connectionStateTtl` expiry triggering SUSPENDED) + +The final `AWAIT_STATE` after the loop acts as a safety net in case the loop iterations weren't quite enough. ## Test Structure @@ -823,3 +875,9 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null 14. ❌ Creating client without credentials for time() tests: `ClientOptions(tls: false)` ✅ Constructor requires credentials - use `ClientOptions(key: "...", tls: false, useTokenAuth: true)` + +15. ❌ Using intermediate `AWAIT_STATE disconnected` to observe transient states mid-test + ✅ Record all state changes and use `CONTAINS_IN_ORDER` to verify the sequence at the end + +16. ❌ Using exact `ADVANCE_TIME` calculations for multi-retry scenarios: `ADVANCE_TIME(6000); ADVANCE_TIME(1000)` + ✅ Use a time-advancement loop: `LOOP up to N times: ADVANCE_TIME(increment); PUMP_EVENT_QUEUE()` diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index d2b143f62..ab7892fca 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -761,7 +761,16 @@ ASSERT client.connection.key == "key-1-updated" **Spec requirement:** If disconnected longer than connectionStateTtl, don't attempt resume. Clear local state and make fresh connection. -Tests that stale connections don't attempt resume. +Tests that stale connections don't attempt resume. After disconnecting, reconnection +attempts fail repeatedly, causing the client to eventually transition to SUSPENDED +(once connectionStateTtl expires). When the client eventually reconnects from +SUSPENDED state, it makes a fresh connection without resume parameters. + +> **Note on verifying transient states:** Rather than trying to observe intermediate +> states (e.g. DISCONNECTED, SUSPENDED) mid-test with `AWAIT_STATE`, we record all +> state changes and verify the full sequence at the end. This avoids flaky tests +> caused by the SDK (correctly) attempting immediate reconnection per RTN15a, which +> makes transient states difficult to observe reliably. ### Setup @@ -774,10 +783,9 @@ mock_ws = MockWebSocket( connection_attempt_count++ captured_connection_attempts.append(conn) - conn.respond_with_success() - IF connection_attempt_count == 1: - # Initial connection + # Initial connection succeeds + conn.respond_with_success() conn.send_to_client(ProtocolMessage( action: CONNECTED, connectionId: "connection-1", @@ -788,8 +796,13 @@ mock_ws = MockWebSocket( connectionStateTtl: 5000 # 5 seconds TTL ) )) + ELSE IF connection_attempt_count < 6: + # Reconnection attempts 2-5 fail (connection refused) + # This keeps the client retrying while TTL expires + conn.respond_with_refused() ELSE: - # Fresh connection (no resume) + # After TTL expires, fresh connection succeeds (no resume) + conn.respond_with_success() conn.send_to_client(ProtocolMessage( action: CONNECTED, connectionId: "connection-2", # New ID @@ -804,10 +817,17 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, "yes") # Connectivity check +) +install_mock(mock_http) + client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", disconnectedRetryTimeout: 1000, - autoConnect: false + suspendedRetryTimeout: 2000, + autoConnect: false, + fallbackHosts: [] )) ``` @@ -816,26 +836,36 @@ client = Realtime(options: ClientOptions( ```pseudo enable_fake_timers() +# Record all state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + # Initial connection client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected original_connection_id = client.connection.id +original_connection_key = client.connection.key -# Force disconnect +# Force disconnect - triggers immediate reconnect per RTN15a ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection ws_connection.simulate_disconnect() - -# Wait for DISCONNECTED -AWAIT_STATE client.connection.state == ConnectionState.disconnected - -# Advance time past connectionStateTtl -ADVANCE_TIME(6000) # Past the 5s TTL - -# Trigger reconnection -ADVANCE_TIME(1000) # Past disconnectedRetryTimeout - -# Wait for reconnection +PUMP_EVENT_QUEUE() + +# Reconnection attempts keep failing (connection refused). +# Advance time in increments to allow retries, TTL expiry, +# transition to SUSPENDED, and eventual successful reconnection. +# TTL is 5000ms, disconnectedRetryTimeout is 1000ms, +# suspendedRetryTimeout is 2000ms. +LOOP up to 15 times: + ADVANCE_TIME(2500) + PUMP_EVENT_QUEUE() + IF client.connection.state == ConnectionState.connected: + BREAK + +# Wait for final successful reconnection AWAIT_STATE client.connection.state == ConnectionState.connected WITH timeout: 5 seconds ``` @@ -843,15 +873,28 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ### Assertions ```pseudo -# New connection (different ID, not resumed) +# Verify the full state change sequence includes SUSPENDED +# (TTL expired while reconnection attempts were failing) +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.suspended, + ConnectionState.connecting, + ConnectionState.connected +] + +# RTN15g: New connection (different ID, not resumed - TTL expired) ASSERT client.connection.id == "connection-2" ASSERT client.connection.id != original_connection_id -# Second connection did NOT include resume parameter -ASSERT "resume" NOT IN captured_connection_attempts[1].url.query_params - # Fresh connection key ASSERT client.connection.key == "key-2" +ASSERT client.connection.key != original_connection_key + +# Final reconnection URL did NOT include resume parameter +# (because TTL expired and connection state was cleared) +ASSERT "resume" NOT IN captured_connection_attempts.last.url.query_params ``` --- diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index 9a89b1644..97a87eb84 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -21,6 +21,17 @@ RTN23 defines how the client detects connection liveness: A concrete implementation should implement either RTN23a with HEARTBEAT messages OR RTN23b with ping frames, depending on platform capabilities. The test cases below cover both approaches. +### Verifying Transient States + +When testing heartbeat timeout behavior, the connection will pass through the DISCONNECTED state very quickly due to immediate reconnection (RTN15a). Attempting to `AWAIT_STATE disconnected` as an intermediate step in the middle of a test is unreliable. Instead, all tests that involve disconnection should: + +1. Record the full sequence of state changes from the start of the test +2. Let the complete connect → disconnect → reconnect cycle play out +3. `AWAIT_STATE connected` after the final reconnection +4. Assert the recorded state change sequence and other invariants at the end + +This pattern is used consistently throughout these tests. + --- # RTN23a Tests (HEARTBEAT Protocol Messages) @@ -79,23 +90,27 @@ ASSERT captured_url.query_params["heartbeats"] == "true" --- -## RTN23a - Disconnect after maxIdleInterval + realtimeRequestTimeout +## RTN23a - Disconnect and reconnect after maxIdleInterval + realtimeRequestTimeout -**Spec requirement:** If no message is received from the server for `maxIdleInterval + realtimeRequestTimeout` milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state. +**Spec requirement:** If no message is received from the server for `maxIdleInterval + realtimeRequestTimeout` milliseconds, the connection is considered lost and the client transitions to DISCONNECTED state, then immediately reconnects (RTN15a). -Tests that the client disconnects and closes the WebSocket when no server activity is detected. +Tests the full disconnect/reconnect cycle when no server activity is detected. ### Setup ```pseudo +connection_attempt_count = 0 +state_changes = [] + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { + connection_attempt_count++ conn.respond_with_success(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id", - connectionKey: "connection-key", + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, connectionDetails: ConnectionDetails( - connectionKey: "connection-key", + connectionKey: "connection-key-" + connection_attempt_count, maxIdleInterval: 5000, # 5 seconds connectionStateTtl: 120000 ) @@ -110,6 +125,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 2000, # 2 seconds autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -120,20 +140,36 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + # Advance time past maxIdleInterval + realtimeRequestTimeout # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for the reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -ASSERT client.connection.state == ConnectionState.disconnected -ASSERT client.connection.errorReason IS NOT null +# Verify the full state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 -# Verify the client closed the WebSocket connection +# Verify we're connected with new connection details +ASSERT client.connection.id == "connection-id-2" + +# Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 ``` @@ -144,19 +180,22 @@ ASSERT client_close_events.length == 1 **Spec requirement:** Any message from the server, including HEARTBEAT messages, resets the idle timer. -Tests that receiving HEARTBEAT messages keeps the connection alive, and that the client closes the WebSocket when it eventually times out. +Tests that receiving HEARTBEAT messages keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. ### Setup ```pseudo +connection_attempt_count = 0 + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { + connection_attempt_count++ conn.respond_with_success(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id", - connectionKey: "connection-key", + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, connectionDetails: ConnectionDetails( - connectionKey: "connection-key", + connectionKey: "connection-key-" + connection_attempt_count, maxIdleInterval: 3000, # 3 seconds connectionStateTtl: 120000 ) @@ -180,32 +219,41 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + # Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) ADVANCE_TIME(2000) +PUMP_EVENT_QUEUE() # Send HEARTBEAT from server - resets timer mock_ws.active_connection.send_to_client(ProtocolMessage( action: HEARTBEAT )) +PUMP_EVENT_QUEUE() # Advance time again (2000ms since HEARTBEAT, still within threshold) ADVANCE_TIME(2000) +PUMP_EVENT_QUEUE() -# Connection should still be alive +# Connection should still be alive - no reconnection triggered ASSERT client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 # Advance time past the timeout window (4100ms since last HEARTBEAT) ADVANCE_TIME(2100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -ASSERT client.connection.state == ConnectionState.disconnected +# Verify reconnection happened +ASSERT connection_attempt_count == 2 -# Verify the client closed the WebSocket connection +# Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 ``` @@ -216,21 +264,24 @@ ASSERT client_close_events.length == 1 **Spec requirement:** Any message from the server resets the idle timer, not just HEARTBEAT messages. -Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive, and that the client closes the WebSocket when it eventually times out. +Tests that receiving any protocol message (e.g., ACK, MESSAGE) keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. ### Setup ```pseudo channel_name = "test-RTN23a-message-${random_id()}" +connection_attempt_count = 0 +state_changes = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { + connection_attempt_count++ conn.respond_with_success(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id", - connectionKey: "connection-key", + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, connectionDetails: ConnectionDetails( - connectionKey: "connection-key", + connectionKey: "connection-key-" + connection_attempt_count, maxIdleInterval: 2000, # 2 seconds connectionStateTtl: 120000 ) @@ -244,6 +295,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -254,17 +310,20 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Advance time +# Advance time (timeout is 2000+1000=3000ms) ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() # Send ACK message from server - resets timer mock_ws.active_connection.send_to_client(ProtocolMessage( action: ACK, msgSerial: 0 )) +PUMP_EVENT_QUEUE() -# Advance time again +# Advance time again (1500ms since ACK, still within threshold) ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() # Connection should still be alive (timer was reset) ASSERT client.connection.state == ConnectionState.connected @@ -277,25 +336,39 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( Message(name: "event", data: "data") ] )) +PUMP_EVENT_QUEUE() -# Advance time again +# Advance time again (1500ms since MESSAGE) ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() -# Still connected -ASSERT client.connection.state == ConnectionState.connected +# Still only one connection attempt - no timeout yet +ASSERT connection_attempt_count == 1 -# Advance time past timeout without any message -ADVANCE_TIME(1600) +# Advance time past timeout without any message (3100ms since last activity) +ADVANCE_TIME(3100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -ASSERT client.connection.state == ConnectionState.disconnected +# Verify the state change sequence includes disconnected +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made +ASSERT connection_attempt_count == 2 -# Verify the client closed the WebSocket connection +# Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 ``` @@ -306,12 +379,13 @@ ASSERT client_close_events.length == 1 **Spec requirement:** When a heartbeat timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). -Tests that the client attempts to reconnect after a heartbeat timeout. +Tests that the client attempts to reconnect after a heartbeat timeout, verifying the complete state change sequence. ### Setup ```pseudo connection_attempt_count = 0 +state_changes = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { @@ -335,6 +409,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -347,23 +426,27 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ASSERT connection_attempt_count == 1 -# Advance time past maxIdleInterval + realtimeRequestTimeout to trigger timeout +# Advance time past maxIdleInterval + realtimeRequestTimeout # = 2000 + 1000 = 3000ms ADVANCE_TIME(3100) +PUMP_EVENT_QUEUE() -# Client should disconnect -AWAIT_STATE client.connection.state == ConnectionState.disconnected - -# Client should immediately attempt to reconnect (RTN15a) -# Allow time for the reconnection attempt -ADVANCE_TIME(100) - +# Wait for reconnection to complete (immediate per RTN15a) AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo +# Verify the state change sequence shows disconnect then reconnect +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + # Verify two connection attempts were made (initial + reconnect) ASSERT connection_attempt_count == 2 @@ -388,6 +471,7 @@ Tests that the reconnection attempt includes the resume parameters. ```pseudo connection_attempts = [] +state_changes = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { @@ -414,6 +498,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -424,20 +513,26 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Advance time past timeout to trigger disconnection +# Advance time past timeout to trigger disconnection and reconnection ADVANCE_TIME(3100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected - -# Allow reconnection -ADVANCE_TIME(100) - +# Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + ASSERT connection_attempts.length == 2 # First connection should not have resume parameter @@ -508,23 +603,27 @@ ASSERT captured_url.query_params["heartbeats"] == "false" --- -## RTN23b - Disconnect after maxIdleInterval + realtimeRequestTimeout (no ping frames) +## RTN23b - Disconnect and reconnect after maxIdleInterval + realtimeRequestTimeout (no ping frames) -**Spec requirement:** If no activity (including ping frames) is received for `maxIdleInterval + realtimeRequestTimeout`, disconnect. +**Spec requirement:** If no activity (including ping frames) is received for `maxIdleInterval + realtimeRequestTimeout`, disconnect and reconnect. -Tests that the client disconnects and closes the WebSocket when no ping frames or messages are received. +Tests the full disconnect/reconnect cycle when no ping frames or messages are received. ### Setup ```pseudo +connection_attempt_count = 0 +state_changes = [] + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { + connection_attempt_count++ conn.respond_with_success(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id", - connectionKey: "connection-key", + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, connectionDetails: ConnectionDetails( - connectionKey: "connection-key", + connectionKey: "connection-key-" + connection_attempt_count, maxIdleInterval: 5000, # 5 seconds connectionStateTtl: 120000 ) @@ -539,6 +638,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 2000, # 2 seconds autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -549,20 +653,36 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + # Advance time past maxIdleInterval + realtimeRequestTimeout # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for the reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -ASSERT client.connection.state == ConnectionState.disconnected -ASSERT client.connection.errorReason IS NOT null +# Verify the full state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts were made (initial + reconnect) +ASSERT connection_attempt_count == 2 + +# Verify we're connected with new connection details +ASSERT client.connection.id == "connection-id-2" -# Verify the client closed the WebSocket connection +# Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 ``` @@ -573,19 +693,22 @@ ASSERT client_close_events.length == 1 **Spec requirement:** WebSocket ping frames count as activity indication and reset the idle timer. -Tests that receiving ping frames keeps the connection alive, and that the client closes the WebSocket when it eventually times out. +Tests that receiving ping frames keeps the connection alive, and that when the timer eventually expires the client disconnects and reconnects. ### Setup ```pseudo +connection_attempt_count = 0 + mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { + connection_attempt_count++ conn.respond_with_success(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id", - connectionKey: "connection-key", + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, connectionDetails: ConnectionDetails( - connectionKey: "connection-key", + connectionKey: "connection-key-" + connection_attempt_count, maxIdleInterval: 3000, # 3 seconds connectionStateTtl: 120000 ) @@ -609,30 +732,39 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + # Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) ADVANCE_TIME(2000) +PUMP_EVENT_QUEUE() # Server sends ping frame - resets timer mock_ws.active_connection.send_ping_frame() +PUMP_EVENT_QUEUE() # Advance time again (2000ms since ping, still within threshold) ADVANCE_TIME(2000) +PUMP_EVENT_QUEUE() -# Connection should still be alive +# Connection should still be alive - no reconnection triggered ASSERT client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 # Advance time past the timeout window (4100ms since last ping) ADVANCE_TIME(2100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -ASSERT client.connection.state == ConnectionState.disconnected +# Verify reconnection happened +ASSERT connection_attempt_count == 2 -# Verify the client closed the WebSocket connection +# Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 ``` @@ -643,21 +775,24 @@ ASSERT client_close_events.length == 1 **Spec requirement:** Any message from the server resets the idle timer, not just ping frames. -Tests that both ping frames AND protocol messages reset the timer, and that the client closes the WebSocket when it eventually times out. +Tests that both ping frames AND protocol messages reset the timer, and that when the timer eventually expires the client disconnects and reconnects. ### Setup ```pseudo channel_name = "test-RTN23b-message-${random_id()}" +connection_attempt_count = 0 +state_changes = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { + connection_attempt_count++ conn.respond_with_success(ProtocolMessage( action: CONNECTED, - connectionId: "connection-id", - connectionKey: "connection-key", + connectionId: "connection-id-" + connection_attempt_count, + connectionKey: "connection-key-" + connection_attempt_count, connectionDetails: ConnectionDetails( - connectionKey: "connection-key", + connectionKey: "connection-key-" + connection_attempt_count, maxIdleInterval: 2000, # 2 seconds connectionStateTtl: 120000 ) @@ -671,6 +806,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -683,12 +823,15 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() # Send ping frame - resets timer mock_ws.active_connection.send_ping_frame() +PUMP_EVENT_QUEUE() # Advance time ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() # Still connected ASSERT client.connection.state == ConnectionState.connected @@ -701,34 +844,50 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( Message(name: "event", data: "data") ] )) +PUMP_EVENT_QUEUE() # Advance time ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() # Still connected ASSERT client.connection.state == ConnectionState.connected # Send another ping frame mock_ws.active_connection.send_ping_frame() +PUMP_EVENT_QUEUE() # Advance time ADVANCE_TIME(1500) +PUMP_EVENT_QUEUE() -# Still connected -ASSERT client.connection.state == ConnectionState.connected +# Still only one connection attempt +ASSERT connection_attempt_count == 1 # Advance time past timeout without any activity ADVANCE_TIME(1600) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for reconnection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo -ASSERT client.connection.state == ConnectionState.disconnected +# Verify the state change sequence includes disconnected +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + +# Verify two connection attempts +ASSERT connection_attempt_count == 2 -# Verify the client closed the WebSocket connection +# Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 ``` @@ -739,12 +898,13 @@ ASSERT client_close_events.length == 1 **Spec requirement:** When a ping frame timeout causes disconnection, the client should immediately attempt to reconnect (per RTN15a - DISCONNECTED state triggers reconnection). -Tests that the client attempts to reconnect after a ping frame timeout. +Tests that the client attempts to reconnect after a ping frame timeout, verifying the complete state change sequence. ### Setup ```pseudo connection_attempt_count = 0 +state_changes = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { @@ -768,6 +928,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -780,23 +945,27 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ASSERT connection_attempt_count == 1 -# Advance time past maxIdleInterval + realtimeRequestTimeout to trigger timeout +# Advance time past maxIdleInterval + realtimeRequestTimeout # = 2000 + 1000 = 3000ms ADVANCE_TIME(3100) +PUMP_EVENT_QUEUE() -# Client should disconnect -AWAIT_STATE client.connection.state == ConnectionState.disconnected - -# Client should immediately attempt to reconnect (RTN15a) -# Allow time for the reconnection attempt -ADVANCE_TIME(100) - +# Wait for reconnection to complete (immediate per RTN15a) AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo +# Verify the state change sequence shows disconnect then reconnect +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + # Verify two connection attempts were made (initial + reconnect) ASSERT connection_attempt_count == 2 @@ -821,6 +990,7 @@ Tests that the reconnection attempt includes the resume parameters. ```pseudo connection_attempts = [] +state_changes = [] mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { @@ -847,6 +1017,11 @@ client = Realtime(options: ClientOptions( realtimeRequestTimeout: 1000, # 1 second autoConnect: false )) + +# Record all state changes +client.connection.on().listen((change) => { + state_changes.append(change.current) +}) ``` ### Test Steps @@ -857,20 +1032,26 @@ enable_fake_timers() client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Advance time past timeout to trigger disconnection +# Advance time past timeout to trigger disconnection and reconnection ADVANCE_TIME(3100) +PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.disconnected - -# Allow reconnection -ADVANCE_TIME(100) - +# Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` ### Assertions ```pseudo +# Verify the state change sequence +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected, + ConnectionState.disconnected, + ConnectionState.connecting, + ConnectionState.connected +] + ASSERT connection_attempts.length == 2 # First connection should not have resume parameter @@ -971,30 +1152,11 @@ These tests use `enable_fake_timers()` and `ADVANCE_TIME()` to avoid slow tests. 3. **Or use very short timeout values** (e.g., 50ms instead of 5s) 4. **Last resort:** Use actual delays with generous test timeouts -## Verifying Transient States +## State Sequence Assertion Pattern -When testing heartbeat timeout behavior, the connection may pass through DISCONNECTED state very quickly due to immediate reconnection (RTN15a). Do not attempt to catch the DISCONNECTED state directly - instead, record the full sequence of state changes and verify it at the end: +All heartbeat tests that involve disconnection follow the same pattern: record the full sequence of state changes, let the complete cycle play out, then assert the sequence at the end. This avoids flaky tests caused by trying to observe transient intermediate states (like DISCONNECTED) that may pass too quickly due to immediate reconnection (RTN15a). -```pseudo -state_changes = [] -client.connection.on().listen((change) => { - state_changes.append(change.current) -}) - -# Trigger timeout and reconnection -ADVANCE_TIME(maxIdleInterval + realtimeRequestTimeout + buffer) -PUMP_EVENT_QUEUE() -AWAIT_STATE client.connection.state == ConnectionState.connected - -# Verify the sequence included DISCONNECTED -ASSERT state_changes CONTAINS_IN_ORDER [ - ConnectionState.connecting, - ConnectionState.connected, - ConnectionState.disconnected, - ConnectionState.connecting, - ConnectionState.connected -] -``` +The `CONTAINS_IN_ORDER` assertion verifies that the expected states appear in the recorded sequence in the correct order, without requiring that they are the only states present (allowing for implementation-specific intermediate states). See `mock_websocket.md` for more details on event sequence verification. From 5299cbb187e9d2f18a5ca569be247886285f968f Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 08/32] Fix minor issues in channel attach and state events test specs Correct small errors in channel_attach and channel_state_events specs. --- uts/realtime/unit/channels/channel_attach.md | 6 +++--- uts/realtime/unit/channels/channel_state_events.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md index 997ff464f..166f7b547 100644 --- a/uts/realtime/unit/channels/channel_attach.md +++ b/uts/realtime/unit/channels/channel_attach.md @@ -731,8 +731,8 @@ AWAIT channel.attach() ```pseudo ASSERT captured_attach_message IS NOT null ASSERT captured_attach_message.flags IS NOT null -# Flags should include PUBLISH (65536) and SUBSCRIBE (262144) bits -ASSERT (captured_attach_message.flags AND 65536) != 0 # PUBLISH bit set +# Flags should include PUBLISH (131072, TR3r bit 17) and SUBSCRIBE (262144, TR3s bit 18) bits +ASSERT (captured_attach_message.flags AND 131072) != 0 # PUBLISH bit set ASSERT (captured_attach_message.flags AND 262144) != 0 # SUBSCRIBE bit set ``` @@ -755,7 +755,7 @@ mock_ws = MockWebSocket( mock_ws.send_to_client(ProtocolMessage( action: ATTACHED, channel: channel_name, - flags: 327680 # PUBLISH (65536) + SUBSCRIBE (262144) + flags: 393216 # PUBLISH (131072, TR3r) + SUBSCRIBE (262144, TR3s) )) } ) diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md index 7fff18163..593d58389 100644 --- a/uts/realtime/unit/channels/channel_state_events.md +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -306,7 +306,7 @@ AWAIT channel.attach() mock_ws.send_to_client(ProtocolMessage( action: ATTACHED, channel: channel_name, - flags: HAS_RESUME # Indicates resumed attachment + flags: RESUMED # Indicates resumed attachment (TR3c, bit 2) )) # Wait for the event to be processed From a5438a24226fd727493be7ec44d2c9fccf8a7ad7 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 09/32] Add test specs for connection state transitions and channel properties Add test specs for channel connection state handling, channel error reporting, server-initiated detach, channel properties (RTL15/RTL16), connection ID/key (RTN8/RTN9), and connection ping (RTN13). Also adds a completion-status tracker for spec point coverage. --- uts/.claude/skills/write-test-spec.md | 26 +- uts/completion-status.md | 417 ++++++++ uts/realtime/unit/channels/channel_attach.md | 19 +- .../unit/channels/channel_connection_state.md | 888 ++++++++++++++++++ uts/realtime/unit/channels/channel_error.md | 352 +++++++ .../unit/channels/channel_properties.md | 546 +++++++++++ .../channel_server_initiated_detach.md | 591 ++++++++++++ .../connection/connection_failures_test.md | 2 - .../unit/connection/connection_id_key_test.md | 360 +++++++ .../unit/connection/connection_ping_test.md | 760 +++++++++++++++ .../unit/connection/heartbeat_test.md | 54 -- uts/realtime/unit/helpers/mock_websocket.md | 31 +- 12 files changed, 3928 insertions(+), 118 deletions(-) create mode 100644 uts/completion-status.md create mode 100644 uts/realtime/unit/channels/channel_connection_state.md create mode 100644 uts/realtime/unit/channels/channel_error.md create mode 100644 uts/realtime/unit/channels/channel_properties.md create mode 100644 uts/realtime/unit/channels/channel_server_initiated_detach.md create mode 100644 uts/realtime/unit/connection/connection_id_key_test.md create mode 100644 uts/realtime/unit/connection/connection_ping_test.md diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index b0db95a34..6cdadf70c 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -449,24 +449,11 @@ poll_until( timeout: 10s ) -# Good - pump event queue and wait for state +# Good - advance time and wait for state ADVANCE_TIME(3000) -PUMP_EVENT_QUEUE() AWAIT_STATE state == disconnected ``` -### Pumping the Event Queue - -After advancing fake timers, async callbacks may be scheduled but not yet executed. Use `PUMP_EVENT_QUEUE()` to process pending microtasks and timer events: - -```pseudo -ADVANCE_TIME(5000) # Schedules timeout callback -PUMP_EVENT_QUEUE() # Executes scheduled callbacks -AWAIT_STATE state == x # Wait for resulting state change -``` - -In Dart, this is typically `await Future.delayed(Duration.zero)`. Multiple chained async operations may require multiple pumps. - ### Verifying Transient States (Record-and-Verify Pattern) **When testing disconnect/reconnect behavior, always use the record-and-verify pattern.** Do not use intermediate `AWAIT_STATE` calls to observe transient states like DISCONNECTED or SUSPENDED mid-test. The Ably spec mandates immediate reconnection on unexpected disconnect (RTN15a), which means transient states pass too quickly to be reliably observed between test steps. @@ -486,7 +473,6 @@ client.connection.on((change) => { # 2. Trigger disconnect and let cycle complete ws_connection.simulate_disconnect() -PUMP_EVENT_QUEUE() AWAIT_STATE client.connection.state == ConnectionState.connected # 3. Verify the full sequence at the end @@ -512,7 +498,6 @@ AWAIT_STATE client.connection.state == ConnectionState.connected state_changes = [] client.connection.on((change) => { state_changes.append(change.current) }) ws_connection.simulate_disconnect() -PUMP_EVENT_QUEUE() AWAIT_STATE client.connection.state == ConnectionState.connected ASSERT state_changes CONTAINS_IN_ORDER [disconnected, connecting, connected] ``` @@ -531,11 +516,9 @@ enable_fake_timers() # Trigger disconnect, then advance time in increments # until the client reconnects or we give up ws_connection.simulate_disconnect() -PUMP_EVENT_QUEUE() LOOP up to 15 times: ADVANCE_TIME(2500) - PUMP_EVENT_QUEUE() IF client.connection.state == ConnectionState.connected: BREAK @@ -753,6 +736,10 @@ uts/test/ └── README.md ``` +## Completion Status Matrix + +When adding a new test spec, update the completion status matrix at `uts/test/completion-status.md` to reflect the newly covered spec items. This matrix tracks which spec items have UTS test specs and which do not. + ## Writing Tips 1. **Reference spec points** in test names and file headers @@ -767,6 +754,7 @@ uts/test/ 10. **Use handler pattern for simple tests**, await pattern for complex coordination 11. **Distinguish connection-level vs request-level failures** 12. **Use unique channel names** to avoid test interference +13. **Update `uts/test/completion-status.md`** when adding new test specs ## Example Test Spec (Modern Pattern) @@ -880,4 +868,4 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null ✅ Record all state changes and use `CONTAINS_IN_ORDER` to verify the sequence at the end 16. ❌ Using exact `ADVANCE_TIME` calculations for multi-retry scenarios: `ADVANCE_TIME(6000); ADVANCE_TIME(1000)` - ✅ Use a time-advancement loop: `LOOP up to N times: ADVANCE_TIME(increment); PUMP_EVENT_QUEUE()` + ✅ Use a time-advancement loop: `LOOP up to N times: ADVANCE_TIME(increment)` diff --git a/uts/completion-status.md b/uts/completion-status.md new file mode 100644 index 000000000..1bfb15c03 --- /dev/null +++ b/uts/completion-status.md @@ -0,0 +1,417 @@ +# UTS Test Spec Completion Status + +This matrix lists all spec items from the [Ably features spec](../../specification/md/features.md) and indicates which have a UTS test specification. + +**Legend:** +- **Yes** — UTS test spec exists covering this item +- **Partial** — some sub-items covered, others not +- *blank* — no UTS test spec exists + +--- + +## Specification and Protocol Versions + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| CSV1–CSV2 | Specification & protocol versions | | + +## Client Library Endpoint Configuration + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| REC1 | Primary domain determination (REC1a–REC1d2) | Yes — `rest/unit/fallback.md` | +| REC2 | Fallback domains determination (REC2a–REC2c6) | Yes — `rest/unit/fallback.md` | +| REC3 | Connectivity check URL (REC3a–REC3b) | Yes — `rest/unit/fallback.md` | + +--- + +## REST Client Library + +### RestClient + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSC1 | Constructor options (RSC1a–RSC1c) | Yes — `realtime/unit/client/client_options.md`, `realtime/unit/client/realtime_client.md` | +| RSC2 | Logger default | | +| RSC3 | Log level configuration | | +| RSC4 | Custom logger | | +| RSC5 | Auth object attribute | | +| RSC6 | Stats function (RSC6a–RSC6b4) | Yes — `rest/unit/stats.md`, `rest/integration/time_stats.md` | +| RSC7 | HTTP request headers (RSC7a–RSC7d7) | Yes — `rest/unit/rest_client.md` | +| RSC8 | Protocol support (RSC8a–RSC8e2) | Yes — `rest/unit/rest_client.md` | +| RSC9 | Auth usage for authentication | | +| RSC10 | Token error retry handling | | +| RSC13 | Connection and request timeouts | Yes — `rest/unit/rest_client.md` | +| RSC15 | Host fallback behaviour (RSC15a–RSC15n) | Yes — `rest/unit/fallback.md` | +| RSC16 | Time function | Yes — `rest/unit/time.md`, `rest/integration/time_stats.md` | +| RSC17 | ClientId attribute | | +| RSC18 | TLS configuration | Yes — `rest/unit/rest_client.md`, `rest/unit/time.md` | +| RSC19 | Request function (RSC19a–RSC19f1) | Yes — `rest/unit/request.md` | +| RSC20 | Deprecated exception reporting (RSC20a–RSC20f) | | +| RSC21 | Push object attribute | | +| RSC22 | BatchPublish (RSC22a–RSC22d) | Yes — `rest/unit/batch_publish.md` | +| RSC23 | Deleted | | +| RSC24 | BatchPresence | | +| RSC25 | Request endpoint | | +| RSC26 | CreateWrapperSDKProxy (RSC26a–RSC26c) | | + +### Auth + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSA1 | Basic Auth requires HTTPS | Yes — `rest/unit/auth/auth_scheme.md` | +| RSA2 | Basic Auth default | Yes — `rest/unit/auth/auth_scheme.md` | +| RSA3 | Token Auth support (RSA3a–RSA3d) | Yes — `rest/unit/auth/auth_scheme.md` | +| RSA4 | Token Auth selection logic (RSA4a–RSA4g) | Partial — `rest/unit/auth/auth_scheme.md` covers RSA4, RSA4b; `rest/unit/auth/token_renewal.md` covers RSA4b4; `realtime/unit/auth/connection_auth_test.md` covers RSA4; `realtime/unit/connection/error_reason_test.md` covers RSA4c1, RSA4d | +| RSA5 | TTL for tokens | | +| RSA6 | Capability JSON | | +| RSA7 | ClientId and authenticated clients (RSA7a–RSA7e2) | Partial — `rest/unit/auth/client_id.md` covers RSA7, RSA7a–RSA7c | +| RSA8 | RequestToken function (RSA8a–RSA8g) | Partial — `rest/unit/auth/auth_callback.md` covers RSA8c, RSA8d; `realtime/unit/auth/connection_auth_test.md` covers RSA8d | +| RSA9 | CreateTokenRequest (RSA9a–RSA9i) | Partial — `rest/integration/auth.md` covers RSA9 | +| RSA10 | Authorize function (RSA10a–RSA10l) | Yes — `rest/unit/auth/authorize.md` | +| RSA11 | Base64 encoded API key | | +| RSA12 | Auth#clientId attribute (RSA12a–RSA12b) | Yes — `rest/unit/auth/client_id.md` | +| RSA14 | Error when token auth selected without token | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | +| RSA15 | ClientId validation (RSA15a–RSA15c) | | +| RSA16 | TokenDetails attribute (RSA16a–RSA16d) | Yes — `rest/unit/auth/token_details.md` | +| RSA17 | RevokeTokens (RSA17a–RSA17g) | | + +### Channels (REST) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSN1–RSN4 | REST channels collection (RSN1–RSN4c) | | + +### RestChannel + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSL1 | Publish function (RSL1a–RSL1n1) | Yes — `rest/unit/channel/publish.md`, `rest/integration/publish.md` | +| RSL1k | Idempotent publishing (RSL1k1–RSL1k5) | Yes — `rest/unit/channel/idempotency.md` | +| RSL2 | History function (RSL2a–RSL2b3) | Yes — `rest/unit/channel/history.md`, `rest/integration/history.md` | +| RSL3 | Presence attribute | | +| RSL4 | Message encoding (RSL4a–RSL4d4) | Yes — `rest/unit/encoding/message_encoding.md` | +| RSL5 | Message encryption (RSL5a–RSL5c) | | +| RSL6 | Message decoding (RSL6a–RSL6b) | Yes — `rest/unit/encoding/message_encoding.md` | +| RSL7 | SetOptions function | | +| RSL8 | Status function (RSL8a) | | +| RSL9 | Name attribute | | +| RSL10 | Annotations attribute | | +| RSL11 | GetMessage function (RSL11a–RSL11c) | | +| RSL14 | GetMessageVersions (RSL14a–RSL14c) | | +| RSL15 | UpdateMessage/DeleteMessage/AppendMessage (RSL15a–RSL15f) | | + +### Plugins + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| PC1–PC5 | Plugin architecture, VCDiff, Objects | | +| PT1–PT2 | PluginType enum | | +| VD1–VD2 | VCDiffDecoder | | + +### RestPresence + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSP1 | Associated with single channel | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | +| RSP2 | No presence registration via REST | | +| RSP3 | Get function (RSP3a–RSP3a3) | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | +| RSP4 | History function (RSP4a–RSP4b3) | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | +| RSP5 | Presence message decoding | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | + +### Encryption + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSE1 | Crypto::getDefaultParams (RSE1a–RSE1e) | | +| RSE2 | Crypto::generateRandomKey (RSE2a–RSE2b) | | + +### RestAnnotations + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSAN1–RSAN3 | Annotations publish/delete/get | | + +### Forwards Compatibility (REST) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSF1 | Robustness principle | | + +--- + +## Realtime Client Library + +### RealtimeClient + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTC1 | ClientOptions (RTC1a–RTC1f1) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC2 | Connection object attribute | Yes — `realtime/unit/client/realtime_client.md` | +| RTC3 | Channels object attribute | Yes — `realtime/unit/client/realtime_client.md` | +| RTC4 | Auth object attribute (RTC4a) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC5 | Stats function (RTC5a–RTC5b) | | +| RTC6 | Time function (RTC6a) | | +| RTC7 | Uses configured timeouts | | +| RTC8 | Authorize function for realtime (RTC8a–RTC8c) | | +| RTC9 | Request function | | +| RTC10–RTC11 | Deleted | | +| RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | +| RTC13 | Push object attribute | | +| RTC14 | CreateWrapperSDKProxy (RTC14a–RTC14c) | | +| RTC15 | Connect function (RTC15a) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC16 | Close function (RTC16a) | Yes — `realtime/unit/client/realtime_client.md` | +| RTC17 | ClientId attribute (RTC17a) | Yes — `realtime/unit/client/realtime_client.md` | + +### Connection + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTN1 | Uses websocket connection | | +| RTN2 | Default host and query string params (RTN2a–RTN2g) | Partial — `realtime/unit/auth/connection_auth_test.md` covers RTN2e | +| RTN3 | AutoConnect option | | +| RTN4 | Connection event emission (RTN4a–RTN4i) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN4b, RTN4c; `realtime/unit/connection/update_events_test.md` covers RTN4h | +| RTN5 | Concurrency test (50+ clients) | | +| RTN6 | Successful connection definition | | +| RTN7 | ACK and NACK handling (RTN7a–RTN7e) | | +| RTN8 | Connection#id attribute (RTN8a–RTN8c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | +| RTN9 | Connection#key attribute (RTN9a–RTN9c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | +| RTN11 | Connect function (RTN11a–RTN11f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN11; `realtime/unit/connection/error_reason_test.md` covers RTN11d | +| RTN12 | Close function (RTN12a–RTN12f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN12, RTN12a | +| RTN13 | Ping function (RTN13a–RTN13e) | Yes — `realtime/unit/connection/connection_ping_test.md` | +| RTN14 | Connection opening failures (RTN14a–RTN14g) | Yes — `realtime/unit/connection/connection_open_failures_test.md` | +| RTN15 | Connection failures when CONNECTED (RTN15a–RTN15j) | Yes — `realtime/unit/connection/connection_failures_test.md` | +| RTN16 | Connection recovery (RTN16a–RTN16m1) | Partial — `realtime/unit/connection/error_reason_test.md` covers RTN16e | +| RTN17 | Domain selection and fallback (RTN17a–RTN17j) | Yes — `realtime/unit/connection/fallback_hosts_test.md` | +| RTN19 | Transport state side effects (RTN19a–RTN19b) | | +| RTN20 | OS network change handling (RTN20a–RTN20c) | | +| RTN21 | ConnectionDetails override defaults | Partial — `realtime/unit/connection/update_events_test.md` covers RTN21; `realtime/integration/connection_lifecycle_test.md` covers RTN21 | +| RTN22 | Re-authentication request handling (RTN22a) | | +| RTN23 | Heartbeats (RTN23a–RTN23b) | Yes — `realtime/unit/connection/heartbeat_test.md` | +| RTN24 | UPDATE event on CONNECTED while connected | Yes — `realtime/unit/connection/update_events_test.md` | +| RTN25 | Connection#errorReason attribute | Yes — `realtime/unit/connection/error_reason_test.md` | +| RTN26 | Connection#whenState function (RTN26a–RTN26b) | Yes — `realtime/unit/connection/when_state_test.md` | +| RTN27 | Connection state machine (RTN27a–RTN27h) | Partial — `realtime/unit/auth/connection_auth_test.md` covers RTN27b | + +### Channels (Realtime) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTS1 | Channels collection accessible via RealtimeClient | Yes — `realtime/unit/channels/channels_collection.md` | +| RTS2 | Methods to check existence and iterate | Yes — `realtime/unit/channels/channels_collection.md` | +| RTS3 | Get function (RTS3a–RTS3c1) | Yes — `realtime/unit/channels/channels_collection.md` (RTS3a), `realtime/unit/channels/channel_options.md` (RTS3b, RTS3c, RTS3c1) | +| RTS4 | Release function (RTS4a) | Yes — `realtime/unit/channels/channels_collection.md` | +| RTS5 | GetDerived function (RTS5a–RTS5a2) | Yes — `realtime/unit/channels/channel_options.md` | + +### RealtimeChannel + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTL1 | Message and presence processing | | +| RTL2 | Channel event emission (RTL2a–RTL2i) | Yes — `realtime/unit/channels/channel_state_events.md` | +| RTL3 | Connection state side effects (RTL3a–RTL3e) | Yes — `realtime/unit/channels/channel_connection_state.md` | +| RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md` | +| RTL5 | Detach function (RTL5a–RTL5l) | Yes — `realtime/unit/channels/channel_detach.md` | +| RTL6 | Publish function (RTL6a–RTL6k) | | +| RTL7 | Subscribe function (RTL7a–RTL7h) | | +| RTL8 | Unsubscribe function (RTL8a–RTL8c) | | +| RTL9 | Presence attribute (RTL9a) | | +| RTL10 | History function (RTL10a–RTL10d) | | +| RTL11 | Channel state effect on presence (RTL11a) | | +| RTL12 | Additional ATTACHED message handling | | +| RTL13 | Server-initiated DETACHED handling (RTL13a–RTL13c) | Yes — `realtime/unit/channels/channel_server_initiated_detach.md` | +| RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md` | +| RTL15 | Channel#properties attribute (RTL15a–RTL15b1) | Yes — `realtime/unit/channels/channel_properties.md` | +| RTL16 | SetOptions function (RTL16a) | Yes — `realtime/unit/channels/channel_options.md` | +| RTL17 | No messages outside ATTACHED state | | +| RTL18 | Vcdiff decoding failure recovery (RTL18a–RTL18c) | | +| RTL19 | Base payload storage for vcdiff (RTL19a–RTL19c) | | +| RTL20 | Last message ID storage | | +| RTL21 | Message ordering in arrays | | +| RTL22 | Message filtering (RTL22a–RTL22d) | | +| RTL23 | Name attribute | | +| RTL24 | ErrorReason attribute | | +| RTL25 | WhenState function (RTL25a–RTL25b) | | +| RTL26 | Annotations attribute | | +| RTL27 | Objects attribute (RTL27a–RTL27b) | | +| RTL28 | GetMessage function | | +| RTL31 | GetMessageVersions function | | +| RTL32 | UpdateMessage/DeleteMessage/AppendMessage (RTL32a–RTL32e) | | + +### RealtimePresence + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTP1 | HAS_PRESENCE flag and SYNC | | +| RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | | +| RTP4 | Large member count test | | +| RTP5 | Channel state side effects (RTP5a–RTP5f) | | +| RTP6 | Subscribe function (RTP6a–RTP6e) | | +| RTP7 | Unsubscribe function (RTP7a–RTP7c) | | +| RTP8 | Enter function (RTP8a–RTP8j) | | +| RTP9 | Update function (RTP9a–RTP9e) | | +| RTP10 | Leave function (RTP10a–RTP10e) | | +| RTP11 | Get function (RTP11a–RTP11d) | | +| RTP12 | History function (RTP12a–RTP12d) | | +| RTP13 | SyncComplete attribute | | +| RTP14 | EnterClient function (RTP14a–RTP14d) | | +| RTP15 | EnterClient/UpdateClient/LeaveClient (RTP15a–RTP15f) | | +| RTP16 | Connection state conditions (RTP16a–RTP16c) | | +| RTP17 | Internal PresenceMap (RTP17a–RTP17j) | | +| RTP18 | Server-initiated sync (RTP18a–RTP18c) | | +| RTP19 | PresenceMap cleanup on sync (RTP19a) | | + +### RealtimeAnnotations + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTAN1–RTAN5 | Annotations publish/delete/get/subscribe/unsubscribe | | + +### EventEmitter + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTE1–RTE6 | EventEmitter interface (on/once/off/emit) | | + +### Incremental Backoff and Jitter + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTB1 | Retry timeout calculation (RTB1a–RTB1b) | | + +### Forwards Compatibility (Realtime) + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RTF1 | Robustness principle | | + +### Wrapper SDK Proxy Client + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| WP1–WP7 | Wrapper SDK proxy client | | + +--- + +## Push Notifications + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| RSH1 | Push#admin object (RSH1a–RSH1c5) | | +| RSH2 | Platform-specific push operations (RSH2a–RSH2e) | | +| RSH3 | Activation state machine (RSH3a–RSH3g3) | | +| RSH4–RSH5 | Event queueing and sequential handling | | +| RSH6 | Push device authentication (RSH6a–RSH6b) | | +| RSH7 | Push channels (RSH7a–RSH7e) | | +| RSH8 | LocalDevice (RSH8a–RSH8k2) | | + +--- + +## Types + +### Data Types + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5 | +| DE1–DE2 | DeltaExtras | | +| TP1–TP5 | PresenceMessage | | +| OM1–OM5 | ObjectMessage | | +| OOP1–OOP5 | ObjectOperation | | +| OST1–OST3 | ObjectState | | +| OMO1–OMO3 | ObjectsMapOp | | +| OCO1–OCO3 | ObjectsCounterOp | | +| OMP1–OMP4 | ObjectsMap | | +| OCN1–OCN3 | ObjectsCounter | | +| OME1–OME3 | ObjectsMapEntry | | +| OD1–OD5 | ObjectData | | +| TAN1–TAN3 | Annotation | | +| TR1–TR4 | ProtocolMessage | | +| TG1–TG7 | PaginatedResult | Yes — `rest/unit/types/paginated_result.md`, `rest/integration/pagination.md` | +| HP1–HP8 | HttpPaginatedResponse | Yes — `rest/unit/request.md` | +| TE1–TE6 | TokenRequest | Yes — `rest/unit/types/token_types.md` | +| TD1–TD7 | TokenDetails | Yes — `rest/unit/types/token_types.md` | +| TN1–TN3 | Token string | | +| AD1–AD2 | AuthDetails | | +| TS1–TS14 | Stats | | +| TI1–TI5 | ErrorInfo | Yes — `rest/unit/types/error_types.md` | +| TA1–TA5 | ConnectionStateChange | | +| TH1–TH6 | ChannelStateChange | Yes — `realtime/unit/channels/channel_state_events.md` | +| TC1–TC2 | Capability | | +| CD1–CD2 | ConnectionDetails | | +| CP1–CP2 | ChannelProperties | | +| CHD1–CHD2, CHS1–CHS2, CHO1–CHO2, CHM1–CHM2 | Channel status types | | +| BAR1–BAR2 | BatchResult | | +| BSP1–BSP2 | BatchPublishSpec | | +| BPR1–BPR2, BPF1–BPF2 | BatchPublish result types | | +| BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | | +| PBR1–PBR2 | PublishResult | | +| UDR1–UDR2 | UpdateDeleteResult | | +| TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | | +| MFI1–MFI2 | MessageFilter | | +| REX1–REX2 | ReferenceExtras | | + +### Option Types + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| TO1–TO3 | ClientOptions | Yes — `rest/unit/types/options_types.md` | +| TK1–TK6 | TokenParams | Yes — `rest/unit/types/token_types.md` | +| AO1–AO2 | AuthOptions | Yes — `rest/unit/types/options_types.md` | +| TB1–TB4 | ChannelOptions | Yes — `realtime/unit/channels/channel_options.md` | +| DO1–DO2 | DeriveOptions | Yes — `realtime/unit/channels/channel_options.md` | +| TZ1–TZ2 | CipherParams | | +| CO1–CO2 | CipherParamOptions | | +| WPO1–WPO2 | WrapperSDKProxyOptions | | + +### Push Notification Types + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| PCS1–PCS5 | PushChannelSubscription | | +| PCD1–PCD7 | DeviceDetails | | +| PCP1–PCP4 | DevicePushDetails | | + +### Client Library Introspection + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| CR1–CR3 | ClientInformation | | + +### Client Library Defaults + +| Spec item | Description | UTS test spec | +|-----------|-------------|---------------| +| DF1 | Default values (DF1a–DF1b) | | + +--- + +## Summary + +| Area | Spec groups | With UTS spec | Coverage | +|------|-------------|---------------|----------| +| **Endpoint config** (REC) | 3 | 3 | Full | +| **REST client** (RSC) | 18 | 9 | Partial | +| **REST auth** (RSA) | 15 | 10 | Partial | +| **REST channels** (RSN) | 4 | 0 | None | +| **REST channel** (RSL) | 13 | 6 | Partial | +| **REST presence** (RSP) | 5 | 4 | Mostly | +| **REST encryption** (RSE) | 2 | 0 | None | +| **REST annotations** (RSAN) | 3 | 0 | None | +| **Realtime client** (RTC) | 14 | 8 | Partial | +| **Connection** (RTN) | 23 | 16 | Partial | +| **Realtime channels** (RTS) | 5 | 5 | Full | +| **Realtime channel** (RTL) | 24 | 10 | Partial | +| **Realtime presence** (RTP) | 15 | 0 | None | +| **Realtime annotations** (RTAN) | 5 | 0 | None | +| **EventEmitter** (RTE) | 6 | 0 | None | +| **Backoff/jitter** (RTB) | 1 | 0 | None | +| **Wrapper SDK** (WP) | 7 | 0 | None | +| **Push notifications** (RSH) | 8 | 0 | None | +| **Plugins** (PC/PT/VD) | 3 | 0 | None | +| **Data types** | 30 | 7 | Partial | +| **Option types** | 8 | 5 | Partial | +| **Push types** | 3 | 0 | None | +| **Introspection** (CR) | 1 | 0 | None | +| **Defaults** (DF) | 1 | 0 | None | +| **Compatibility** (RSF/RTF) | 2 | 0 | None | diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md index 166f7b547..254c6eee2 100644 --- a/uts/realtime/unit/channels/channel_attach.md +++ b/uts/realtime/unit/channels/channel_attach.md @@ -528,9 +528,9 @@ ASSERT captured_attach_message.channel == channel_name ## RTL4c1 - ATTACH message includes channelSerial when available -**Spec requirement:** The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial. +**Spec requirement:** The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial. If the RTL15b channelSerial is not set, the field may be set to null or omitted. -Tests that channelSerial is included in ATTACH message when available. +Tests that channelSerial is included in ATTACH message when available. Uses setOptions (RTL16a) to trigger a reattach without going through DETACHED state, since RTL15b1 clears channelSerial on DETACHED. ### Setup ```pseudo @@ -547,11 +547,6 @@ mock_ws = MockWebSocket( channel: channel_name, channelSerial: "serial-from-server-1" )) - ELSE IF msg.action == DETACH: - mock_ws.send_to_client(ProtocolMessage( - action: DETACHED, - channel: channel_name - )) } ) install_mock(mock_ws) @@ -568,11 +563,9 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # First attach - no channelSerial yet AWAIT channel.attach() -# Detach -AWAIT channel.detach() - -# Second attach - should include channelSerial from previous ATTACHED -AWAIT channel.attach() +# Trigger reattach via setOptions (RTL16a) — does NOT go through DETACHED, +# so channelSerial is preserved (RTL15b1 only clears on DETACHED/SUSPENDED/FAILED) +AWAIT channel.setOptions(ChannelOptions(modes: [subscribe])) ``` ### Assertions @@ -580,7 +573,7 @@ AWAIT channel.attach() ASSERT length(captured_attach_messages) == 2 # First attach has no channelSerial (or null) ASSERT captured_attach_messages[0].channelSerial IS null OR captured_attach_messages[0].channelSerial IS NOT SET -# Second attach includes channelSerial from previous attachment +# Second attach (reattach via setOptions) includes channelSerial ASSERT captured_attach_messages[1].channelSerial == "serial-from-server-1" ``` diff --git a/uts/realtime/unit/channels/channel_connection_state.md b/uts/realtime/unit/channels/channel_connection_state.md new file mode 100644 index 000000000..2b5fabb96 --- /dev/null +++ b/uts/realtime/unit/channels/channel_connection_state.md @@ -0,0 +1,888 @@ +# RealtimeChannel Connection State Side Effects Tests + +Spec points: `RTL3`, `RTL3a`, `RTL3b`, `RTL3c`, `RTL3d`, `RTL3e`, `RTL4c1` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL3e - DISCONNECTED has no effect on ATTACHED channel + +**Spec requirement:** If the connection state enters the DISCONNECTED state, it will have no effect on the channel states. + +Tests that a channel in the ATTACHED state is unaffected when the connection transitions to DISCONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL3e-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate transport failure - connection goes to DISCONNECTED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions +```pseudo +# Channel state must remain ATTACHED +ASSERT channel.state == ChannelState.attached + +# No channel state change events should have been emitted +ASSERT length(channel_state_changes) == 0 +``` + +--- + +## RTL3e - DISCONNECTED has no effect on ATTACHING channel + +**Spec requirement:** If the connection state enters the DISCONNECTED state, it will have no effect on the channel states. + +Tests that a channel in the ATTACHING state is unaffected when the connection transitions to DISCONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL3e-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate transport failure - connection goes to DISCONNECTED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions +```pseudo +# Channel state must remain ATTACHING +ASSERT channel.state == ChannelState.attaching + +# No channel state change events should have been emitted +ASSERT length(channel_state_changes) == 0 +``` + +--- + +## RTL3a - FAILED connection transitions ATTACHED channel to FAILED + +**Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED and set the `RealtimeChannel#errorReason`. + +Tests that attached channels transition to FAILED when the connection enters FAILED state. + +### Setup +```pseudo +channel_name = "test-RTL3a-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends a fatal connection-level ERROR +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40198, + statusCode: 403, + message: "Account disabled" + ) +)) +AWAIT_STATE client.connection.state == ConnectionState.failed +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40198 + +# Channel state change event was emitted +ASSERT length(channel_state_changes) >= 1 +failed_change = channel_state_changes.find(c => c.current == ChannelState.failed) +ASSERT failed_change IS NOT null +ASSERT failed_change.previous == ChannelState.attached +ASSERT failed_change.reason IS NOT null +ASSERT failed_change.reason.code == 40198 +``` + +--- + +## RTL3a - FAILED connection transitions ATTACHING channel to FAILED + +**Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED and set the `RealtimeChannel#errorReason`. + +Tests that a channel in the ATTACHING state transitions to FAILED when the connection enters FAILED. + +### Setup +```pseudo +channel_name = "test-RTL3a-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends a fatal connection-level ERROR +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40198, + statusCode: 403, + message: "Account disabled" + ) +)) +AWAIT_STATE client.connection.state == ConnectionState.failed + +# The pending attach should fail +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason IS NOT null + +failed_change = channel_state_changes.find(c => c.current == ChannelState.failed) +ASSERT failed_change IS NOT null +ASSERT failed_change.previous == ChannelState.attaching +``` + +--- + +## RTL3a - Channels in other states are unaffected by FAILED connection + +**Spec requirement:** If the connection state enters the FAILED state, then an ATTACHING or ATTACHED channel state will transition to FAILED. + +Tests that channels in INITIALIZED, DETACHED, SUSPENDED, or FAILED states are not affected when the connection enters FAILED. + +### Setup +```pseudo +initialized_channel_name = "test-RTL3a-init-${random_id()}" +detached_channel_name = "test-RTL3a-detached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +initialized_channel = client.channels.get(initialized_channel_name) +detached_channel = client.channels.get(detached_channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Leave initialized_channel in INITIALIZED state (never attach) +ASSERT initialized_channel.state == ChannelState.initialized + +# Attach then detach to get to DETACHED state +AWAIT detached_channel.attach() +AWAIT detached_channel.detach() +ASSERT detached_channel.state == ChannelState.detached + +# Record state changes on both channels +init_changes = [] +detached_changes = [] +initialized_channel.on().listen((change) => init_changes.append(change)) +detached_channel.on().listen((change) => detached_changes.append(change)) + +# Server sends a fatal connection-level ERROR +mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40198, + statusCode: 403, + message: "Account disabled" + ) +)) +AWAIT_STATE client.connection.state == ConnectionState.failed +``` + +### Assertions +```pseudo +# Channels not in ATTACHING/ATTACHED should be unaffected +ASSERT initialized_channel.state == ChannelState.initialized +ASSERT detached_channel.state == ChannelState.detached +ASSERT length(init_changes) == 0 +ASSERT length(detached_changes) == 0 +``` + +--- + +## RTL3b - CLOSED connection transitions ATTACHED channel to DETACHED + +**Spec requirement:** If the connection state enters the CLOSED state, then an ATTACHING or ATTACHED channel state will transition to DETACHED. + +Tests that an attached channel transitions to DETACHED when the connection is explicitly closed. + +### Setup +```pseudo +channel_name = "test-RTL3b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Close the connection +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached + +detached_change = channel_state_changes.find(c => c.current == ChannelState.detached) +ASSERT detached_change IS NOT null +ASSERT detached_change.previous == ChannelState.attached +``` + +--- + +## RTL3b - CLOSED connection transitions ATTACHING channel to DETACHED + +**Spec requirement:** If the connection state enters the CLOSED state, then an ATTACHING or ATTACHED channel state will transition to DETACHED. + +Tests that a channel in the ATTACHING state transitions to DETACHED when the connection is closed. + +### Setup +```pseudo +channel_name = "test-RTL3b-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Close the connection +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# The pending attach should fail +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached + +detached_change = channel_state_changes.find(c => c.current == ChannelState.detached) +ASSERT detached_change IS NOT null +ASSERT detached_change.previous == ChannelState.attaching +``` + +--- + +## RTL3c - SUSPENDED connection transitions ATTACHED channel to SUSPENDED + +**Spec requirement:** If the connection state enters the SUSPENDED state, then an ATTACHING or ATTACHED channel state will transition to SUSPENDED. + +Tests that an attached channel transitions to SUSPENDED when the connection enters SUSPENDED state. + +### Setup +```pseudo +channel_name = "test-RTL3c-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [] +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate disconnect - all reconnection attempts will fail +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +# Advance time past connectionStateTtl (default 120s) to reach SUSPENDED +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended + +suspended_change = channel_state_changes.find(c => c.current == ChannelState.suspended) +ASSERT suspended_change IS NOT null +ASSERT suspended_change.previous == ChannelState.attached +``` + +--- + +## RTL3c - SUSPENDED connection transitions ATTACHING channel to SUSPENDED + +**Spec requirement:** If the connection state enters the SUSPENDED state, then an ATTACHING or ATTACHED channel state will transition to SUSPENDED. + +Tests that a channel in the ATTACHING state transitions to SUSPENDED when the connection enters SUSPENDED state. + +### Setup +```pseudo +channel_name = "test-RTL3c-attaching-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond - leave channel in ATTACHING state + pass + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [] +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await - server won't respond +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate disconnect - all reconnection attempts will fail +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +# Advance time past connectionStateTtl (default 120s) to reach SUSPENDED +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended + +suspended_change = channel_state_changes.find(c => c.current == ChannelState.suspended) +ASSERT suspended_change IS NOT null +ASSERT suspended_change.previous == ChannelState.attaching +``` + +--- + +## RTL3d, RTL4c1 - CONNECTED connection re-attaches ATTACHED channels with channelSerial + +| Spec | Requirement | +|------|-------------| +| RTL3d | If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence | +| RTL4c1 | The ATTACH ProtocolMessage channelSerial field must be set to the RTL15b channelSerial | + +Tests that when a connection is re-established, previously attached channels are re-attached automatically, and that the re-attach ATTACH message includes the channel's stored channelSerial. + +### Setup +```pseudo +channel_name = "test-RTL3d-attached-${random_id()}" +attach_messages = [] + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 100 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT length(attach_messages) == 1 + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Simulate disconnect +mock_ws.active_connection.simulate_disconnect() + +# Wait for reconnection and re-attach +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# A second ATTACH message was sent for the re-attach +ASSERT length(attach_messages) == 2 + +# RTL4c1: The re-attach ATTACH message must include the channelSerial +# from the previous ATTACHED response +ASSERT attach_messages[1].channelSerial == "serial-001" + +# Channel transitioned through ATTACHING during re-attach +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] +``` + +--- + +## RTL3d - CONNECTED connection re-attaches SUSPENDED channels + +**Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence. + +Tests that suspended channels are re-attached when the connection is re-established. + +### Setup +```pseudo +channel_name = "test-RTL3d-suspended-${random_id()}" +attach_message_count = 0 + +enable_fake_timers() + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [], + suspendedRetryTimeout: 2000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 + +# Simulate disconnect - all reconnection attempts will fail +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +# Advance time past connectionStateTtl to reach SUSPENDED +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +ASSERT channel.state == ChannelState.suspended + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Allow reconnection to succeed +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_success(CONNECTED_MESSAGE) + +# Advance time past suspendedRetryTimeout to trigger retry +LOOP up to 10 times: + ADVANCE_TIME(2500) + IF client.connection.state == ConnectionState.connected: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# An ATTACH message was sent for the re-attach +ASSERT attach_message_count >= 2 + +# Channel transitioned from SUSPENDED through ATTACHING to ATTACHED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.attached +] +``` + +--- + +## RTL3d - Channels in INITIALIZED or DETACHED are not re-attached on CONNECTED + +**Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING. + +Tests that channels in INITIALIZED or DETACHED states are not affected when the connection becomes CONNECTED. + +### Setup +```pseudo +initialized_channel_name = "test-RTL3d-init-${random_id()}" +detached_channel_name = "test-RTL3d-detached-${random_id()}" +attach_messages = [] + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 100 +)) +initialized_channel = client.channels.get(initialized_channel_name) +detached_channel = client.channels.get(detached_channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Leave initialized_channel in INITIALIZED state +ASSERT initialized_channel.state == ChannelState.initialized + +# Attach then detach to get to DETACHED state +AWAIT detached_channel.attach() +AWAIT detached_channel.detach() +ASSERT detached_channel.state == ChannelState.detached + +attach_count_before = length(attach_messages) + +# Record state changes +init_changes = [] +detached_changes = [] +initialized_channel.on().listen((change) => init_changes.append(change)) +detached_channel.on().listen((change) => detached_changes.append(change)) + +# Simulate disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# Neither channel should have been re-attached +ASSERT initialized_channel.state == ChannelState.initialized +ASSERT detached_channel.state == ChannelState.detached +ASSERT length(init_changes) == 0 +ASSERT length(detached_changes) == 0 + +# No new ATTACH messages for these channels +attach_count_after = length(attach_messages) +new_attach_channels = [m.channel FOR m IN attach_messages[attach_count_before:]] +ASSERT initialized_channel_name NOT IN new_attach_channels +ASSERT detached_channel_name NOT IN new_attach_channels +``` + +--- + +## RTL3d - Multiple channels re-attached on CONNECTED + +**Spec requirement:** If the connection state enters the CONNECTED state, any channels in the ATTACHING, ATTACHED, or SUSPENDED states should be transitioned to ATTACHING and initiate an RTL4c attach sequence. + +Tests that multiple channels in eligible states are all re-attached when the connection is restored. + +### Setup +```pseudo +channel1_name = "test-RTL3d-multi1-${random_id()}" +channel2_name = "test-RTL3d-multi2-${random_id()}" +attach_messages = [] + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 100 +)) +channel1 = client.channels.get(channel1_name) +channel2 = client.channels.get(channel2_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel1.attach() +AWAIT channel2.attach() +ASSERT channel1.state == ChannelState.attached +ASSERT channel2.state == ChannelState.attached + +attach_count_before = length(attach_messages) + +# Simulate disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT_STATE channel1.state == ChannelState.attached +AWAIT_STATE channel2.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel1.state == ChannelState.attached +ASSERT channel2.state == ChannelState.attached + +# Both channels should have received new ATTACH messages +new_attach_channels = [m.channel FOR m IN attach_messages[attach_count_before:]] +ASSERT channel1_name IN new_attach_channels +ASSERT channel2_name IN new_attach_channels +``` diff --git a/uts/realtime/unit/channels/channel_error.md b/uts/realtime/unit/channels/channel_error.md new file mode 100644 index 000000000..bfb1c0b57 --- /dev/null +++ b/uts/realtime/unit/channels/channel_error.md @@ -0,0 +1,352 @@ +# Channel ERROR Protocol Message Tests + +Spec points: `RTL14` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL14 - Channel ERROR transitions ATTACHED channel to FAILED + +**Spec requirement:** If an ERROR ProtocolMessage is received for this channel (the channel attribute matches this channel's name), then the channel should immediately transition to the FAILED state, and the RealtimeChannel.errorReason should be set. + +Tests that receiving a channel-scoped ERROR while ATTACHED causes the channel to transition to FAILED with the error. + +### Setup +```pseudo +channel_name = "test-RTL14-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record channel state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends channel-scoped ERROR (e.g., permission revoked) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel transitioned to FAILED +ASSERT channel.state == ChannelState.failed + +# errorReason is set +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.statusCode == 401 +ASSERT channel.errorReason.message CONTAINS "Not permitted" + +# State change event emitted +ASSERT length(channel_state_changes) == 1 +ASSERT channel_state_changes[0].current == ChannelState.failed +ASSERT channel_state_changes[0].previous == ChannelState.attached +ASSERT channel_state_changes[0].reason IS NOT null +ASSERT channel_state_changes[0].reason.code == 40160 + +# Connection stays open (channel-scoped ERROR does NOT close connection) +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTL14 - Channel ERROR transitions ATTACHING channel to FAILED + +**Spec requirement:** If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to FAILED. + +Tests that receiving a channel-scoped ERROR while ATTACHING causes the channel to transition to FAILED and the pending attach operation to fail. + +### Setup +```pseudo +channel_name = "test-RTL14-attaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Respond with channel ERROR instead of ATTACHED + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: msg.channel, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach should fail +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +# Channel is in FAILED state +ASSERT channel.state == ChannelState.failed + +# errorReason is set +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 + +# The error from attach() matches the channel error +ASSERT error.code == 40160 + +# Connection stays open +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTL14 - Channel ERROR completes pending detach with error + +**Spec requirement:** If an ERROR ProtocolMessage is received for this channel, the channel should immediately transition to FAILED. + +Tests that if a channel ERROR is received while a detach is pending (DETACHING state), the channel transitions to FAILED and the pending detach operation fails with the error. + +### Setup +```pseudo +channel_name = "test-RTL14-detaching-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + # Respond with ERROR instead of DETACHED + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: msg.channel, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach failed") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Detach should fail +AWAIT channel.detach() FAILS WITH error +``` + +### Assertions +```pseudo +# Channel is in FAILED state (not DETACHED) +ASSERT channel.state == ChannelState.failed + +# errorReason is set +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 90198 + +# The error from detach() matches +ASSERT error.code == 90198 + +# Connection stays open +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTL14 - Channel ERROR does not affect other channels + +**Spec requirement:** The ERROR ProtocolMessage with a channel attribute only affects that specific channel. + +Tests that a channel-scoped ERROR only transitions the target channel to FAILED, leaving other channels unaffected. + +### Setup +```pseudo +channel_name_a = "test-RTL14-a-${random_id()}" +channel_name_b = "test-RTL14-b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel_a = client.channels.get(channel_name_a) +channel_b = client.channels.get(channel_name_b) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel_a.attach() +AWAIT channel_b.attach() +ASSERT channel_a.state == ChannelState.attached +ASSERT channel_b.state == ChannelState.attached + +# Send ERROR only for channel A +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name_a, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel_a.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel A is FAILED +ASSERT channel_a.state == ChannelState.failed +ASSERT channel_a.errorReason IS NOT null + +# Channel B is unaffected +ASSERT channel_b.state == ChannelState.attached +ASSERT channel_b.errorReason IS null + +# Connection stays open +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTL14 - Channel ERROR cancels pending timers + +**Spec requirement:** When the channel transitions to FAILED, any pending timers (attach timeout, channel retry) should be cancelled. + +Tests that receiving a channel ERROR while a channel retry timer is pending cancels the timer. + +### Setup +```pseudo +channel_name = "test-RTL14-timers-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + # Don't respond to subsequent attaches + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + suspendedRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_count == 1 + +# Trigger server-initiated DETACHED -> reattach -> timeout -> SUSPENDED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach") +)) +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Channel retry timer is now pending (suspendedRetryTimeout = 200ms) +# Send ERROR before the retry fires +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel.state == ChannelState.failed + +attach_count_after_error = attach_count + +# Advance time well past the suspendedRetryTimeout +ADVANCE_TIME(500) +``` + +### Assertions +```pseudo +# Channel remains FAILED - no retry was attempted +ASSERT channel.state == ChannelState.failed +ASSERT attach_count == attach_count_after_error +``` diff --git a/uts/realtime/unit/channels/channel_properties.md b/uts/realtime/unit/channels/channel_properties.md new file mode 100644 index 000000000..79bdfa49d --- /dev/null +++ b/uts/realtime/unit/channels/channel_properties.md @@ -0,0 +1,546 @@ +# Channel Properties Tests + +Spec points: `RTL15`, `RTL15a`, `RTL15b`, `RTL15b1` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL15a - attachSerial is updated from ATTACHED message + +| Spec | Requirement | +|------|-------------| +| RTL15 | `RealtimeChannel#properties` is a `ChannelProperties` object with `attachSerial` and `channelSerial` | +| RTL15a | `attachSerial` is unset when instantiated, and updated with the `channelSerial` from each ATTACHED ProtocolMessage received | + +Tests that the channel's `attachSerial` property is initially unset, is set from the `channelSerial` field of the ATTACHED response, and is updated on subsequent ATTACHED messages (e.g. after reattach). + +### Setup +```pseudo +channel_name = "test-RTL15a-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "attach-serial-${attach_count}" + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Before connecting, attachSerial should be unset +ASSERT channel.properties.attachSerial IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach +AWAIT channel.attach() +``` + +### Assertions +```pseudo +# attachSerial set from ATTACHED response +ASSERT channel.properties.attachSerial == "attach-serial-1" + +# Detach and reattach to get a new attachSerial +AWAIT channel.detach() +AWAIT channel.attach() + +# attachSerial updated from second ATTACHED response +ASSERT channel.properties.attachSerial == "attach-serial-2" +``` + +--- + +## RTL15a - attachSerial updated on server-initiated reattach + +**Spec requirement:** `attachSerial` is updated with the `channelSerial` from each ATTACHED ProtocolMessage received. + +Tests that when the server sends an unsolicited ATTACHED message (e.g. RTL2g update), the `attachSerial` is updated. + +### Setup +```pseudo +channel_name = "test-RTL15a-update-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "initial-serial" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.attachSerial == "initial-serial" + +# Server sends unsolicited ATTACHED (e.g. RTL2g update) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + channelSerial: "updated-serial" +)) +AWAIT_STATE channel.properties.attachSerial == "updated-serial" +``` + +### Assertions +```pseudo +ASSERT channel.properties.attachSerial == "updated-serial" +``` + +--- + +## RTL15b - channelSerial updated from ATTACHED message + +| Spec | Requirement | +|------|-------------| +| RTL15b | `channelSerial` is updated whenever a ProtocolMessage with MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED action is received, set to the `channelSerial` of that message, if and only if that field is populated | + +Tests that `channelSerial` is set from the ATTACHED response's `channelSerial` field. + +### Setup +```pseudo +channel_name = "test-RTL15b-attached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Before attach, channelSerial should be unset +ASSERT channel.properties.channelSerial IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.properties.channelSerial == "serial-001" +``` + +--- + +## RTL15b - channelSerial updated from MESSAGE and PRESENCE actions + +**Spec requirement:** `channelSerial` is updated whenever a ProtocolMessage with MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED action is received. + +Tests that receiving MESSAGE and PRESENCE protocol messages with a `channelSerial` field updates the channel's `channelSerial` property. + +### Setup +```pseudo +channel_name = "test-RTL15b-messages-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends MESSAGE with channelSerial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + channelSerial: "serial-002", + messages: [ + Message(name: "event", data: "data") + ] +)) +AWAIT_STATE channel.properties.channelSerial == "serial-002" + +# Server sends PRESENCE with channelSerial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + channelSerial: "serial-003" +)) +AWAIT_STATE channel.properties.channelSerial == "serial-003" +``` + +### Assertions +```pseudo +ASSERT channel.properties.channelSerial == "serial-003" +``` + +--- + +## RTL15b - channelSerial not updated when field is not populated + +**Spec requirement:** `channelSerial` is set to the channelSerial of the ProtocolMessage, if and only if that field is populated. + +Tests that receiving a protocol message without a `channelSerial` field does not clear or change the channel's existing `channelSerial`. + +### Setup +```pseudo +channel_name = "test-RTL15b-noupdate-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends MESSAGE without channelSerial +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event", data: "data") + ] +)) +``` + +### Assertions +```pseudo +# channelSerial should remain unchanged +ASSERT channel.properties.channelSerial == "serial-001" +``` + +--- + +## RTL15b - channelSerial not updated from irrelevant actions + +**Spec requirement:** `channelSerial` is updated only for MESSAGE, PRESENCE, ANNOTATION, OBJECT, or ATTACHED actions. + +Tests that receiving a protocol message with a different action (e.g. ERROR, DETACHED) does not update `channelSerial`, even if the message happens to contain a `channelSerial` field. + +### Setup +```pseudo +channel_name = "test-RTL15b-irrelevant-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends DETACHED with a channelSerial field +# (RTL13a will trigger reattach, but the DETACHED itself should not update channelSerial) +# Record channelSerial before the DETACHED +serial_before = channel.properties.channelSerial + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + channelSerial: "serial-should-not-apply", + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detached") +)) + +# Wait for the reattach to complete (RTL13a) +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# channelSerial should be from the new ATTACHED, not from DETACHED +# The DETACHED action should not have updated channelSerial +# (RTL15b1 clears it on DETACHED/SUSPENDED/FAILED, then ATTACHED sets it fresh) +ASSERT attach_count == 2 +ASSERT channel.properties.channelSerial == "serial-001" +``` + +--- + +## RTL15b1 - channelSerial cleared on DETACHED state + +| Spec | Requirement | +|------|-------------| +| RTL15b1 | If the channel enters the DETACHED, SUSPENDED, or FAILED state, it must clear its channelSerial | + +Tests that `channelSerial` is cleared when the channel transitions to DETACHED. + +### Setup +```pseudo +channel_name = "test-RTL15b1-detached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT channel.properties.channelSerial IS null +``` + +--- + +## RTL15b1 - channelSerial cleared on SUSPENDED state + +**Spec requirement:** If the channel enters the SUSPENDED state, it must clear its `channelSerial`. + +Tests that `channelSerial` is cleared when the channel transitions to SUSPENDED (e.g. due to attach timeout). + +### Setup +```pseudo +channel_name = "test-RTL15b1-suspended-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + # Don't respond to second attach (causes timeout -> SUSPENDED) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Trigger server-initiated DETACHED -> reattach attempt that will timeout +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detached") +)) +AWAIT_STATE channel.state == ChannelState.attaching + +# Let attach timeout -> SUSPENDED +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.suspended +ASSERT channel.properties.channelSerial IS null +``` + +--- + +## RTL15b1 - channelSerial cleared on FAILED state + +**Spec requirement:** If the channel enters the FAILED state, it must clear its `channelSerial`. + +Tests that `channelSerial` is cleared when the channel transitions to FAILED (e.g. due to channel ERROR). + +### Setup +```pseudo +channel_name = "test-RTL15b1-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel, + channelSerial: "serial-001" + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.properties.channelSerial == "serial-001" + +# Server sends channel ERROR -> FAILED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") +)) +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.failed +ASSERT channel.properties.channelSerial IS null +``` diff --git a/uts/realtime/unit/channels/channel_server_initiated_detach.md b/uts/realtime/unit/channels/channel_server_initiated_detach.md new file mode 100644 index 000000000..cff98929f --- /dev/null +++ b/uts/realtime/unit/channels/channel_server_initiated_detach.md @@ -0,0 +1,591 @@ +# Server-Initiated DETACHED and Channel Retry Tests + +Spec points: `RTL13`, `RTL13a`, `RTL13b`, `RTL13c` + +## Test Type +Unit test with mocked WebSocket and fake timers + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL13a - Server DETACHED on ATTACHED channel triggers immediate reattach + +| Spec | Requirement | +|------|-------------| +| RTL13 | If the channel receives a server-initiated DETACHED when ATTACHING, ATTACHED, or SUSPENDED, specific handling applies | +| RTL13a | If ATTACHED or SUSPENDED, an immediate reattach attempt should be made by sending ATTACH, transitioning to ATTACHING with the error from the DETACHED message | + +Tests that receiving a server-initiated DETACHED while ATTACHED causes the channel to transition to ATTACHING with the error, send a new ATTACH message, and successfully reattach. + +### Setup +```pseudo +channel_name = "test-RTL13a-attached-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached +ASSERT attach_count == 1 + +# Record channel state changes from this point +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends unsolicited DETACHED with error +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Server detached channel") +)) + +# Channel should reattach automatically +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# Two ATTACH messages total: initial + reattach +ASSERT attach_count == 2 + +# State change sequence: ATTACHING (with error) -> ATTACHED +ASSERT length(channel_state_changes) >= 2 +ASSERT channel_state_changes[0].current == ChannelState.attaching +ASSERT channel_state_changes[0].previous == ChannelState.attached +ASSERT channel_state_changes[0].reason IS NOT null +ASSERT channel_state_changes[0].reason.code == 90198 +ASSERT channel_state_changes[1].current == ChannelState.attached +``` + +--- + +## RTL13a - Server DETACHED on SUSPENDED channel triggers immediate reattach + +**Spec requirement:** If the channel is in the SUSPENDED state and receives a server-initiated DETACHED, an immediate reattach attempt should be made. + +Tests that receiving a server-initiated DETACHED while SUSPENDED causes the channel to transition to ATTACHING and reattach. + +### Setup +```pseudo +channel_name = "test-RTL13a-suspended-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF attach_count == 2: + # Second attach (after timeout) - don't respond, causing timeout -> SUSPENDED + ELSE: + # Third attach (after server DETACHED) - succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + suspendedRetryTimeout: 60000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Force channel into SUSPENDED state by triggering a reattach that times out: +# Send server-initiated DETACHED to trigger RTL13a reattach +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach 1") +)) +AWAIT_STATE channel.state == ChannelState.attaching + +# Let the reattach timeout -> SUSPENDED +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Now send another server-initiated DETACHED while SUSPENDED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90199, statusCode: 500, message: "Detach 2") +)) + +# Channel should immediately attempt to reattach and succeed +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +# 3 total ATTACH messages: initial + RTL13a reattach + RTL13a reattach from SUSPENDED +ASSERT attach_count == 3 +``` + +--- + +## RTL13b - Failed reattach transitions to SUSPENDED with automatic retry + +| Spec | Requirement | +|------|-------------| +| RTL13b | If the reattach fails, or if the channel was already ATTACHING, channel transitions to SUSPENDED. An automatic re-attach attempt is made after suspendedRetryTimeout. If that also fails (timeout or DETACHED), the cycle repeats indefinitely. | + +Tests that when a server-initiated DETACHED triggers a reattach that times out, the channel transitions to SUSPENDED and then automatically retries after the suspended retry timeout. + +### Setup +```pseudo +channel_name = "test-RTL13b-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF attach_count == 2: + # Reattach after server DETACHED - don't respond (timeout) + ELSE IF attach_count == 3: + # Automatic retry after suspendedRetryTimeout - succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + suspendedRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Record state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends unsolicited DETACHED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Server detached") +)) + +# Channel should be in ATTACHING (RTL13a) +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_count == 2 + +# Let reattach timeout -> SUSPENDED (RTL13b) +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Wait for suspendedRetryTimeout to trigger automatic retry and succeed +ADVANCE_TIME(250) +AWAIT_STATE channel.state == ChannelState.attached +ASSERT attach_count == 3 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_count == 3 + +# Verify state sequence: ATTACHING -> SUSPENDED -> ATTACHING -> ATTACHED +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.suspended, + ChannelState.attaching, + ChannelState.attached +] +``` + +--- + +## RTL13b - Server DETACHED while already ATTACHING transitions directly to SUSPENDED + +**Spec requirement:** If the channel was already in the ATTACHING state when the server-initiated DETACHED is received, the channel transitions directly to SUSPENDED (with automatic retry). + +Tests that a server-initiated DETACHED received while ATTACHING goes directly to SUSPENDED without another reattach attempt first. + +### Setup +```pseudo +channel_name = "test-RTL13b-attaching-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach - don't respond immediately, leave channel in ATTACHING + ELSE IF attach_count == 2: + # Automatic retry from SUSPENDED - succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 500, + suspendedRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't await it (mock won't respond) +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Record state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends DETACHED while channel is still ATTACHING +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Server detached") +)) + +# Channel should go directly to SUSPENDED (RTL13b), not try another reattach +AWAIT_STATE channel.state == ChannelState.suspended +ASSERT attach_count == 1 # Only the original attach, no second attempt + +# Wait for suspendedRetryTimeout — automatic retry should succeed +ADVANCE_TIME(250) +AWAIT_STATE channel.state == ChannelState.attached +ASSERT attach_count == 2 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# Verify direct transition to SUSPENDED (no intermediate ATTACHING) +ASSERT channel_state_changes[0].current == ChannelState.suspended +ASSERT channel_state_changes[0].previous == ChannelState.attaching +ASSERT channel_state_changes[0].reason IS NOT null +ASSERT channel_state_changes[0].reason.code == 90198 +``` + +--- + +## RTL13b - Repeated failures cycle SUSPENDED -> ATTACHING indefinitely + +**Spec requirement:** If the re-attach also fails (timeout or DETACHED), the SUSPENDED -> retry cycle repeats indefinitely. + +Tests that repeated reattach failures produce repeated SUSPENDED -> ATTACHING cycles. + +### Setup +```pseudo +channel_name = "test-RTL13b-repeat-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF attach_count <= 3: + # Reattach attempts 2 and 3 - don't respond (timeout) + ELSE: + # Fourth attempt succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + suspendedRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_count == 1 + +# Record state changes +channel_state_changes = [] +channel.on().listen((change) => channel_state_changes.append(change)) + +# Server sends DETACHED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach") +)) + +# Cycle 1: ATTACHING (reattach) -> timeout -> SUSPENDED -> retry +AWAIT_STATE channel.state == ChannelState.attaching +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended +ADVANCE_TIME(250) +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_count == 3 + +# Cycle 2: ATTACHING (retry) -> timeout -> SUSPENDED -> retry +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended +ADVANCE_TIME(250) + +# Fourth attempt succeeds +AWAIT_STATE channel.state == ChannelState.attached +ASSERT attach_count == 4 +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_count == 4 + +# Verify repeated cycling +ASSERT channel_state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching, + ChannelState.suspended, + ChannelState.attaching, + ChannelState.suspended, + ChannelState.attaching, + ChannelState.attached +] +``` + +--- + +## RTL13c - Retry cancelled when connection is no longer CONNECTED + +| Spec | Requirement | +|------|-------------| +| RTL13c | If the connection is no longer CONNECTED, the automatic re-attach attempts described in RTL13b must be cancelled, as any implicit channel state changes will be covered by RTL3 | + +Tests that when the connection leaves the CONNECTED state, any pending automatic channel retry is cancelled. + +### Setup +```pseudo +channel_name = "test-RTL13c-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach succeeds + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE: + # Don't respond to reattach attempts (timeout) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100, + suspendedRetryTimeout: 200 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_count == 1 + +# Server sends DETACHED +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90198, statusCode: 500, message: "Detach") +)) + +# Reattach triggered (RTL13a) but will timeout +AWAIT_STATE channel.state == ChannelState.attaching +ADVANCE_TIME(150) +AWAIT_STATE channel.state == ChannelState.suspended + +# Now disconnect the connection BEFORE the suspendedRetryTimeout fires +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state != ConnectionState.connected + +# Record attach_count at this point +attach_count_after_disconnect = attach_count + +# Advance time well past the suspendedRetryTimeout +ADVANCE_TIME(500) +``` + +### Assertions +```pseudo +# No additional ATTACH messages should have been sent after disconnect +ASSERT attach_count == attach_count_after_disconnect + +# Channel state is now governed by RTL3, not RTL13 +# (connection DISCONNECTED does not affect channel state per RTL3e, +# so channel should still be SUSPENDED) +ASSERT channel.state == ChannelState.suspended +``` + +--- + +## RTL13a - DETACHED while DETACHING is not server-initiated + +**Spec requirement:** RTL13 applies when the channel receives a server-initiated DETACHED when it is in ATTACHING, ATTACHED, or SUSPENDED. A channel in the DETACHING state has explicitly requested a detach, so a DETACHED response in that state is handled by the normal detach flow (RTL5), not RTL13. + +Tests that receiving a DETACHED while DETACHING completes the normal detach flow rather than triggering a reattach. + +### Setup +```pseudo +channel_name = "test-RTL13-detaching-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +AWAIT channel.detach() +``` + +### Assertions +```pseudo +# Channel should be cleanly DETACHED, not re-attached +ASSERT channel.state == ChannelState.detached + +# Only one ATTACH message (the initial attach, no reattach) +ASSERT attach_count == 1 +``` diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index ab7892fca..08e93ba2d 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -852,7 +852,6 @@ original_connection_key = client.connection.key # Force disconnect - triggers immediate reconnect per RTN15a ws_connection = mock_ws.events.find(e => e.type == CONNECTION_SUCCESS).connection ws_connection.simulate_disconnect() -PUMP_EVENT_QUEUE() # Reconnection attempts keep failing (connection refused). # Advance time in increments to allow retries, TTL expiry, @@ -861,7 +860,6 @@ PUMP_EVENT_QUEUE() # suspendedRetryTimeout is 2000ms. LOOP up to 15 times: ADVANCE_TIME(2500) - PUMP_EVENT_QUEUE() IF client.connection.state == ConnectionState.connected: BREAK diff --git a/uts/realtime/unit/connection/connection_id_key_test.md b/uts/realtime/unit/connection/connection_id_key_test.md new file mode 100644 index 000000000..1571dc052 --- /dev/null +++ b/uts/realtime/unit/connection/connection_id_key_test.md @@ -0,0 +1,360 @@ +# Connection ID and Key Tests + +Spec points: `RTN8`, `RTN8a`, `RTN8b`, `RTN8c`, `RTN9`, `RTN9a`, `RTN9b`, `RTN9c` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTN8a - Connection ID is unset until connected + +| Spec | Requirement | +|------|-------------| +| RTN8 | `Connection#id` attribute | +| RTN8a | Is unset until connected | + +Tests that `connection.id` is null before the connection is established and is set after CONNECTED. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "unique-conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Before connecting, id should be null +ASSERT client.connection.id IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client.connection.id == "unique-conn-id-1" +``` + +--- + +## RTN9a - Connection key is unset until connected + +| Spec | Requirement | +|------|-------------| +| RTN9 | `Connection#key` attribute | +| RTN9a | Is unset until connected | + +Tests that `connection.key` is null before the connection is established and is set after CONNECTED. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "unique-conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Before connecting, key should be null +ASSERT client.connection.key IS null + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client.connection.key == "conn-key-1" +``` + +--- + +## RTN8b - Connection ID is unique per connection + +| Spec | Requirement | +|------|-------------| +| RTN8b | Is a unique string provided by Ably. Multiple connected clients have unique connection IDs | + +Tests that two separate clients receive different connection IDs from the server. + +### Setup +```pseudo +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success( + CONNECTED_MESSAGE( + connectionId: "conn-id-${connection_count}", + connectionKey: "conn-key-${connection_count}" + ) + ) + } +) +install_mock(mock_ws) + +client1 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +client2 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client1.connect() +AWAIT_STATE client1.connection.state == ConnectionState.connected + +client2.connect() +AWAIT_STATE client2.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client1.connection.id != client2.connection.id +ASSERT client1.connection.id == "conn-id-1" +ASSERT client2.connection.id == "conn-id-2" +``` + +--- + +## RTN9b - Connection key is unique per connection + +| Spec | Requirement | +|------|-------------| +| RTN9b | Is a unique private connection key. Multiple connected clients have unique connection keys | + +Tests that two separate clients receive different connection keys from the server. + +### Setup +```pseudo +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success( + CONNECTED_MESSAGE( + connectionId: "conn-id-${connection_count}", + connectionKey: "conn-key-${connection_count}" + ) + ) + } +) +install_mock(mock_ws) + +client1 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +client2 = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client1.connect() +AWAIT_STATE client1.connection.state == ConnectionState.connected + +client2.connect() +AWAIT_STATE client2.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +ASSERT client1.connection.key != client2.connection.key +ASSERT client1.connection.key == "conn-key-1" +ASSERT client2.connection.key == "conn-key-2" +``` + +--- + +## RTN8c - Connection ID is null in terminal/non-connected states + +| Spec | Requirement | +|------|-------------| +| RTN8c | Is null when the SDK is in CLOSED, CLOSING, FAILED, or SUSPENDED states | + +Tests that `connection.id` is cleared when the connection enters CLOSED or FAILED states. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "conn-id-1" + +# Close the connection +AWAIT client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +### Assertions +```pseudo +ASSERT client.connection.id IS null +``` + +--- + +## RTN9c - Connection key is null in terminal/non-connected states + +| Spec | Requirement | +|------|-------------| +| RTN9c | Is null when the SDK is in CLOSED, CLOSING, FAILED, or SUSPENDED states | + +Tests that `connection.key` is cleared when the connection enters CLOSED or FAILED states. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT client.connection.key == "conn-key-1" + +# Close the connection +AWAIT client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed +``` + +### Assertions +```pseudo +ASSERT client.connection.key IS null +``` + +--- + +## RTN8c, RTN9c - ID and key null after FAILED + +**Spec requirement:** Connection ID and key are null in FAILED state. + +Tests that both `connection.id` and `connection.key` are cleared when the connection transitions to FAILED (e.g. due to a fatal error). + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, statusCode: 400, message: "Fatal error") + ) + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed +``` + +### Assertions +```pseudo +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +``` + +--- + +## RTN8c, RTN9c - ID and key null after SUSPENDED + +**Spec requirement:** Connection ID and key are null in SUSPENDED state. + +Tests that both `connection.id` and `connection.key` are null when the connection transitions to SUSPENDED. + +### Setup +```pseudo +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [] +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance past connectionStateTtl to reach SUSPENDED +ADVANCE_TIME(121s) +AWAIT_STATE client.connection.state == ConnectionState.suspended +``` + +### Assertions +```pseudo +ASSERT client.connection.id IS null +ASSERT client.connection.key IS null +``` diff --git a/uts/realtime/unit/connection/connection_ping_test.md b/uts/realtime/unit/connection/connection_ping_test.md new file mode 100644 index 000000000..b2b6d1299 --- /dev/null +++ b/uts/realtime/unit/connection/connection_ping_test.md @@ -0,0 +1,760 @@ +# Connection Ping Tests (RTN13) + +Spec points: `RTN13`, `RTN13a`, `RTN13b`, `RTN13c`, `RTN13d`, `RTN13e` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Overview + +RTN13 defines the `Connection#ping()` function: + +- **RTN13a**: Sends a `ProtocolMessage` with action `HEARTBEAT` and expects a `HEARTBEAT` response. Returns the round-trip duration. +- **RTN13b**: Returns an error if in, or transitions to, `INITIALIZED`, `SUSPENDED`, `CLOSING`, `CLOSED`, or `FAILED`. +- **RTN13c**: Fails with a timeout error if no `HEARTBEAT` response is received within `realtimeRequestTimeout`. +- **RTN13d**: If connection state is `CONNECTING` or `DISCONNECTED`, the operation is deferred and executed once the state becomes `CONNECTED`. +- **RTN13e**: The sent `HEARTBEAT` includes an `id` property with a random string. Only a response `HEARTBEAT` with a matching `id` is considered a valid response — this disambiguates from normal heartbeats and other pings. + +--- + +## RTN13a - Ping sends HEARTBEAT and returns round-trip duration + +| Spec | Requirement | +|------|-------------| +| RTN13a | Sends HEARTBEAT when connected and expects HEARTBEAT response with round-trip time | + +Tests that `connection.ping()` sends a HEARTBEAT protocol message and resolves with the elapsed duration when a matching HEARTBEAT response is received. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + # Echo back a HEARTBEAT with matching id + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT client.connection.ping() +``` + +### Assertions +```pseudo +# Ping should resolve successfully +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# Verify a HEARTBEAT was sent by the client +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 1 +``` + +--- + +## RTN13e - HEARTBEAT includes random id for disambiguation + +| Spec | Requirement | +|------|-------------| +| RTN13e | Sent HEARTBEAT includes random id; only matching response counts | + +Tests that the sent HEARTBEAT includes a random `id` and that only a response with the same `id` is accepted. + +### Setup +```pseudo +captured_heartbeat_id = null + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + captured_heartbeat_id = msg.id + # First send a HEARTBEAT with a DIFFERENT id (should be ignored) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: "wrong-id" + )) + # Then send a HEARTBEAT with the matching id (should resolve ping) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT client.connection.ping() +``` + +### Assertions +```pseudo +# Ping should resolve (matched the correct id) +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# The sent HEARTBEAT should have had a non-empty id +ASSERT captured_heartbeat_id IS NOT null +ASSERT captured_heartbeat_id.length > 0 +``` + +--- + +## RTN13e - HEARTBEAT with no id is ignored as ping response + +| Spec | Requirement | +|------|-------------| +| RTN13e | Only a HEARTBEAT with matching id counts as a ping response | + +Tests that a server-initiated HEARTBEAT (no `id` field) does not resolve a pending ping. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + # Send a HEARTBEAT without an id (like a server-initiated heartbeat) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT + )) + # Then send the correct response + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT client.connection.ping() +``` + +### Assertions +```pseudo +# Ping should resolve (ignored the no-id heartbeat, matched the correct one) +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero +``` + +--- + +## RTN13e - Multiple concurrent pings each get their own response + +| Spec | Requirement | +|------|-------------| +| RTN13e | Each ping has a unique random id for disambiguation | + +Tests that two concurrent pings each resolve independently via their unique ids. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + # Echo back with matching id + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start two pings concurrently +ping1_future = client.connection.ping() +ping2_future = client.connection.ping() + +duration1 = AWAIT ping1_future +duration2 = AWAIT ping2_future +``` + +### Assertions +```pseudo +# Both pings should resolve +ASSERT duration1 IS NOT null +ASSERT duration2 IS NOT null + +# Verify two separate HEARTBEAT messages were sent +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 2 + +# The two HEARTBEATs should have different ids +ASSERT heartbeats_sent[0].message.id != heartbeats_sent[1].message.id +``` + +--- + +## RTN13c - Ping times out if no HEARTBEAT response + +| Spec | Requirement | +|------|-------------| +| RTN13c | Fails if HEARTBEAT not received within realtimeRequestTimeout | + +Tests that `ping()` fails with a timeout error if the server does not respond within `realtimeRequestTimeout`. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + # No onMessageFromClient handler — server never responds to HEARTBEAT +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ping_future = client.connection.ping() + +# Advance time past realtimeRequestTimeout +ADVANCE_TIME(2100) + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +# The error should indicate a timeout +ASSERT error.message CONTAINS "timeout" (case insensitive) +``` + +--- + +## RTN13b - Ping errors in INITIALIZED state + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error immediately when the connection is in INITIALIZED state. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +ASSERT client.connection.state == ConnectionState.initialized + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTN13b - Ping errors in SUSPENDED state + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error when the connection is in SUSPENDED state. + +### Setup +```pseudo +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [] +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Advance past connectionStateTtl to reach SUSPENDED +ADVANCE_TIME(121s) +AWAIT_STATE client.connection.state == ConnectionState.suspended + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTN13b - Ping errors in CLOSED state + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error when the connection is in CLOSED state. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTN13b - Ping errors in FAILED state + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if in INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED state | + +Tests that `ping()` returns an error when the connection is in FAILED state. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, statusCode: 400, message: "Fatal error") + ) + ) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +error = AWAIT_ERROR client.connection.ping() +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTN13d - Ping deferred from CONNECTING state until CONNECTED + +| Spec | Requirement | +|------|-------------| +| RTN13d | If CONNECTING or DISCONNECTED, execute ping once CONNECTED | + +Tests that calling `ping()` while CONNECTING defers the operation until the connection becomes CONNECTED, then sends the HEARTBEAT and resolves. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Delay the CONNECTED response so we can call ping() while CONNECTING + SCHEDULE_AFTER(100ms): + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + }, + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +ASSERT client.connection.state == ConnectionState.connecting + +# Call ping() while still CONNECTING +ping_future = client.connection.ping() + +# Advance time so the connection completes +ADVANCE_TIME(200ms) +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT ping_future +``` + +### Assertions +```pseudo +# Ping should resolve after connection was established +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# Verify HEARTBEAT was sent (only after CONNECTED) +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 1 +``` + +--- + +## RTN13d - Ping deferred from DISCONNECTED state until CONNECTED + +| Spec | Requirement | +|------|-------------| +| RTN13d | If CONNECTING or DISCONNECTED, execute ping once CONNECTED | + +Tests that calling `ping()` while DISCONNECTED defers the operation until the connection reconnects, then sends the HEARTBEAT and resolves. + +### Setup +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # First attempt: connect successfully + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + ELSE: + # Subsequent attempts: also connect successfully + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-2", connectionKey: "conn-key-2") + ) + }, + onMessageFromClient: (msg) => { + IF msg.action == HEARTBEAT: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: HEARTBEAT, + id: msg.id + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + disconnectedRetryTimeout: 500, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Force disconnect by closing the transport +mock_ws.active_connection.close_from_server() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Call ping() while DISCONNECTED +ping_future = client.connection.ping() + +# Advance time past disconnectedRetryTimeout so reconnection happens +ADVANCE_TIME(600ms) +AWAIT_STATE client.connection.state == ConnectionState.connected + +duration = AWAIT ping_future +``` + +### Assertions +```pseudo +# Ping should resolve after reconnection +ASSERT duration IS NOT null +ASSERT duration >= Duration.zero + +# Verify HEARTBEAT was sent +heartbeats_sent = mock_ws.events.filter( + e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT +) +ASSERT heartbeats_sent.length == 1 +``` + +--- + +## RTN13b - Deferred ping errors if connection transitions to FAILED + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if has transitioned to INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED | +| RTN13d | Deferred ping from CONNECTING state | + +Tests that a ping deferred from CONNECTING state fails with an error if the connection transitions to FAILED instead of CONNECTED. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Respond with fatal error instead of CONNECTED + SCHEDULE_AFTER(100ms): + conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, statusCode: 400, message: "Fatal error") + ) + ) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +ASSERT client.connection.state == ConnectionState.connecting + +# Call ping() while CONNECTING +ping_future = client.connection.ping() + +# Advance time so the error response arrives +ADVANCE_TIME(200ms) +AWAIT_STATE client.connection.state == ConnectionState.failed + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTN13b - Deferred ping errors if connection transitions to SUSPENDED + +| Spec | Requirement | +|------|-------------| +| RTN13b | Error if has transitioned to INITIALIZED, SUSPENDED, CLOSING, CLOSED, or FAILED | +| RTN13d | Deferred ping from CONNECTING/DISCONNECTED state | + +Tests that a ping deferred from DISCONNECTED state fails with an error if the connection transitions to SUSPENDED instead of reconnecting. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + suspendedRetryTimeout: 100, + fallbackHosts: [] +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Call ping() while DISCONNECTED +ping_future = client.connection.ping() + +# Advance past connectionStateTtl to reach SUSPENDED +ADVANCE_TIME(121s) +AWAIT_STATE client.connection.state == ConnectionState.suspended + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTN13c - Deferred ping times out after realtimeRequestTimeout from CONNECTED + +| Spec | Requirement | +|------|-------------| +| RTN13c | Fails if HEARTBEAT not received within realtimeRequestTimeout | +| RTN13d | Deferred ping from CONNECTING state | + +Tests that a ping deferred from CONNECTING state still times out based on `realtimeRequestTimeout` after the connection becomes CONNECTED (the timeout starts when the HEARTBEAT is actually sent, not when `ping()` is called). + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + SCHEDULE_AFTER(100ms): + conn.respond_with_success( + CONNECTED_MESSAGE(connectionId: "conn-id-1", connectionKey: "conn-key-1") + ) + } + # No onMessageFromClient — server never responds to HEARTBEAT +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + realtimeRequestTimeout: 2000, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +ASSERT client.connection.state == ConnectionState.connecting + +# Call ping() while CONNECTING +ping_future = client.connection.ping() + +# Advance time so connection completes +ADVANCE_TIME(200ms) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Advance time past realtimeRequestTimeout +ADVANCE_TIME(2100) + +error = AWAIT_ERROR ping_future +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.message CONTAINS "timeout" (case insensitive) +``` diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index 97a87eb84..f4f5d3a4e 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -145,7 +145,6 @@ ASSERT connection_attempt_count == 1 # Advance time past maxIdleInterval + realtimeRequestTimeout # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) -PUMP_EVENT_QUEUE() # Wait for the reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected @@ -223,26 +222,18 @@ ASSERT connection_attempt_count == 1 # Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) ADVANCE_TIME(2000) -PUMP_EVENT_QUEUE() - # Send HEARTBEAT from server - resets timer mock_ws.active_connection.send_to_client(ProtocolMessage( action: HEARTBEAT )) -PUMP_EVENT_QUEUE() - # Advance time again (2000ms since HEARTBEAT, still within threshold) ADVANCE_TIME(2000) -PUMP_EVENT_QUEUE() - # Connection should still be alive - no reconnection triggered ASSERT client.connection.state == ConnectionState.connected ASSERT connection_attempt_count == 1 # Advance time past the timeout window (4100ms since last HEARTBEAT) ADVANCE_TIME(2100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -312,19 +303,13 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time (timeout is 2000+1000=3000ms) ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Send ACK message from server - resets timer mock_ws.active_connection.send_to_client(ProtocolMessage( action: ACK, msgSerial: 0 )) -PUMP_EVENT_QUEUE() - # Advance time again (1500ms since ACK, still within threshold) ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Connection should still be alive (timer was reset) ASSERT client.connection.state == ConnectionState.connected @@ -336,19 +321,13 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( Message(name: "event", data: "data") ] )) -PUMP_EVENT_QUEUE() - # Advance time again (1500ms since MESSAGE) ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Still only one connection attempt - no timeout yet ASSERT connection_attempt_count == 1 # Advance time past timeout without any message (3100ms since last activity) ADVANCE_TIME(3100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -429,8 +408,6 @@ ASSERT connection_attempt_count == 1 # Advance time past maxIdleInterval + realtimeRequestTimeout # = 2000 + 1000 = 3000ms ADVANCE_TIME(3100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete (immediate per RTN15a) AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -515,8 +492,6 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time past timeout to trigger disconnection and reconnection ADVANCE_TIME(3100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -658,7 +633,6 @@ ASSERT connection_attempt_count == 1 # Advance time past maxIdleInterval + realtimeRequestTimeout # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) -PUMP_EVENT_QUEUE() # Wait for the reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected @@ -736,24 +710,16 @@ ASSERT connection_attempt_count == 1 # Advance time (not enough to trigger timeout: 3000 + 1000 = 4000ms) ADVANCE_TIME(2000) -PUMP_EVENT_QUEUE() - # Server sends ping frame - resets timer mock_ws.active_connection.send_ping_frame() -PUMP_EVENT_QUEUE() - # Advance time again (2000ms since ping, still within threshold) ADVANCE_TIME(2000) -PUMP_EVENT_QUEUE() - # Connection should still be alive - no reconnection triggered ASSERT client.connection.state == ConnectionState.connected ASSERT connection_attempt_count == 1 # Advance time past the timeout window (4100ms since last ping) ADVANCE_TIME(2100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -823,16 +789,10 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Send ping frame - resets timer mock_ws.active_connection.send_ping_frame() -PUMP_EVENT_QUEUE() - # Advance time ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Still connected ASSERT client.connection.state == ConnectionState.connected @@ -844,30 +804,20 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( Message(name: "event", data: "data") ] )) -PUMP_EVENT_QUEUE() - # Advance time ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Still connected ASSERT client.connection.state == ConnectionState.connected # Send another ping frame mock_ws.active_connection.send_ping_frame() -PUMP_EVENT_QUEUE() - # Advance time ADVANCE_TIME(1500) -PUMP_EVENT_QUEUE() - # Still only one connection attempt ASSERT connection_attempt_count == 1 # Advance time past timeout without any activity ADVANCE_TIME(1600) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -948,8 +898,6 @@ ASSERT connection_attempt_count == 1 # Advance time past maxIdleInterval + realtimeRequestTimeout # = 2000 + 1000 = 3000ms ADVANCE_TIME(3100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete (immediate per RTN15a) AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -1034,8 +982,6 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time past timeout to trigger disconnection and reconnection ADVANCE_TIME(3100) -PUMP_EVENT_QUEUE() - # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` diff --git a/uts/realtime/unit/helpers/mock_websocket.md b/uts/realtime/unit/helpers/mock_websocket.md index cb8a94b95..f6a76d8a1 100644 --- a/uts/realtime/unit/helpers/mock_websocket.md +++ b/uts/realtime/unit/helpers/mock_websocket.md @@ -348,32 +348,6 @@ respond_with_success(connected_message): }) ``` -### Pumping the Event Queue - -After advancing fake timers or triggering async operations, tests may need to "pump" the event queue to allow scheduled callbacks to execute: - -```pseudo -# Pump the event queue to process pending microtasks and timer events -PUMP_EVENT_QUEUE() -``` - -**Implementation notes:** - -- **Microtasks** (e.g., `scheduleMicrotask`, `Future.value().then()`) run before timer events -- **Timer events** (e.g., `Timer.run`, `Future.delayed(Duration.zero)`) run after all microtasks -- Multiple chained async operations may require multiple pumps - -In Dart, `await Future.delayed(Duration.zero)` yields to the event loop and allows pending timer events to fire. For nested async chains, multiple pumps may be needed: - -```dart -// Pump the event queue multiple times for nested async operations -Future pumpEventQueue([int times = 5]) async { - for (var i = 0; i < times; i++) { - await Future.delayed(Duration.zero); - } -} -``` - ### Avoiding Arbitrary Real-Time Delays Tests should **never** use fixed real-time delays like `await Future.delayed(Duration(milliseconds: 100))`. These cause: @@ -383,7 +357,6 @@ Tests should **never** use fixed real-time delays like `await Future.delayed(Dur Instead: - Use fake timers with `ADVANCE_TIME()` -- Pump the event queue with `PUMP_EVENT_QUEUE()` or `await Future.delayed(Duration.zero)` - Wait for specific state changes with `AWAIT_STATE` ```pseudo @@ -392,9 +365,8 @@ ADVANCE_TIME(3000) WAIT 100ms # Real-time delay - flaky! ASSERT state == disconnected -# GOOD - pump event queue and wait for state +# GOOD - advance time and wait for state ADVANCE_TIME(3000) -PUMP_EVENT_QUEUE() AWAIT_STATE state == disconnected ``` @@ -414,7 +386,6 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Trigger disconnect and reconnect mock_ws.active_connection.simulate_disconnect() -PUMP_EVENT_QUEUE() AWAIT_STATE client.connection.state == ConnectionState.connected # Verify the sequence included the expected states From 7d8cd91ea01c1332bddab5b949d178816045e4e1 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 10/32] Add test specs for realtime channel subscribe (RTL7/RTL8) Add comprehensive test specs covering channel message subscription, filtering, listener management, and unsubscribe behaviour. --- uts/completion-status.md | 8 +- .../unit/channels/channel_subscribe.md | 1026 +++++++++++++++++ 2 files changed, 1030 insertions(+), 4 deletions(-) create mode 100644 uts/realtime/unit/channels/channel_subscribe.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 1bfb15c03..b5f03611d 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -213,8 +213,8 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md` | | RTL5 | Detach function (RTL5a–RTL5l) | Yes — `realtime/unit/channels/channel_detach.md` | | RTL6 | Publish function (RTL6a–RTL6k) | | -| RTL7 | Subscribe function (RTL7a–RTL7h) | | -| RTL8 | Unsubscribe function (RTL8a–RTL8c) | | +| RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md` | +| RTL8 | Unsubscribe function (RTL8a–RTL8c) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL9 | Presence attribute (RTL9a) | | | RTL10 | History function (RTL10a–RTL10d) | | | RTL11 | Channel state effect on presence (RTL11a) | | @@ -223,7 +223,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md` | | RTL15 | Channel#properties attribute (RTL15a–RTL15b1) | Yes — `realtime/unit/channels/channel_properties.md` | | RTL16 | SetOptions function (RTL16a) | Yes — `realtime/unit/channels/channel_options.md` | -| RTL17 | No messages outside ATTACHED state | | +| RTL17 | No messages outside ATTACHED state | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL18 | Vcdiff decoding failure recovery (RTL18a–RTL18c) | | | RTL19 | Base payload storage for vcdiff (RTL19a–RTL19c) | | | RTL20 | Last message ID storage | | @@ -401,7 +401,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **Realtime client** (RTC) | 14 | 8 | Partial | | **Connection** (RTN) | 23 | 16 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | -| **Realtime channel** (RTL) | 24 | 10 | Partial | +| **Realtime channel** (RTL) | 24 | 13 | Partial | | **Realtime presence** (RTP) | 15 | 0 | None | | **Realtime annotations** (RTAN) | 5 | 0 | None | | **EventEmitter** (RTE) | 6 | 0 | None | diff --git a/uts/realtime/unit/channels/channel_subscribe.md b/uts/realtime/unit/channels/channel_subscribe.md new file mode 100644 index 000000000..cbae8f600 --- /dev/null +++ b/uts/realtime/unit/channels/channel_subscribe.md @@ -0,0 +1,1026 @@ +# RealtimeChannel Subscribe and Unsubscribe Tests + +Spec points: `RTL7`, `RTL7a`, `RTL7b`, `RTL7g`, `RTL7h`, `RTL7f`, `RTL8`, `RTL8a`, `RTL8b`, `RTL8c`, `RTL17` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL7a - Subscribe with no name receives all messages + +**Spec requirement:** Subscribe with a single listener argument subscribes a listener to all messages. + +Tests that subscribing without a name filter delivers all incoming messages regardless of name. + +### Setup +```pseudo +channel_name = "test-RTL7a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Server sends messages with different names +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event1", data: "data1") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "event2", data: "data2") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: null, data: "data3") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 3 +ASSERT received_messages[0].name == "event1" +ASSERT received_messages[0].data == "data1" +ASSERT received_messages[1].name == "event2" +ASSERT received_messages[1].data == "data2" +ASSERT received_messages[2].name IS null +ASSERT received_messages[2].data == "data3" +``` + +--- + +## RTL7a - Subscribe receives multiple messages from a single ProtocolMessage + +**Spec requirement:** Subscribe with a single listener argument subscribes a listener to all messages. + +Tests that when a ProtocolMessage contains multiple messages in its `messages` array, each is delivered individually to the subscriber. + +### Setup +```pseudo +channel_name = "test-RTL7a-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Server sends a single ProtocolMessage with multiple messages +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "batch1", data: "first"), + Message(name: "batch2", data: "second"), + Message(name: "batch3", data: "third") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 3 +ASSERT received_messages[0].name == "batch1" +ASSERT received_messages[1].name == "batch2" +ASSERT received_messages[2].name == "batch3" +``` + +--- + +## RTL7b - Subscribe with name only receives matching messages + +**Spec requirement:** Subscribe with a name argument and a listener argument subscribes a listener to only messages whose `name` member matches the string name. + +Tests that subscribing with a name filter delivers only messages with the matching name. + +### Setup +```pseudo +channel_name = "test-RTL7b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe("target", (message) => { + received_messages.append(message) +}) + +# Server sends messages with different names +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "other", data: "should-not-receive") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "target", data: "should-receive") + ] +)) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: null, data: "no-name-should-not-receive") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].name == "target" +ASSERT received_messages[0].data == "should-receive" +``` + +--- + +## RTL7b - Multiple name-specific subscriptions are independent + +**Spec requirement:** Subscribe with a name argument and a listener argument subscribes a listener to only messages whose `name` member matches the string name. + +Tests that multiple name-specific subscriptions each receive only their matching messages. + +### Setup +```pseudo +channel_name = "test-RTL7b-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +alpha_messages = [] +beta_messages = [] + +channel.subscribe("alpha", (message) => { + alpha_messages.append(message) +}) + +channel.subscribe("beta", (message) => { + beta_messages.append(message) +}) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "alpha", data: "a1"), + Message(name: "beta", data: "b1"), + Message(name: "alpha", data: "a2"), + Message(name: "gamma", data: "g1") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(alpha_messages) == 2 +ASSERT alpha_messages[0].data == "a1" +ASSERT alpha_messages[1].data == "a2" + +ASSERT length(beta_messages) == 1 +ASSERT beta_messages[0].data == "b1" +``` + +--- + +## RTL7g - Subscribe triggers implicit attach when attachOnSubscribe is true + +**Spec requirement:** If the `attachOnSubscribe` channel option is `true`, implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. The listener will always be registered regardless of the implicit attach result. + +Tests that subscribing on a channel with `attachOnSubscribe: true` (the default) triggers an implicit attach from INITIALIZED state. + +### Setup +```pseudo +channel_name = "test-RTL7g-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +# Default attachOnSubscribe is true +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Wait for implicit attach to complete +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 + +# Verify the listener was registered by sending a message +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "test", data: "hello") + ] +)) +ASSERT length(received_messages) == 1 +``` + +--- + +## RTL7g - Subscribe triggers implicit attach from DETACHED state + +**Spec requirement:** If the `attachOnSubscribe` channel option is `true`, implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. + +Tests that subscribing on a DETACHED channel triggers an implicit attach. + +### Setup +```pseudo +channel_name = "test-RTL7g-detached-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +ASSERT attach_message_count == 1 + +# Subscribe should trigger implicit attach from DETACHED +channel.subscribe((message) => {}) + +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 2 +``` + +--- + +## RTL7g - Listener registered even if implicit attach fails + +**Spec requirement:** The listener will always be registered regardless of the implicit attach result. + +Tests that the subscription listener is registered even when the implicit attach fails. + +### Setup +```pseudo +channel_name = "test-RTL7g-fail-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Fail the attach with a channel error + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Wait for the channel to enter FAILED from the rejected attach +AWAIT_STATE channel.state == ChannelState.failed + +# Verify the listener was registered despite the failed attach. +# Re-attach the channel so messages can flow. +# First, reset mock to succeed on attach: +mock_ws.onMessageFromClient = (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) +} +AWAIT channel.attach() + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "test", data: "after-reattach") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].data == "after-reattach" +``` + +--- + +## RTL7h - Subscribe does not attach when attachOnSubscribe is false + +**Spec requirement:** If the `attachOnSubscribe` channel option is `false`, then subscribe should not trigger an implicit attach. + +Tests that subscribing with `attachOnSubscribe: false` does not trigger an attach. + +### Setup +```pseudo +channel_name = "test-RTL7h-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.subscribe((message) => {}) +``` + +### Assertions +```pseudo +# Channel should remain INITIALIZED — no attach triggered +ASSERT channel.state == ChannelState.initialized +ASSERT attach_message_count == 0 +``` + +--- + +## RTL7g - Subscribe does not attach when already attached + +**Spec requirement:** Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. + +Tests that subscribing on an already-attached channel does not trigger another attach. + +### Setup +```pseudo +channel_name = "test-RTL7g-already-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT attach_message_count == 1 + +# Subscribe on already-attached channel — no additional attach +channel.subscribe((message) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT attach_message_count == 1 +``` + +--- + +## RTL7g - Subscribe does not attach when already attaching + +**Spec requirement:** Implicitly attaches the `RealtimeChannel` if the channel is in the `INITIALIZED`, `DETACHING`, or `DETACHED` states. + +Tests that subscribing on a channel that is already ATTACHING does not trigger a second attach. + +### Setup +```pseudo +channel_name = "test-RTL7g-attaching-${random_id()}" +attach_message_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + # Don't respond yet — leave channel in ATTACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't complete it +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching +ASSERT attach_message_count == 1 + +# Subscribe while attaching — should not trigger another attach +channel.subscribe((message) => {}) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attaching +ASSERT attach_message_count == 1 # No additional ATTACH message sent +``` + +--- + +## RTL17 - Messages not delivered when channel is not ATTACHED + +**Spec requirement:** No messages should be passed to subscribers if the channel is in any state other than `ATTACHED`. + +Tests that incoming MESSAGE protocol messages are not delivered to subscribers when the channel is not in the ATTACHED state (e.g. ATTACHING, SUSPENDED). + +### Setup +```pseudo +channel_name = "test-RTL17-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond — leave channel in ATTACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Start attach but don't complete it — channel stays ATTACHING +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Server sends a message while channel is still ATTACHING +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "premature", data: "should-not-deliver") + ] +)) +``` + +### Assertions +```pseudo +# Message should not have been delivered +ASSERT length(received_messages) == 0 +``` + +--- + +## RTL7f - Messages not echoed when echoMessages is false + +**Spec requirement:** A test should exist ensuring published messages are not echoed back to the subscriber when `echoMessages` is set to false in the `RealtimeClient` library constructor. + +Tests that when `echoMessages` is false, messages originating from this connection (identified by matching `connectionId`) are not delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-RTL7f-${random_id()}" +connection_id = "conn-self-123" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: connection_id, + connectionKey: "key-456" + )), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + echoMessages: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((message) => { + received_messages.append(message) +}) + +# Server echoes back a message with this connection's connectionId +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + connectionId: connection_id, + messages: [ + Message(name: "echo", data: "from-self") + ] +)) + +# Server sends a message from a different connection +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + connectionId: "conn-other-789", + messages: [ + Message(name: "remote", data: "from-other") + ] +)) +``` + +### Assertions +```pseudo +# Only the message from the other connection should be delivered +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].name == "remote" +ASSERT received_messages[0].data == "from-other" +``` + +--- + +## RTL8a - Unsubscribe specific listener from all messages + +**Spec requirement:** Unsubscribe with a single listener argument unsubscribes the provided listener to all messages if subscribed. + +Tests that unsubscribing a specific listener stops it from receiving messages, while other listeners continue. + +### Setup +```pseudo +channel_name = "test-RTL8a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +messages_a = [] +messages_b = [] + +listener_a = (message) => { messages_a.append(message) } +listener_b = (message) => { messages_b.append(message) } + +channel.subscribe(listener_a) +channel.subscribe(listener_b) + +# Both listeners receive first message +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "msg1", data: "first") + ] +)) + +ASSERT length(messages_a) == 1 +ASSERT length(messages_b) == 1 + +# Unsubscribe listener_a +channel.unsubscribe(listener_a) + +# Only listener_b should receive second message +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "msg2", data: "second") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(messages_a) == 1 # Did not receive second message +ASSERT length(messages_b) == 2 # Received both messages +ASSERT messages_b[1].name == "msg2" +``` + +--- + +## RTL8b - Unsubscribe listener from specific name + +**Spec requirement:** Unsubscribe with a name argument and a listener argument unsubscribes the provided listener if previously subscribed with a name-specific subscription. + +Tests that unsubscribing with a name removes only that name-specific subscription for the listener. + +### Setup +```pseudo +channel_name = "test-RTL8b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +listener = (message) => { received_messages.append(message) } + +# Subscribe to two different names with the same listener +channel.subscribe("alpha", listener) +channel.subscribe("beta", listener) + +# Both subscriptions active +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "alpha", data: "a1"), + Message(name: "beta", data: "b1") + ] +)) +ASSERT length(received_messages) == 2 + +# Unsubscribe only from "alpha" +channel.unsubscribe("alpha", listener) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "alpha", data: "a2"), + Message(name: "beta", data: "b2") + ] +)) +``` + +### Assertions +```pseudo +# "alpha" unsubscribed but "beta" still active +ASSERT length(received_messages) == 3 +ASSERT received_messages[2].name == "beta" +ASSERT received_messages[2].data == "b2" +``` + +--- + +## RTL8c - Unsubscribe with no arguments removes all listeners + +**Spec requirement:** Unsubscribe with no arguments unsubscribes all listeners. + +Tests that calling unsubscribe with no arguments removes all subscriptions from the channel. + +### Setup +```pseudo +channel_name = "test-RTL8c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +messages_all = [] +messages_named = [] + +channel.subscribe((message) => { messages_all.append(message) }) +channel.subscribe("specific", (message) => { messages_named.append(message) }) + +# Both listeners receive +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "specific", data: "first") + ] +)) +ASSERT length(messages_all) == 1 +ASSERT length(messages_named) == 1 + +# Unsubscribe all +channel.unsubscribe() + +# No listeners should receive +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "specific", data: "second"), + Message(name: "other", data: "third") + ] +)) +``` + +### Assertions +```pseudo +ASSERT length(messages_all) == 1 # No new messages +ASSERT length(messages_named) == 1 # No new messages +``` + +--- + +## RTL8a - Unsubscribe listener not currently subscribed is no-op + +**Spec requirement:** Unsubscribe with a single listener argument unsubscribes the provided listener to all messages if subscribed. + +Tests that unsubscribing a listener that was never subscribed does not cause an error or affect existing subscriptions. + +### Setup +```pseudo +channel_name = "test-RTL8a-noop-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received_messages = [] +active_listener = (message) => { received_messages.append(message) } +unused_listener = (message) => {} + +channel.subscribe(active_listener) + +# Unsubscribe a listener that was never subscribed — should be no-op +channel.unsubscribe(unused_listener) + +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + Message(name: "test", data: "still-works") + ] +)) +``` + +### Assertions +```pseudo +# Existing subscription should be unaffected +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].data == "still-works" +``` From 81fec1646cf53feab2b7691a74946414ea0d0429 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 11/32] Add test specs for realtime channel publish (RTL6) Add test specs covering channel message publishing, including message encoding, connection state requirements, and error handling. --- uts/completion-status.md | 8 +- uts/realtime/unit/channels/channel_publish.md | 1255 +++++++++++++++++ uts/realtime/unit/helpers/mock_websocket.md | 17 +- 3 files changed, 1275 insertions(+), 5 deletions(-) create mode 100644 uts/realtime/unit/channels/channel_publish.md diff --git a/uts/completion-status.md b/uts/completion-status.md index b5f03611d..d20e63571 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -173,7 +173,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTN4 | Connection event emission (RTN4a–RTN4i) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN4b, RTN4c; `realtime/unit/connection/update_events_test.md` covers RTN4h | | RTN5 | Concurrency test (50+ clients) | | | RTN6 | Successful connection definition | | -| RTN7 | ACK and NACK handling (RTN7a–RTN7e) | | +| RTN7 | ACK and NACK handling (RTN7a–RTN7e) | Partial — `realtime/unit/channels/channel_publish.md` covers RTN7a, RTN7b (via RTL6j tests) | | RTN8 | Connection#id attribute (RTN8a–RTN8c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | | RTN9 | Connection#key attribute (RTN9a–RTN9c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | | RTN11 | Connect function (RTN11a–RTN11f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN11; `realtime/unit/connection/error_reason_test.md` covers RTN11d | @@ -212,7 +212,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL3 | Connection state side effects (RTL3a–RTL3e) | Yes — `realtime/unit/channels/channel_connection_state.md` | | RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md` | | RTL5 | Detach function (RTL5a–RTL5l) | Yes — `realtime/unit/channels/channel_detach.md` | -| RTL6 | Publish function (RTL6a–RTL6k) | | +| RTL6 | Publish function (RTL6a–RTL6k) | Yes — `realtime/unit/channels/channel_publish.md` | | RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL8 | Unsubscribe function (RTL8a–RTL8c) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL9 | Presence attribute (RTL9a) | | @@ -345,7 +345,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | BSP1–BSP2 | BatchPublishSpec | | | BPR1–BPR2, BPF1–BPF2 | BatchPublish result types | | | BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | | -| PBR1–PBR2 | PublishResult | | +| PBR1–PBR2 | PublishResult | Yes — `realtime/unit/channels/channel_publish.md` | | UDR1–UDR2 | UpdateDeleteResult | | | TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | | | MFI1–MFI2 | MessageFilter | | @@ -401,7 +401,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **Realtime client** (RTC) | 14 | 8 | Partial | | **Connection** (RTN) | 23 | 16 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | -| **Realtime channel** (RTL) | 24 | 13 | Partial | +| **Realtime channel** (RTL) | 24 | 14 | Partial | | **Realtime presence** (RTP) | 15 | 0 | None | | **Realtime annotations** (RTAN) | 5 | 0 | None | | **EventEmitter** (RTE) | 6 | 0 | None | diff --git a/uts/realtime/unit/channels/channel_publish.md b/uts/realtime/unit/channels/channel_publish.md new file mode 100644 index 000000000..8f4b6ffa5 --- /dev/null +++ b/uts/realtime/unit/channels/channel_publish.md @@ -0,0 +1,1255 @@ +# RealtimeChannel Publish Tests + +Spec points: `RTL6`, `RTL6a`, `RTL6c`, `RTL6c1`, `RTL6c2`, `RTL6c4`, `RTL6c5`, `RTL6i`, `RTL6i1`, `RTL6i2`, `RTL6i3`, `RTL6j` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL6i1 - Publish single message by name and data + +**Spec requirement:** When `name` and `data` (or a `Message`) is provided, a single `ProtocolMessage` containing one `Message` is published to Ably. + +Tests that publishing with name and data sends a single MESSAGE ProtocolMessage with one message entry. + +### Setup +```pseudo +channel_name = "test-RTL6i1-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.publish(name: "greeting", data: "hello") +``` + +### Assertions +```pseudo +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].action == MESSAGE +ASSERT captured_messages[0].channel == channel_name +ASSERT length(captured_messages[0].messages) == 1 +ASSERT captured_messages[0].messages[0].name == "greeting" +ASSERT captured_messages[0].messages[0].data == "hello" +``` + +--- + +## RTL6i2 - Publish array of Message objects + +**Spec requirement:** When an array of `Message` objects is provided, a single `ProtocolMessage` is used to publish all `Message` objects in the array. + +Tests that publishing an array of messages sends them in a single ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTL6i2-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.publish(messages: [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +]) +``` + +### Assertions +```pseudo +ASSERT length(captured_messages) == 1 # Single ProtocolMessage +ASSERT length(captured_messages[0].messages) == 3 +ASSERT captured_messages[0].messages[0].name == "event1" +ASSERT captured_messages[0].messages[1].name == "event2" +ASSERT captured_messages[0].messages[2].name == "event3" +``` + +--- + +## RTL6i3 - Null fields omitted from JSON wire encoding + +**Spec requirement:** Allows `name` and or `data` to be `null`. If any of the values are `null`, then key is not sent to Ably i.e. a payload with a `null` value for `data` would be sent as follows `{ "name": "click" }`. + +Tests that when using the JSON protocol, null `name` or `data` fields are omitted from the encoded JSON representation on the wire (not sent as `"name": null`). + +### Setup +```pseudo +channel_name = "test-RTL6i3-json-${random_id()}" +captured_frames = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + }, + onTextDataFrame: (text) => { + decoded = JSON_DECODE(text) + IF decoded["action"] == MESSAGE_ACTION_INT: + captured_frames.append(decoded) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish with name only (null data) +channel.publish(name: "click", data: null) + +# Publish with data only (null name) +channel.publish(name: null, data: "payload") + +# Publish with both null +channel.publish(name: null, data: null) +``` + +### Assertions +```pseudo +ASSERT length(captured_frames) == 3 + +# First message: name present, data key absent +msg0 = captured_frames[0]["messages"][0] +ASSERT msg0["name"] == "click" +ASSERT "data" NOT IN msg0 + +# Second message: data present, name key absent +msg1 = captured_frames[1]["messages"][0] +ASSERT "name" NOT IN msg1 +ASSERT msg1["data"] == "payload" + +# Third message: both keys absent +msg2 = captured_frames[2]["messages"][0] +ASSERT "name" NOT IN msg2 +ASSERT "data" NOT IN msg2 +``` + +--- + +## RTL6i3 - Null fields omitted from msgpack wire encoding + +**Spec requirement:** Allows `name` and or `data` to be `null`. If any of the values are `null`, then key is not sent to Ably. + +Tests that when using the msgpack protocol, null `name` or `data` fields are omitted from the encoded msgpack representation on the wire. + +### Setup +```pseudo +channel_name = "test-RTL6i3-msgpack-${random_id()}" +captured_frames = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + }, + onBinaryDataFrame: (bytes) => { + decoded = MSGPACK_DECODE(bytes) + IF decoded["action"] == MESSAGE_ACTION_INT: + captured_frames.append(decoded) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + useBinaryProtocol: true +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish with name only (null data) +channel.publish(name: "click", data: null) + +# Publish with data only (null name) +channel.publish(name: null, data: "payload") + +# Publish with both null +channel.publish(name: null, data: null) +``` + +### Assertions +```pseudo +ASSERT length(captured_frames) == 3 + +# First message: name present, data key absent +msg0 = captured_frames[0]["messages"][0] +ASSERT msg0["name"] == "click" +ASSERT "data" NOT IN msg0 + +# Second message: data present, name key absent +msg1 = captured_frames[1]["messages"][0] +ASSERT "name" NOT IN msg1 +ASSERT msg1["data"] == "payload" + +# Third message: both keys absent +msg2 = captured_frames[2]["messages"][0] +ASSERT "name" NOT IN msg2 +ASSERT "data" NOT IN msg2 +``` + +--- + +## RTL6c1 - Publish immediately when CONNECTED and channel ATTACHED + +| Spec | Requirement | +|------|-------------| +| RTL6c1 | If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately | + +Tests that messages are sent immediately to the server when the connection is CONNECTED and the channel is ATTACHED. + +### Setup +```pseudo +channel_name = "test-RTL6c1-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached + +channel.publish(name: "test", data: "immediate") +``` + +### Assertions +```pseudo +# Message should have been sent immediately (synchronously captured by mock) +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "test" +ASSERT captured_messages[0].messages[0].data == "immediate" +``` + +--- + +## RTL6c1 - Publish immediately when CONNECTED and channel ATTACHING + +**Spec requirement:** If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately. + +Tests that messages are sent immediately even when the channel is in the ATTACHING state (which is neither SUSPENDED nor FAILED). + +### Setup +```pseudo +channel_name = "test-RTL6c1-attaching-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond — leave channel in ATTACHING + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't complete it — channel stays ATTACHING +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +channel.publish(name: "while-attaching", data: "data") +``` + +### Assertions +```pseudo +# Message should have been sent immediately (ATTACHING is neither SUSPENDED nor FAILED) +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "while-attaching" +``` + +--- + +## RTL6c1 - Publish immediately when CONNECTED and channel INITIALIZED + +**Spec requirement:** If the connection is `CONNECTED` and the channel is neither `SUSPENDED` nor `FAILED` then the messages are published immediately. + +Tests that messages are sent immediately when the channel is in the INITIALIZED state (which is neither SUSPENDED nor FAILED). + +### Setup +```pseudo +channel_name = "test-RTL6c1-init-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.publish(name: "before-attach", data: "data") +``` + +### Assertions +```pseudo +# Message should have been sent immediately +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "before-attach" +``` + +--- + +## RTL6c2 - Publish queued when connection is CONNECTING + +| Spec | Requirement | +|------|-------------| +| RTL6c2 | If the connection is `INITIALIZED`, `CONNECTING` or `DISCONNECTED`; and the channel is neither `SUSPENDED` nor `FAILED`; and `ClientOptions#queueMessages` is `true`; then the message will be placed in a connection-wide message queue | + +Tests that messages published while the connection is CONNECTING are queued and sent once the connection becomes CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c2-connecting-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Don't respond yet — leave connection in CONNECTING + }, + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Publish while CONNECTING — should be queued +channel.publish(name: "queued", data: "waiting") + +# Message should NOT have been sent yet +ASSERT length(captured_messages) == 0 + +# Complete the connection +pending_conn = AWAIT mock_ws.await_connection_attempt() +pending_conn.respond_with_success(CONNECTED_MESSAGE) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# Queued message should now have been sent +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "queued" +ASSERT captured_messages[0].messages[0].data == "waiting" +``` + +--- + +## RTL6c2 - Publish queued when connection is DISCONNECTED + +**Spec requirement:** Messages are queued when connection is `DISCONNECTED` and `queueMessages` is true. + +Tests that messages published while the connection is DISCONNECTED are queued and sent once the connection reconnects. + +### Setup +```pseudo +channel_name = "test-RTL6c2-disconnected-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Simulate disconnect +mock_ws.active_connection.simulate_disconnect() + +# Record state changes to verify DISCONNECTED was reached +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Publish while DISCONNECTED — should be queued +channel.publish(name: "during-disconnect", data: "queued") + +# Message should NOT have been sent yet (no active connection) +message_count_before = length(captured_messages) +``` + +### Assertions +```pseudo +# After reconnection, the queued message should be sent +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT length(captured_messages) > message_count_before +# Find the queued message in captured messages +queued = filter(captured_messages, (m) => m.messages[0].name == "during-disconnect") +ASSERT length(queued) == 1 +``` + +--- + +## RTL6c2 - Publish queued when connection is INITIALIZED + +**Spec requirement:** Messages are queued when connection is `INITIALIZED` and `queueMessages` is true. + +Tests that messages published before `connect()` is called are queued and sent once the connection becomes CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c2-init-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +ASSERT client.connection.state == ConnectionState.initialized + +# Publish before connecting — should be queued +channel.publish(name: "pre-connect", data: "early") + +# Message should NOT have been sent +ASSERT length(captured_messages) == 0 + +# Now connect +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# Queued message should now have been sent +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].messages[0].name == "pre-connect" +``` + +--- + +## RTL6c4 - Publish fails when connection is SUSPENDED + +**Spec requirement:** In any other case the operation should result in an error. + +Tests that publishing fails immediately when the connection is SUSPENDED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-suspended-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000 +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() + +# Advance time until connection enters SUSPENDED +LOOP up to 15 times: + ADVANCE_TIME(2000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended + +# Publish should fail +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTL6c4 - Publish fails when connection is CLOSED + +**Spec requirement:** In any other case the operation should result in an error. + +Tests that publishing fails when the connection is CLOSED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-closed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE) +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT client.close() +ASSERT client.connection.state == ConnectionState.closed + +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTL6c4 - Publish fails when connection is FAILED + +**Spec requirement:** In any other case the operation should result in an error. + +Tests that publishing fails when the connection is FAILED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_error( + ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, message: "Fatal error") + ), + thenClose: true + ) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTL6c4 - Publish fails when channel is SUSPENDED + +**Spec requirement:** If the channel is SUSPENDED, publish results in an error regardless of connection state. + +Tests that publishing fails when the channel is in SUSPENDED state even though the connection is CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c4-ch-suspended-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Don't respond on second attach — will timeout to SUSPENDED + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 100 +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — will timeout and channel enters SUSPENDED +attach_future = channel.attach() +ADVANCE_TIME(150) +AWAIT attach_future FAILS WITH attach_error + +AWAIT_STATE channel.state == ChannelState.suspended + +# Publish should fail because channel is SUSPENDED +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT length(captured_messages) == 0 # No MESSAGE sent to server +``` + +--- + +## RTL6c4 - Publish fails when channel is FAILED + +**Spec requirement:** Publishing to a FAILED channel results in an error (RTL6c3/RTL6c4). + +Tests that publishing fails when the channel is in FAILED state. + +### Setup +```pseudo +channel_name = "test-RTL6c4-ch-failed-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Not permitted") + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach fails → channel enters FAILED +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.failed + +# Publish should fail because channel is FAILED +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT length(captured_messages) == 0 # No MESSAGE sent to server +``` + +--- + +## RTL6c2 - Publish fails when queueMessages is false and connection not CONNECTED + +**Spec requirement:** Messages are queued only when `queueMessages` is true. When false and connection is not CONNECTED, publish should fail. + +Tests that publishing fails immediately when queueMessages is false and the connection is not CONNECTED. + +### Setup +```pseudo +channel_name = "test-RTL6c2-noqueue-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Don't respond — leave connection in CONNECTING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + queueMessages: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +channel.publish(name: "fail", data: "should-error") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTL6c5 - Publish does not trigger implicit attach + +**Spec requirement:** A publish should not trigger an implicit attach (in contrast to earlier version of this spec). + +Tests that publishing on an INITIALIZED channel does not cause the channel to begin attaching. + +### Setup +```pseudo +channel_name = "test-RTL6c5-${random_id()}" +attach_message_count = 0 +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_message_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.publish(name: "no-attach", data: "test") +``` + +### Assertions +```pseudo +# Publish should have been sent (RTL6c1 — CONNECTED, channel not SUSPENDED/FAILED) +ASSERT length(captured_messages) == 1 + +# Channel should remain INITIALIZED — no implicit attach +ASSERT channel.state == ChannelState.initialized +ASSERT attach_message_count == 0 +``` + +--- + +## RTL6c2 - Multiple queued messages sent in order after connection + +**Spec requirement:** Messages queued while not connected are delivered once the connection becomes CONNECTED. + +Tests that multiple messages queued before connection are all sent in the correct order once connected. + +### Setup +```pseudo +channel_name = "test-RTL6c2-order-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + # Don't respond yet — leave in CONNECTING + }, + onMessageFromClient: (msg) => { + IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Queue multiple messages +channel.publish(name: "first", data: "1") +channel.publish(name: "second", data: "2") +channel.publish(name: "third", data: "3") + +ASSERT length(captured_messages) == 0 + +# Complete the connection +pending_conn = AWAIT mock_ws.await_connection_attempt() +pending_conn.respond_with_success(CONNECTED_MESSAGE) +AWAIT_STATE client.connection.state == ConnectionState.connected +``` + +### Assertions +```pseudo +# All messages should have been sent in order +ASSERT length(captured_messages) == 3 +ASSERT captured_messages[0].messages[0].name == "first" +ASSERT captured_messages[1].messages[0].name == "second" +ASSERT captured_messages[2].messages[0].name == "third" +``` + +--- + +## RTL6i1 - Publish Message object + +**Spec requirement:** When a `Message` is provided, a single `ProtocolMessage` containing one `Message` is published to Ably. + +Tests that publishing a Message object directly sends it correctly. + +### Setup +```pseudo +channel_name = "test-RTL6i1-obj-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.publish(message: Message(name: "custom", data: {"key": "value"})) +``` + +### Assertions +```pseudo +ASSERT length(captured_messages) == 1 +ASSERT length(captured_messages[0].messages) == 1 +ASSERT captured_messages[0].messages[0].name == "custom" +ASSERT captured_messages[0].messages[0].data == {"key": "value"} +``` + +--- + +## RTL6j - Publish returns PublishResult with serials from ACK + +| Spec | Requirement | +|------|-------------| +| RTL6j | On success, returns a `PublishResult` object containing the serials of the published messages. The serials are obtained from the `ACK` `ProtocolMessage` response (see TR4s). | +| PBR1 | Contains the result of a publish operation | +| PBR2a | `serials` array of `String?` — an array of message serials corresponding 1:1 to the messages that were published | +| TR4s | `res` Array of `PublishResult` objects — present in `ACK` `ProtocolMessages`, contains one `PublishResult` per acknowledged `ProtocolMessage` in order | +| TR4g | `count` integer — number of `ProtocolMessages` being acknowledged | +| RTN7b | Every `ProtocolMessage` that expects an ACK must contain a unique serially incrementing `msgSerial` integer value starting at zero | + +Tests that `publish()` returns a `PublishResult` whose `serials` array contains the message serials from the ACK response. + +### Setup +```pseudo +channel_name = "test-RTL6j-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + # Respond with ACK containing PublishResult with serials + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["abc123"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.publish(name: "greeting", data: "hello") +``` + +### Assertions +```pseudo +# Publish should have been sent with msgSerial +ASSERT length(captured_messages) == 1 +ASSERT captured_messages[0].msgSerial == 0 + +# Result should be a PublishResult with serials from the ACK +ASSERT result IS PublishResult +ASSERT length(result.serials) == 1 +ASSERT result.serials[0] == "abc123" +``` + +--- + +## RTL6j - Publish returns PublishResult with multiple serials for batch publish + +**Spec requirement:** When an array of messages is published, the `PublishResult` `serials` array contains one serial per message, corresponding 1:1 to the published messages (PBR2a). A serial may be null if the message was discarded due to a configured conflation rule. + +Tests that a batch publish of multiple messages returns a `PublishResult` with a serial for each message. + +### Setup +```pseudo +channel_name = "test-RTL6j-batch-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + # Respond with ACK containing serials for each message + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-1", null, "serial-3"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.publish(messages: [ + Message(name: "event1", data: "data1"), + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3") +]) +``` + +### Assertions +```pseudo +# Single ProtocolMessage with 3 messages +ASSERT length(captured_messages) == 1 +ASSERT length(captured_messages[0].messages) == 3 + +# Result should contain serials 1:1 with published messages +ASSERT result IS PublishResult +ASSERT length(result.serials) == 3 +ASSERT result.serials[0] == "serial-1" +ASSERT result.serials[1] == null # Conflated message +ASSERT result.serials[2] == "serial-3" +``` + +--- + +## RTL6j - Sequential publishes get incrementing msgSerial + +**Spec requirement:** Every ProtocolMessage that expects an ACK must contain a unique serially incrementing `msgSerial` integer value starting at zero (RTN7b). + +Tests that successive publish calls assign incrementing `msgSerial` values to the outgoing ProtocolMessages, and that each publish resolves with the correct `PublishResult` from its corresponding ACK. + +### Setup +```pseudo +channel_name = "test-RTL6j-serial-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + # Respond with ACK, using msgSerial to generate distinct serials + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-${msg.msgSerial}"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result1 = AWAIT channel.publish(name: "first", data: "1") +result2 = AWAIT channel.publish(name: "second", data: "2") +result3 = AWAIT channel.publish(name: "third", data: "3") +``` + +### Assertions +```pseudo +# Each outgoing MESSAGE should have incrementing msgSerial +ASSERT length(captured_messages) == 3 +ASSERT captured_messages[0].msgSerial == 0 +ASSERT captured_messages[1].msgSerial == 1 +ASSERT captured_messages[2].msgSerial == 2 + +# Each publish should resolve with the correct PublishResult +ASSERT result1.serials[0] == "serial-0" +ASSERT result2.serials[0] == "serial-1" +ASSERT result3.serials[0] == "serial-2" +``` + +--- + +## RTL6j - Publish NACK results in error + +| Spec | Requirement | +|------|-------------| +| RTN7a | All MESSAGE ProtocolMessages sent to Ably expect either an ACK or NACK to confirm success or failure | + +Tests that when the server responds with a NACK instead of an ACK, the publish future completes with an error. + +### Setup +```pseudo +channel_name = "test-RTL6j-nack-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Respond with NACK + mock_ws.send_to_client(ProtocolMessage( + action: NACK, + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Publish rejected") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.publish(name: "rejected", data: "data") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code == 40160 +ASSERT error.message == "Publish rejected" +``` diff --git a/uts/realtime/unit/helpers/mock_websocket.md b/uts/realtime/unit/helpers/mock_websocket.md index f6a76d8a1..345496aa3 100644 --- a/uts/realtime/unit/helpers/mock_websocket.md +++ b/uts/realtime/unit/helpers/mock_websocket.md @@ -90,13 +90,28 @@ mock_ws = MockWebSocket( conn.respond_with_success(CONNECTED_MESSAGE) }, onMessageFromClient: (msg) => { - # Handle messages from client + # Handle decoded messages from client + }, + onTextDataFrame: (text) => { + # Handle raw text WebSocket data frame (JSON protocol) + }, + onBinaryDataFrame: (bytes) => { + # Handle raw binary WebSocket data frame (msgpack protocol) } ) ``` Handlers are called automatically when connection attempts or messages occur. The await-based API should always be available for tests that need to coordinate responses with test state. +### Raw Data Frame Hooks + +The `onTextDataFrame` and `onBinaryDataFrame` handlers provide access to the raw WebSocket data frames before they are decoded into `ProtocolMessage` objects. This is useful for tests that need to verify the wire encoding (e.g., that null fields are omitted from the encoded representation). + +- **`onTextDataFrame(text: String)`** — Called when the client sends a text WebSocket frame. This occurs when using the JSON protocol (`useBinaryProtocol: false`). The `text` parameter is the raw JSON string. +- **`onBinaryDataFrame(bytes: Bytes)`** — Called when the client sends a binary WebSocket frame. This occurs when using the msgpack protocol (`useBinaryProtocol: true`). The `bytes` parameter is the raw msgpack-encoded bytes. + +Both raw frame handlers are called **in addition to** `onMessageFromClient` (which receives the decoded `ProtocolMessage`). If only `onMessageFromClient` is provided, raw frames are not surfaced to the test. + ### When to Use Each Pattern **Handler pattern** (recommended for most tests): From 0c6b330a6f50eb21837d161e75e4dc282f65a72b Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 12/32] Add realtime entries for stats() and time() referencing existing REST specs Add realtime test spec stubs for stats (RSC6a) and time (RSC16) that reference the existing REST test specs, since behaviour is identical. --- uts/completion-status.md | 4 ++-- uts/realtime/unit/client/realtime_stats.md | 17 +++++++++++++++++ uts/realtime/unit/client/realtime_time.md | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 uts/realtime/unit/client/realtime_stats.md create mode 100644 uts/realtime/unit/client/realtime_time.md diff --git a/uts/completion-status.md b/uts/completion-status.md index d20e63571..754d812e0 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -150,8 +150,8 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC2 | Connection object attribute | Yes — `realtime/unit/client/realtime_client.md` | | RTC3 | Channels object attribute | Yes — `realtime/unit/client/realtime_client.md` | | RTC4 | Auth object attribute (RTC4a) | Yes — `realtime/unit/client/realtime_client.md` | -| RTC5 | Stats function (RTC5a–RTC5b) | | -| RTC6 | Time function (RTC6a) | | +| RTC5 | Stats function (RTC5a–RTC5b) | Yes — `realtime/unit/client/realtime_stats.md` (proxies to RSC6 tests) | +| RTC6 | Time function (RTC6a) | Yes — `realtime/unit/client/realtime_time.md` (proxies to RSC16 tests) | | RTC7 | Uses configured timeouts | | | RTC8 | Authorize function for realtime (RTC8a–RTC8c) | | | RTC9 | Request function | | diff --git a/uts/realtime/unit/client/realtime_stats.md b/uts/realtime/unit/client/realtime_stats.md new file mode 100644 index 000000000..7c19dbd4a --- /dev/null +++ b/uts/realtime/unit/client/realtime_stats.md @@ -0,0 +1,17 @@ +# RealtimeClient Stats Tests + +Spec points: `RTC5`, `RTC5a`, `RTC5b` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTC5 - RealtimeClient#stats proxies to RestClient#stats + +| Spec | Requirement | +|------|-------------| +| RTC5a | Proxy to `RestClient#stats` presented with an async or threaded interface as appropriate | +| RTC5b | Accepts all the same params as `RestClient#stats` and provides all the same functionality | + +`RealtimeClient#stats` is a direct proxy to `RestClient#stats`. The tests in `uts/test/rest/unit/stats.md` (covering RSC6) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. diff --git a/uts/realtime/unit/client/realtime_time.md b/uts/realtime/unit/client/realtime_time.md new file mode 100644 index 000000000..c0213cc94 --- /dev/null +++ b/uts/realtime/unit/client/realtime_time.md @@ -0,0 +1,16 @@ +# RealtimeClient Time Tests + +Spec points: `RTC6`, `RTC6a` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTC6 - RealtimeClient#time proxies to RestClient#time + +| Spec | Requirement | +|------|-------------| +| RTC6a | Proxy to `RestClient#time` presented with an async or threaded interface as appropriate | + +`RealtimeClient#time` is a direct proxy to `RestClient#time`. The tests in `uts/test/rest/unit/time.md` (covering RSC16) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. From 1b7778c0124cfe100114a42b6e73fb5f3351c932 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 13/32] Add test specs for Realtime.request() (RTN18) Add test specs covering the Realtime.request() method for making arbitrary REST requests through the realtime client. --- uts/completion-status.md | 2 +- uts/realtime/unit/client/realtime_request.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 uts/realtime/unit/client/realtime_request.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 754d812e0..9861471f1 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -154,7 +154,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC6 | Time function (RTC6a) | Yes — `realtime/unit/client/realtime_time.md` (proxies to RSC16 tests) | | RTC7 | Uses configured timeouts | | | RTC8 | Authorize function for realtime (RTC8a–RTC8c) | | -| RTC9 | Request function | | +| RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | | RTC10–RTC11 | Deleted | | | RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | | RTC13 | Push object attribute | | diff --git a/uts/realtime/unit/client/realtime_request.md b/uts/realtime/unit/client/realtime_request.md new file mode 100644 index 000000000..95ff9586b --- /dev/null +++ b/uts/realtime/unit/client/realtime_request.md @@ -0,0 +1,14 @@ +# RealtimeClient Request Tests + +Spec points: `RTC9` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTC9 - RealtimeClient#request proxies to RestClient#request + +**Spec requirement:** `RealtimeClient#request` is a wrapper around `RestClient#request` (see RSC19) delivered in an idiomatic way for the realtime library. + +`RealtimeClient#request` is a direct proxy to `RestClient#request`. The tests in `uts/test/rest/unit/request.md` (covering RSC19) should be used to test a `RealtimeClient` instance in place of a `RestClient` instance. All the same behaviour, parameters, and return types apply. From 6622edc07c48e1709d8463ffa95f8c36aa439877 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 14/32] Extend realtime publish tests: queued messages and state transitions Add test coverage for message queueing during connection state changes, publish behaviour across different connection states, and message delivery ordering guarantees. --- uts/completion-status.md | 4 +- uts/realtime/unit/channels/channel_publish.md | 844 +++++++++++++++++- 2 files changed, 845 insertions(+), 3 deletions(-) diff --git a/uts/completion-status.md b/uts/completion-status.md index 9861471f1..35a46fb2a 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -173,7 +173,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTN4 | Connection event emission (RTN4a–RTN4i) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN4b, RTN4c; `realtime/unit/connection/update_events_test.md` covers RTN4h | | RTN5 | Concurrency test (50+ clients) | | | RTN6 | Successful connection definition | | -| RTN7 | ACK and NACK handling (RTN7a–RTN7e) | Partial — `realtime/unit/channels/channel_publish.md` covers RTN7a, RTN7b (via RTL6j tests) | +| RTN7 | ACK and NACK handling (RTN7a–RTN7e) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN7a, RTN7b (via RTL6j tests), RTN7d, RTN7e | | RTN8 | Connection#id attribute (RTN8a–RTN8c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | | RTN9 | Connection#key attribute (RTN9a–RTN9c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | | RTN11 | Connect function (RTN11a–RTN11f) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN11; `realtime/unit/connection/error_reason_test.md` covers RTN11d | @@ -183,7 +183,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTN15 | Connection failures when CONNECTED (RTN15a–RTN15j) | Yes — `realtime/unit/connection/connection_failures_test.md` | | RTN16 | Connection recovery (RTN16a–RTN16m1) | Partial — `realtime/unit/connection/error_reason_test.md` covers RTN16e | | RTN17 | Domain selection and fallback (RTN17a–RTN17j) | Yes — `realtime/unit/connection/fallback_hosts_test.md` | -| RTN19 | Transport state side effects (RTN19a–RTN19b) | | +| RTN19 | Transport state side effects (RTN19a–RTN19b) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN19a, RTN19a2, RTN19b | | RTN20 | OS network change handling (RTN20a–RTN20c) | | | RTN21 | ConnectionDetails override defaults | Partial — `realtime/unit/connection/update_events_test.md` covers RTN21; `realtime/integration/connection_lifecycle_test.md` covers RTN21 | | RTN22 | Re-authentication request handling (RTN22a) | | diff --git a/uts/realtime/unit/channels/channel_publish.md b/uts/realtime/unit/channels/channel_publish.md index 8f4b6ffa5..db12c32ad 100644 --- a/uts/realtime/unit/channels/channel_publish.md +++ b/uts/realtime/unit/channels/channel_publish.md @@ -1,6 +1,6 @@ # RealtimeChannel Publish Tests -Spec points: `RTL6`, `RTL6a`, `RTL6c`, `RTL6c1`, `RTL6c2`, `RTL6c4`, `RTL6c5`, `RTL6i`, `RTL6i1`, `RTL6i2`, `RTL6i3`, `RTL6j` +Spec points: `RTL6`, `RTL6a`, `RTL6c`, `RTL6c1`, `RTL6c2`, `RTL6c4`, `RTL6c5`, `RTL6i`, `RTL6i1`, `RTL6i2`, `RTL6i3`, `RTL6j`, `RTN7d`, `RTN7e`, `RTN19a`, `RTN19a2`, `RTN19b` ## Test Type Unit test with mocked WebSocket @@ -1253,3 +1253,845 @@ ASSERT error IS NOT null ASSERT error.code == 40160 ASSERT error.message == "Publish rejected" ``` + +--- + +## RTN7e - Pending publishes fail when connection enters SUSPENDED + +| Spec | Requirement | +|------|-------------| +| RTN7e | If a connection enters the SUSPENDED, CLOSED or FAILED state, and an ACK or NACK has not yet been received for a message submitted to the connection, the client should consider the delivery of those messages as failed, meaning their callback should be called with an error representing the reason for the state change, and they should be removed from any RTN19a retry queue. | + +Tests that messages awaiting ACK/NACK are failed with the state change reason when the connection enters SUSPENDED. + +### Setup +```pseudo +channel_name = "test-RTN7e-suspended-${random_id()}" + +enable_fake_timers() + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 1000, + connectionStateTtl: 5000 +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Disconnect and refuse all reconnection attempts so connection enters SUSPENDED +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_refused() +) +install_mock(mock_ws) + +mock_ws.active_connection.simulate_disconnect() + +# Advance time until connection enters SUSPENDED +LOOP up to 15 times: + ADVANCE_TIME(2000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended + +# The pending publish should now fail +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTN7e - Pending publishes fail when connection enters CLOSED + +**Spec requirement:** If a connection enters the CLOSED state, pending messages are failed with an error representing the reason for the state change. + +Tests that messages awaiting ACK/NACK are failed when the connection is explicitly closed. + +### Setup +```pseudo +channel_name = "test-RTN7e-closed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Close the connection +AWAIT client.close() +ASSERT client.connection.state == ConnectionState.closed + +# The pending publish should now fail +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTN7e - Pending publishes fail when connection enters FAILED + +**Spec requirement:** If a connection enters the FAILED state, pending messages are failed with an error representing the reason for the state change. + +Tests that messages awaiting ACK/NACK are failed when the connection enters FAILED. + +### Setup +```pseudo +channel_name = "test-RTN7e-failed-${random_id()}" +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + IF connection_count == 1: + conn.respond_with_success(CONNECTED_MESSAGE) + ELSE: + # Fatal error on reconnection attempt + conn.respond_with_success() + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + # Send a fatal error to force FAILED state + mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 80000, message: "Fatal error") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish — server responds with fatal ERROR instead of ACK +publish_future = channel.publish(name: "pending", data: "data") + +AWAIT_STATE client.connection.state == ConnectionState.failed + +# The pending publish should now fail +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTN7e - Multiple pending publishes all fail on state change + +**Spec requirement:** All messages awaiting ACK/NACK are failed when the connection enters a terminal state. + +Tests that when multiple publishes are pending and the connection enters CLOSED, all of them fail. + +### Setup +```pseudo +channel_name = "test-RTN7e-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave all messages pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish multiple messages, none will be ACK'd +future1 = channel.publish(name: "msg1", data: "data1") +future2 = channel.publish(name: "msg2", data: "data2") +future3 = channel.publish(name: "msg3", data: "data3") + +# Close the connection +AWAIT client.close() + +# All pending publishes should fail +AWAIT future1 FAILS WITH error1 +AWAIT future2 FAILS WITH error2 +AWAIT future3 FAILS WITH error3 +``` + +### Assertions +```pseudo +ASSERT error1 IS NOT null +ASSERT error2 IS NOT null +ASSERT error3 IS NOT null +``` + +--- + +## RTN7d - Pending publishes fail on DISCONNECTED when queueMessages is false + +| Spec | Requirement | +|------|-------------| +| RTN7d | If the `queueMessages` client option (TO3g) has been set to false, then when a connection enters the DISCONNECTED state, any messages which have not yet been ACK'd should be considered to have failed, with the same effect as in RTN7e. | + +Tests that when queueMessages is false and the connection becomes DISCONNECTED, pending messages awaiting ACK/NACK are failed immediately. + +### Setup +```pseudo +channel_name = "test-RTN7d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + # Do NOT send ACK — leave message pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + queueMessages: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Disconnect — triggers DISCONNECTED state +mock_ws.active_connection.simulate_disconnect() + +# Record state changes to verify DISCONNECTED was reached +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# The pending publish should fail immediately on DISCONNECTED +AWAIT publish_future FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code IS NOT null +``` + +--- + +## RTN7d - Pending publishes survive DISCONNECTED when queueMessages is true (default) + +**Spec requirement:** The RTN7d behavior (failing on DISCONNECTED) only applies when `queueMessages` is false. With the default `queueMessages: true`, pending messages should NOT be failed on DISCONNECTED — they are retained for resending per RTN19a. + +Tests that with the default queueMessages=true, pending messages are not failed when the connection enters DISCONNECTED. + +### Setup +```pseudo +channel_name = "test-RTN7d-default-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append(msg) + IF connection_count >= 2: + # ACK on reconnection + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-ack"])] + )) + # First connection: do NOT ACK — leave pending + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish but don't ACK — message stays pending +publish_future = channel.publish(name: "pending", data: "data") + +# Disconnect +mock_ws.active_connection.simulate_disconnect() + +# Reconnect +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# The publish should eventually succeed (resent on new transport, then ACK'd) +result = AWAIT publish_future +``` + +### Assertions +```pseudo +ASSERT result IS PublishResult +ASSERT result.serials[0] == "serial-ack" +``` + +--- + +## RTN19a - Pending messages resent on new transport after disconnect + +| Spec | Requirement | +|------|-------------| +| RTN19a | Any ProtocolMessage that is awaiting an ACK/NACK on the old transport will not receive the ACK/NACK on the new transport. The client library must therefore resend any ProtocolMessage that is awaiting an ACK/NACK to Ably in order to receive the expected ACK/NACK for that message. | + +Tests that after a transport disconnect and reconnect, messages that were awaiting ACK/NACK are resent on the new transport. + +### Setup +```pseudo +channel_name = "test-RTN19a-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + # ACK on second connection + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-resent"])] + )) + # First connection: do NOT ACK + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish — will be sent on first transport, no ACK received +publish_future = channel.publish(name: "resend-me", data: "data") + +# Verify message was sent on first transport +first_transport_messages = filter(captured_messages, (m) => m.connection == 1 AND m.msg.action == MESSAGE) +ASSERT length(first_transport_messages) == 1 + +# Disconnect +mock_ws.active_connection.simulate_disconnect() + +# Reconnect +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# The publish should succeed (resent and ACK'd on new transport) +result = AWAIT publish_future +``` + +### Assertions +```pseudo +# Message should have been sent on both transports +second_transport_messages = filter(captured_messages, (m) => m.connection == 2 AND m.msg.action == MESSAGE) +ASSERT length(second_transport_messages) >= 1 + +# The resent message should have the same content +ASSERT second_transport_messages[0].msg.messages[0].name == "resend-me" + +# Publish should have resolved successfully +ASSERT result IS PublishResult +ASSERT result.serials[0] == "serial-resent" +``` + +--- + +## RTN19a2 - Resent messages keep same msgSerial on successful resume + +| Spec | Requirement | +|------|-------------| +| RTN19a2 | In the case of an RTN15c6 successful resume, the msgSerial of the reattempted ProtocolMessages should remain the same as for the original attempt. | +| RTN15c6 | A CONNECTED ProtocolMessage with the same connectionId as the current client (and no error property) indicates that the resume attempt was valid. | + +Tests that when messages are resent after a successful connection resume, they retain their original msgSerial values. + +### Setup +```pseudo +channel_name = "test-RTN19a2-resume-${random_id()}" +captured_messages = [] +original_connection_id = "connection-abc" + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + # Both connections use the same connectionId = successful resume (RTN15c6) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: original_connection_id, + connectionKey: "key-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-resumed"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish two messages — neither will be ACK'd +future1 = channel.publish(name: "msg1", data: "data1") +future2 = channel.publish(name: "msg2", data: "data2") + +# Capture original msgSerials +first_transport_msgs = filter(captured_messages, (m) => m.connection == 1 AND m.msg.action == MESSAGE) +original_serial_1 = first_transport_msgs[0].msg.msgSerial +original_serial_2 = first_transport_msgs[1].msg.msgSerial + +# Disconnect and reconnect (successful resume — same connectionId) +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +result1 = AWAIT future1 +result2 = AWAIT future2 +``` + +### Assertions +```pseudo +# Messages resent on second transport should have the SAME msgSerials +second_transport_msgs = filter(captured_messages, (m) => m.connection == 2 AND m.msg.action == MESSAGE) +ASSERT length(second_transport_msgs) == 2 +ASSERT second_transport_msgs[0].msg.msgSerial == original_serial_1 +ASSERT second_transport_msgs[1].msg.msgSerial == original_serial_2 +``` + +--- + +## RTN19a2 - Resent messages get new msgSerial on failed resume + +| Spec | Requirement | +|------|-------------| +| RTN19a2 | In the case of an RTN15c7 failed resume, the message must be assigned a new msgSerial from the SDK's internal counter. | +| RTN15c7 | CONNECTED ProtocolMessage with a new connectionId and an ErrorInfo in the error field. The internal msgSerial counter should be reset so that the first message published will contain a msgSerial of 0. | + +Tests that when messages are resent after a failed connection resume, they are assigned new msgSerial values starting from 0. + +### Setup +```pseudo +channel_name = "test-RTN19a2-failed-resume-${random_id()}" +captured_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + IF connection_count == 1: + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-first", + connectionKey: "key-first" + )) + ELSE: + # Failed resume — different connectionId + error (RTN15c7) + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-new", + connectionKey: "key-new", + error: ErrorInfo(code: 80018, message: "Connection not resumable") + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + ELSE IF msg.action == MESSAGE: + captured_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1, + res: [PublishResult(serials: ["serial-new"])] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Publish two messages with msgSerials 0 and 1 — neither will be ACK'd +future1 = channel.publish(name: "msg1", data: "data1") +future2 = channel.publish(name: "msg2", data: "data2") + +# Verify original serials +first_transport_msgs = filter(captured_messages, (m) => m.connection == 1 AND m.msg.action == MESSAGE) +ASSERT first_transport_msgs[0].msg.msgSerial == 0 +ASSERT first_transport_msgs[1].msg.msgSerial == 1 + +# Disconnect and reconnect (failed resume — different connectionId + error) +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +result1 = AWAIT future1 +result2 = AWAIT future2 +``` + +### Assertions +```pseudo +# Messages resent on second transport should have NEW msgSerials starting from 0 +# (RTN15c7 resets the internal msgSerial counter) +second_transport_msgs = filter(captured_messages, (m) => m.connection == 2 AND m.msg.action == MESSAGE) +ASSERT length(second_transport_msgs) == 2 +ASSERT second_transport_msgs[0].msg.msgSerial == 0 +ASSERT second_transport_msgs[1].msg.msgSerial == 1 +``` + +--- + +## RTN19b - Pending ATTACH resent on new transport after disconnect + +| Spec | Requirement | +|------|-------------| +| RTN19b | If there are any pending channels i.e. in the ATTACHING or DETACHING state, the respective ATTACH or DETACH message should be resent to Ably. | + +Tests that after a transport disconnect and reconnect, channels in the ATTACHING state have their ATTACH message resent. + +### Setup +```pseudo +channel_name = "test-RTN19b-attach-${random_id()}" +captured_attach_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + captured_attach_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + # Respond with ATTACHED on second connection + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + # First connection: don't respond — leave channel ATTACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't respond — channel stays ATTACHING +attach_future = channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Verify ATTACH was sent on first transport +first_transport_attaches = filter(captured_attach_messages, (m) => m.connection == 1) +ASSERT length(first_transport_attaches) == 1 +ASSERT first_transport_attaches[0].msg.channel == channel_name + +# Disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach should complete (ATTACH resent and responded to on new transport) +AWAIT attach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached + +# ATTACH should have been resent on second transport +second_transport_attaches = filter(captured_attach_messages, (m) => m.connection == 2) +ASSERT length(second_transport_attaches) >= 1 +ASSERT second_transport_attaches[0].msg.channel == channel_name +``` + +--- + +## RTN19b - Pending DETACH resent on new transport after disconnect + +**Spec requirement:** If there are any pending channels in the DETACHING state, the respective DETACH message should be resent to Ably. + +Tests that after a transport disconnect and reconnect, channels in the DETACHING state have their DETACH message resent. + +### Setup +```pseudo +channel_name = "test-RTN19b-detach-${random_id()}" +captured_detach_messages = [] + +enable_fake_timers() + +connection_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(CONNECTED_MESSAGE) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + ELSE IF msg.action == DETACH: + captured_detach_messages.append({ + msg: msg, + connection: connection_count + }) + IF connection_count >= 2: + # Respond with DETACHED on second connection + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: msg.channel + )) + # First connection: don't respond — leave channel DETACHING + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Start detach but don't respond — channel stays DETACHING +detach_future = channel.detach() +AWAIT_STATE channel.state == ChannelState.detaching + +# Verify DETACH was sent on first transport +first_transport_detaches = filter(captured_detach_messages, (m) => m.connection == 1) +ASSERT length(first_transport_detaches) == 1 + +# Disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +ADVANCE_TIME(2000) +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Detach should complete (DETACH resent and responded to on new transport) +AWAIT detach_future +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached + +# DETACH should have been resent on second transport +second_transport_detaches = filter(captured_detach_messages, (m) => m.connection == 2) +ASSERT length(second_transport_detaches) >= 1 +ASSERT second_transport_detaches[0].msg.channel == channel_name +``` From 0f010d475ac41b092cc5507226467aa57865a28d Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 15/32] Add test specs for RSN1-4 (connection recovery) and RTL10 (realtime history) Add specs covering connection state recovery options and realtime channel history retrieval. --- uts/completion-status.md | 8 +- .../integration/channel_history_test.md | 109 ++++++++++++++++ uts/realtime/unit/channels/channel_history.md | 119 ++++++++++++++++++ 3 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 uts/realtime/integration/channel_history_test.md create mode 100644 uts/realtime/unit/channels/channel_history.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 35a46fb2a..6550c76a0 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -47,12 +47,12 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSC17 | ClientId attribute | | | RSC18 | TLS configuration | Yes — `rest/unit/rest_client.md`, `rest/unit/time.md` | | RSC19 | Request function (RSC19a–RSC19f1) | Yes — `rest/unit/request.md` | -| RSC20 | Deprecated exception reporting (RSC20a–RSC20f) | | +| RSC20 | Deprecated exception reporting (RSC20a–RSC20f) |N/A | | RSC21 | Push object attribute | | | RSC22 | BatchPublish (RSC22a–RSC22d) | Yes — `rest/unit/batch_publish.md` | | RSC23 | Deleted | | | RSC24 | BatchPresence | | -| RSC25 | Request endpoint | | +| RSC25 | Request endpoint | Yes — `rest/unit/request_endpoint.md` | | RSC26 | CreateWrapperSDKProxy (RSC26a–RSC26c) | | ### Auth @@ -80,7 +80,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSN1–RSN4 | REST channels collection (RSN1–RSN4c) | | +| RSN1–RSN4 | REST channels collection (RSN1–RSN4c) | Yes — `rest/unit/channels_collection.md` | ### RestChannel @@ -216,7 +216,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL8 | Unsubscribe function (RTL8a–RTL8c) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL9 | Presence attribute (RTL9a) | | -| RTL10 | History function (RTL10a–RTL10d) | | +| RTL10 | History function (RTL10a–RTL10d) | Yes — `realtime/unit/channels/channel_history.md` covers RTL10a, RTL10b, RTL10c (proxies to RSL2 tests); `realtime/integration/channel_history_test.md` covers RTL10d | | RTL11 | Channel state effect on presence (RTL11a) | | | RTL12 | Additional ATTACHED message handling | | | RTL13 | Server-initiated DETACHED handling (RTL13a–RTL13c) | Yes — `realtime/unit/channels/channel_server_initiated_detach.md` | diff --git a/uts/realtime/integration/channel_history_test.md b/uts/realtime/integration/channel_history_test.md new file mode 100644 index 000000000..6f3eb032d --- /dev/null +++ b/uts/realtime/integration/channel_history_test.md @@ -0,0 +1,109 @@ +# RealtimeChannel History Integration Test + +Spec points: `RTL10d` + +## Test Type +Integration test against Ably Sandbox endpoint + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTL10d - History contains messages published by another client + +| Spec | Requirement | +|------|-------------| +| RTL10d | A test should exist that publishes messages from one client, and upon confirmation of message delivery, a history request should be made on another client to ensure all messages are available | + +Tests that messages published by one Realtime client are available in the history retrieved by a separate client. + +### Setup + +```pseudo +channel_name = "history-RTL10d-" + random_id() + +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) + +publisher.connect() +subscriber.connect() + +AWAIT_STATE publisher.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE subscriber.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +pub_channel = publisher.channels.get(channel_name) +sub_channel = subscriber.channels.get(channel_name) + +AWAIT pub_channel.attach() +AWAIT sub_channel.attach() +``` + +### Test Steps + +```pseudo +# Publish messages from publisher client and await confirmation +AWAIT pub_channel.publish(name: "event1", data: "data1") +AWAIT pub_channel.publish(name: "event2", data: "data2") +AWAIT pub_channel.publish(name: "event3", data: "data3") + +# Retrieve history from subscriber client +# Poll until all messages appear +history = poll_until( + condition: FUNCTION() => + result = AWAIT sub_channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) +``` + +### Assertions + +```pseudo +ASSERT history.items.length == 3 + +# Default order is backwards (newest first) +ASSERT history.items[0].name == "event3" +ASSERT history.items[0].data == "data3" + +ASSERT history.items[1].name == "event2" +ASSERT history.items[1].data == "data2" + +ASSERT history.items[2].name == "event1" +ASSERT history.items[2].data == "data1" +``` + +### Cleanup + +```pseudo +AFTER TEST: + publisher.close() + subscriber.close() +``` diff --git a/uts/realtime/unit/channels/channel_history.md b/uts/realtime/unit/channels/channel_history.md new file mode 100644 index 000000000..5e9e0b6ec --- /dev/null +++ b/uts/realtime/unit/channels/channel_history.md @@ -0,0 +1,119 @@ +# RealtimeChannel History Tests + +Spec points: `RTL10`, `RTL10a`, `RTL10b`, `RTL10c` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTL10a - RealtimeChannel#history supports all RestChannel#history params + +| Spec | Requirement | +|------|-------------| +| RTL10a | Supports all the same params as `RestChannel#history` | +| RTL10c | Returns a `PaginatedResult` page containing the first page of messages | + +`RealtimeChannel#history` uses the same underlying REST endpoint as `RestChannel#history`. The tests in `uts/test/rest/unit/channel/history.md` (covering RSL2) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. + +--- + +## RTL10b - untilAttach parameter + +**Spec requirement:** Additionally supports the param `untilAttach`, which if true, will only retrieve messages prior to the moment that the channel was attached or emitted an UPDATE indicating loss of continuity. This bound is specified by passing the querystring param `fromSerial` with the `RealtimeChannel#properties.attachSerial` assigned to the channel in the ATTACHED ProtocolMessage (see RTL15a). If the `untilAttach` param is specified when the channel is not attached, it results in an error. + +### RTL10b - untilAttach adds fromSerial query parameter + +Tests that when `untilAttach` is true and the channel is attached, the history request includes a `fromSerial` query parameter set to the channel's `attachSerial`. + +#### Setup +```pseudo +channel_name = "test-RTL10b-${random_id()}" +captured_requests = [] +attach_serial = "serial-abc:0" + +mock_http = MockHttpClient( + onRequest: (req) => { + captured_requests.append(req) + req.respond_with(200, []) + } +) + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + channelSerial: attach_serial + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws, + httpClient: mock_http +) + +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +#### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() +ASSERT channel.state == ATTACHED + +AWAIT channel.history(untilAttach: true) +``` + +#### Assertions +```pseudo +request = captured_requests[0] +ASSERT request.url.query_params["fromSerial"] == attach_serial +``` + +### RTL10b - untilAttach errors when not attached + +Tests that when `untilAttach` is true and the channel is not attached, the history call results in an error. + +#### Setup +```pseudo +channel_name = "test-RTL10b-err-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) + +channel = client.channels.get(channel_name, RealtimeChannelOptions(attachOnSubscribe: false)) +``` + +#### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +ASSERT channel.state == INITIALIZED + +error = null +TRY: + AWAIT channel.history(untilAttach: true) +CATCH e: + error = e +``` + +#### Assertions +```pseudo +ASSERT error IS AblyException +``` From 4482d5fd9da486d3e4fa504bda4e07291cb5e56a Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 16/32] Add test specs for client logging (LOG1-LOG3) Add specs covering log level configuration, log handler callbacks, and default logging behaviour for REST and Realtime clients. --- uts/completion-status.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uts/completion-status.md b/uts/completion-status.md index 6550c76a0..f51489c23 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -32,9 +32,9 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| | RSC1 | Constructor options (RSC1a–RSC1c) | Yes — `realtime/unit/client/client_options.md`, `realtime/unit/client/realtime_client.md` | -| RSC2 | Logger default | | -| RSC3 | Log level configuration | | -| RSC4 | Custom logger | | +| RSC2 | Logger default | Yes — `rest/unit/logging.md` | +| RSC3 | Log level configuration | Yes — `rest/unit/logging.md` | +| RSC4 | Custom logger | Yes — `rest/unit/logging.md` | | RSC5 | Auth object attribute | | | RSC6 | Stats function (RSC6a–RSC6b4) | Yes — `rest/unit/stats.md`, `rest/integration/time_stats.md` | | RSC7 | HTTP request headers (RSC7a–RSC7d7) | Yes — `rest/unit/rest_client.md` | @@ -391,7 +391,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Area | Spec groups | With UTS spec | Coverage | |------|-------------|---------------|----------| | **Endpoint config** (REC) | 3 | 3 | Full | -| **REST client** (RSC) | 18 | 9 | Partial | +| **REST client** (RSC) | 18 | 12 | Partial | | **REST auth** (RSA) | 15 | 10 | Partial | | **REST channels** (RSN) | 4 | 0 | None | | **REST channel** (RSL) | 13 | 6 | Partial | From 156301da7b60aa55f0d9ac0bb38a5c7cbed49c41 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 17/32] Add remaining auth test specs Complete the authentication test spec coverage with specs for token reauth, auth error handling, and edge cases. --- uts/completion-status.md | 34 +- uts/realtime/integration/auth.md | 254 +++++ uts/realtime/unit/auth/realtime_authorize.md | 901 ++++++++++++++++++ .../server_initiated_reauth_test.md | 287 ++++++ uts/rest/integration/auth.md | 97 ++ uts/rest/unit/rest_client.md | 20 + 6 files changed, 1576 insertions(+), 17 deletions(-) create mode 100644 uts/realtime/integration/auth.md create mode 100644 uts/realtime/unit/auth/realtime_authorize.md create mode 100644 uts/realtime/unit/connection/server_initiated_reauth_test.md diff --git a/uts/completion-status.md b/uts/completion-status.md index f51489c23..4ae917734 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -13,7 +13,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| CSV1–CSV2 | Specification & protocol versions | | +| CSV1–CSV2 | Specification & protocol versions | Information only | ## Client Library Endpoint Configuration @@ -35,16 +35,16 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSC2 | Logger default | Yes — `rest/unit/logging.md` | | RSC3 | Log level configuration | Yes — `rest/unit/logging.md` | | RSC4 | Custom logger | Yes — `rest/unit/logging.md` | -| RSC5 | Auth object attribute | | +| RSC5 | Auth object attribute | Yes — `rest/unit/rest_client.md` | | RSC6 | Stats function (RSC6a–RSC6b4) | Yes — `rest/unit/stats.md`, `rest/integration/time_stats.md` | | RSC7 | HTTP request headers (RSC7a–RSC7d7) | Yes — `rest/unit/rest_client.md` | | RSC8 | Protocol support (RSC8a–RSC8e2) | Yes — `rest/unit/rest_client.md` | -| RSC9 | Auth usage for authentication | | -| RSC10 | Token error retry handling | | +| RSC9 | Auth usage for authentication | Information only | +| RSC10 | Token error retry handling | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | | RSC13 | Connection and request timeouts | Yes — `rest/unit/rest_client.md` | | RSC15 | Host fallback behaviour (RSC15a–RSC15n) | Yes — `rest/unit/fallback.md` | | RSC16 | Time function | Yes — `rest/unit/time.md`, `rest/integration/time_stats.md` | -| RSC17 | ClientId attribute | | +| RSC17 | ClientId attribute | Yes — `rest/unit/rest_client.md` | | RSC18 | TLS configuration | Yes — `rest/unit/rest_client.md`, `rest/unit/time.md` | | RSC19 | Request function (RSC19a–RSC19f1) | Yes — `rest/unit/request.md` | | RSC20 | Deprecated exception reporting (RSC20a–RSC20f) |N/A | @@ -63,16 +63,16 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSA2 | Basic Auth default | Yes — `rest/unit/auth/auth_scheme.md` | | RSA3 | Token Auth support (RSA3a–RSA3d) | Yes — `rest/unit/auth/auth_scheme.md` | | RSA4 | Token Auth selection logic (RSA4a–RSA4g) | Partial — `rest/unit/auth/auth_scheme.md` covers RSA4, RSA4b; `rest/unit/auth/token_renewal.md` covers RSA4b4; `realtime/unit/auth/connection_auth_test.md` covers RSA4; `realtime/unit/connection/error_reason_test.md` covers RSA4c1, RSA4d | -| RSA5 | TTL for tokens | | -| RSA6 | Capability JSON | | -| RSA7 | ClientId and authenticated clients (RSA7a–RSA7e2) | Partial — `rest/unit/auth/client_id.md` covers RSA7, RSA7a–RSA7c | -| RSA8 | RequestToken function (RSA8a–RSA8g) | Partial — `rest/unit/auth/auth_callback.md` covers RSA8c, RSA8d; `realtime/unit/auth/connection_auth_test.md` covers RSA8d | +| RSA5 | TTL for tokens | Yes — `rest/unit/auth/token_request_params.md`, `rest/integration/auth.md` | +| RSA6 | Capability JSON | Yes — `rest/unit/auth/token_request_params.md`, `rest/integration/auth.md` | +| RSA7 | ClientId and authenticated clients (RSA7a–RSA7e2) | Partial — `rest/unit/auth/client_id.md` covers RSA7, RSA7a–RSA7c; `realtime/integration/auth.md` covers RSA7 | +| RSA8 | RequestToken function (RSA8a–RSA8g) | Partial — `rest/unit/auth/auth_callback.md` covers RSA8c, RSA8d; `realtime/unit/auth/connection_auth_test.md` covers RSA8d; `rest/integration/auth.md` covers RSA8; `realtime/integration/auth.md` covers RSA8 | | RSA9 | CreateTokenRequest (RSA9a–RSA9i) | Partial — `rest/integration/auth.md` covers RSA9 | | RSA10 | Authorize function (RSA10a–RSA10l) | Yes — `rest/unit/auth/authorize.md` | -| RSA11 | Base64 encoded API key | | +| RSA11 | Base64 encoded API key | Yes — `rest/unit/auth/auth_scheme.md` (with RSA2) | | RSA12 | Auth#clientId attribute (RSA12a–RSA12b) | Yes — `rest/unit/auth/client_id.md` | | RSA14 | Error when token auth selected without token | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | -| RSA15 | ClientId validation (RSA15a–RSA15c) | | +| RSA15 | ClientId validation (RSA15a–RSA15c) | Yes — `rest/unit/auth/client_id.md`, `realtime/integration/auth.md` (RSA15c Realtime case) | | RSA16 | TokenDetails attribute (RSA16a–RSA16d) | Yes — `rest/unit/auth/token_details.md` | | RSA17 | RevokeTokens (RSA17a–RSA17g) | | @@ -153,7 +153,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC5 | Stats function (RTC5a–RTC5b) | Yes — `realtime/unit/client/realtime_stats.md` (proxies to RSC6 tests) | | RTC6 | Time function (RTC6a) | Yes — `realtime/unit/client/realtime_time.md` (proxies to RSC16 tests) | | RTC7 | Uses configured timeouts | | -| RTC8 | Authorize function for realtime (RTC8a–RTC8c) | | +| RTC8 | Authorize function for realtime (RTC8a–RTC8c) | Yes — `realtime/unit/auth/realtime_authorize.md`, `realtime/integration/auth.md` | | RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | | RTC10–RTC11 | Deleted | | | RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | @@ -186,7 +186,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTN19 | Transport state side effects (RTN19a–RTN19b) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN19a, RTN19a2, RTN19b | | RTN20 | OS network change handling (RTN20a–RTN20c) | | | RTN21 | ConnectionDetails override defaults | Partial — `realtime/unit/connection/update_events_test.md` covers RTN21; `realtime/integration/connection_lifecycle_test.md` covers RTN21 | -| RTN22 | Re-authentication request handling (RTN22a) | | +| RTN22 | Re-authentication request handling (RTN22a) | Yes — `realtime/unit/connection/server_initiated_reauth_test.md` | | RTN23 | Heartbeats (RTN23a–RTN23b) | Yes — `realtime/unit/connection/heartbeat_test.md` | | RTN24 | UPDATE event on CONNECTED while connected | Yes — `realtime/unit/connection/update_events_test.md` | | RTN25 | Connection#errorReason attribute | Yes — `realtime/unit/connection/error_reason_test.md` | @@ -391,15 +391,15 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Area | Spec groups | With UTS spec | Coverage | |------|-------------|---------------|----------| | **Endpoint config** (REC) | 3 | 3 | Full | -| **REST client** (RSC) | 18 | 12 | Partial | -| **REST auth** (RSA) | 15 | 10 | Partial | +| **REST client** (RSC) | 18 | 15 | Partial | +| **REST auth** (RSA) | 15 | 15 | Full | | **REST channels** (RSN) | 4 | 0 | None | | **REST channel** (RSL) | 13 | 6 | Partial | | **REST presence** (RSP) | 5 | 4 | Mostly | | **REST encryption** (RSE) | 2 | 0 | None | | **REST annotations** (RSAN) | 3 | 0 | None | -| **Realtime client** (RTC) | 14 | 8 | Partial | -| **Connection** (RTN) | 23 | 16 | Partial | +| **Realtime client** (RTC) | 14 | 12 | Partial | +| **Connection** (RTN) | 23 | 17 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | | **Realtime channel** (RTL) | 24 | 14 | Partial | | **Realtime presence** (RTP) | 15 | 0 | None | diff --git a/uts/realtime/integration/auth.md b/uts/realtime/integration/auth.md new file mode 100644 index 000000000..a91269ccd --- /dev/null +++ b/uts/realtime/integration/auth.md @@ -0,0 +1,254 @@ +# Realtime Auth Integration Tests + +Spec points: `RTC8`, `RSA8`, `RSA7` + +## Test Type +Integration test against Ably sandbox + +## Token Formats + +Tests use JWTs generated using a third-party JWT library, signed with the app key secret using HMAC-SHA256. + +## Test Environment + +### Prerequisites +- Ably sandbox app provisioned via `POST https://sandbox-rest.ably.io/apps` +- API key from provisioned app +- Channel names must be unique per test (see README for naming convention) + +### Setup Pattern +```pseudo +BEFORE ALL TESTS: + app_config = provision_sandbox_app() + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTC8a - In-band reauthorization on CONNECTED client + +**Spec requirement:** RTC8a - When `auth.authorize()` is called on a CONNECTED realtime client, it sends an AUTH protocol message with the new token rather than disconnecting/reconnecting. + +Tests that calling authorize() on a connected client succeeds and the connection remains connected (UPDATE event, not disconnect/reconnect). + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "sandbox", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Connect and wait for CONNECTED +client.connect() +AWAIT_STATE client.connection.state == CONNECTED + +# Record connection ID before reauth +connection_id_before = client.connection.id + +# Collect state changes during reauth +state_changes = [] +subscription = client.connection.on(LISTEN state_changes.append) + +# Call authorize — should send AUTH and get UPDATE, not disconnect +token = AWAIT client.auth.authorize() + +# Check state after reauth +connection_id_after = client.connection.id +``` + +### Assertions +```pseudo +# authorize() returned a valid token +ASSERT token IS NOT NULL +ASSERT token.token IS String + +# Connection remained connected — same connection ID +ASSERT connection_id_after == connection_id_before + +# No state transitions occurred (UPDATE has current == previous == connected, +# so filtering for actual transitions should yield nothing) +state_transitions = state_changes.filter(c => c.current != c.previous) +ASSERT state_transitions IS EMPTY + +AWAIT client.close() +``` + +--- + +## RTC8c - authorize() from INITIALIZED initiates connection + +**Spec requirement:** RTC8c - When `auth.authorize()` is called on a client in INITIALIZED state, it should initiate the connection. + +Tests that calling authorize() on an unconnected client triggers a connection. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "sandbox", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Client starts in INITIALIZED, no connection +ASSERT client.connection.state == INITIALIZED + +# authorize() should trigger connection +token = AWAIT client.auth.authorize() + +# Wait for connection to be established +AWAIT_STATE client.connection.state == CONNECTED +``` + +### Assertions +```pseudo +ASSERT token IS NOT NULL +ASSERT client.connection.state == CONNECTED +ASSERT client.connection.id IS NOT NULL + +AWAIT client.close() +``` + +--- + +## RSA8 - Token auth on realtime connection + +**Spec requirement:** RSA8 - Realtime client can connect using token authentication via an authCallback that returns JWTs. + +Tests that a realtime client can connect using JWT-based token auth. + +### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + endpoint: "sandbox", + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED +``` + +### Assertions +```pseudo +ASSERT client.connection.state == CONNECTED +ASSERT client.connection.id IS NOT NULL +ASSERT client.connection.errorReason IS NULL + +AWAIT client.close() +``` + +--- + +## RSA7 - clientId validation on realtime connection + +**Spec requirement:** RSA7 - The server validates clientId consistency between token claims and connection parameters. + +Tests that: +1. A JWT with a clientId allows connection with matching clientId +2. A JWT with a clientId rejects connection with mismatched clientId + +### Test 1: Matching clientId succeeds + +#### Setup +```pseudo +test_client_id = "test-client-" + random_id() + +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + client_id: test_client_id, + ttl: 3600000 + ) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + clientId: test_client_id, + endpoint: "sandbox", + autoConnect: false +)) +``` + +#### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == CONNECTED +``` + +#### Assertions +```pseudo +ASSERT client.connection.state == CONNECTED +ASSERT client.auth.clientId == test_client_id + +AWAIT client.close() +``` + +### Test 2: Mismatched clientId fails + +#### Setup +```pseudo +auth_callback = FUNCTION(params): + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + client_id: "token-client-id", + ttl: 3600000 + ) +``` + +#### Test Steps +```pseudo +# ClientOptions constructor should reject mismatched clientId +# The clientId in options ("wrong-client-id") doesn't match the token's clientId +# This is validated client-side per RSA7 +EXPECT THROW creating Realtime(options: ClientOptions( + authCallback: auth_callback, + clientId: "wrong-client-id", + endpoint: "sandbox", + autoConnect: false +)) +``` + +#### Assertions +```pseudo +# Note: The mismatch is detected client-side when the token is obtained. +# The exact behavior depends on implementation: it may throw during +# authorize() or during token validation. The key assertion is that +# the connection enters FAILED state with error code 40102. +``` diff --git a/uts/realtime/unit/auth/realtime_authorize.md b/uts/realtime/unit/auth/realtime_authorize.md new file mode 100644 index 000000000..c387daa23 --- /dev/null +++ b/uts/realtime/unit/auth/realtime_authorize.md @@ -0,0 +1,901 @@ +# Realtime Authorize Tests + +Spec points: `RTC8`, `RTC8a`, `RTC8a1`, `RTC8a2`, `RTC8a3`, `RTC8b`, `RTC8b1`, `RTC8c` + +## Test Type +Unit test with mocked WebSocket client and authCallback + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify 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 to Ably. Ably responds with either a `CONNECTED` message (success, +emitting an UPDATE event) or an `ERROR` message (failure). The behaviour varies based +on the current connection state when `authorize()` is called. + +--- + +## RTC8a - authorize() on CONNECTED sends AUTH protocol message + +| Spec | Requirement | +|------|-------------| +| RTC8 | `auth.authorize` instructs the library to obtain a token and alter the current connection to use it | +| RTC8a | If CONNECTED, obtain a new token then send an AUTH ProtocolMessage with an auth attribute containing the token string | + +Tests that calling `authorize()` while connected obtains a new token via the +authCallback and sends an AUTH protocol message containing the new token. + +### Setup +```pseudo +auth_callback_count = 0 +captured_auth_messages = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Track state changes during reauth +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH, record it and respond with new CONNECTED +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + captured_auth_messages.append(msg) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +}) + +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authCallback was called twice (initial connect + authorize) +ASSERT auth_callback_count == 2 + +# An AUTH protocol message was sent +ASSERT captured_auth_messages.length == 1 + +# AUTH message contains the new token +ASSERT captured_auth_messages[0].auth IS NOT null +ASSERT captured_auth_messages[0].auth.accessToken == "token-2" + +# authorize() resolved with the new token +ASSERT token_details.token == "token-2" + +# No state changes occurred — connection stayed CONNECTED throughout +ASSERT state_changes.length == 0 +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTC8a1 - Successful reauth emits UPDATE event + +**Spec requirement:** If the authentication token change is successful, Ably sends a new CONNECTED ProtocolMessage. The connectionDetails must override existing defaults (RTN21). The Connection should emit an UPDATE event per RTN24. + +Tests that a successful in-band reauthorization emits an UPDATE event (not a +CONNECTED state change) and updates connection details. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-1", + connectionKey: "connection-key-1", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Track events +update_events = [] +connected_events = [] +state_changes = [] + +client.connection.on(ConnectionEvent.update, (change) => { + update_events.append(change) +}) +client.connection.on(ConnectionState.connected, (change) => { + connected_events.append(change) +}) +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH, respond with new CONNECTED (updated details) +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-2", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 20000, + connectionStateTtl: 180000 + ) + )) +}) + +AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# UPDATE event was emitted +ASSERT update_events.length == 1 +ASSERT update_events[0].previous == ConnectionState.connected +ASSERT update_events[0].current == ConnectionState.connected + +# No additional CONNECTED state event was emitted +ASSERT connected_events.length == 0 + +# No state changes occurred (stayed CONNECTED throughout) +ASSERT state_changes.length == 0 + +# Connection details were updated (RTN21) +ASSERT client.connection.id == "connection-id-2" +ASSERT client.connection.key == "connection-key-2" +``` + +--- + +## RTC8a1 - Capability downgrade causes channel FAILED + +**Spec requirement:** A test should exist where the capabilities are downgraded resulting in Ably sending an ERROR ProtocolMessage with a channel property, causing the channel to enter the FAILED state. The reason must be included in the channel state change event. + +Tests that after a successful reauth with reduced capabilities, Ably sends a +channel-level ERROR that causes the affected channel to enter FAILED state. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach a channel +channel = client.channels.get("private-channel") + +mock_ws.on_client_message((msg) => { + IF msg.action == ATTACH AND msg.channel == "private-channel": + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: "private-channel", + flags: 0 + )) +}) + +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + +# Track channel state changes +channel_state_changes = [] +channel.on((change) => { + channel_state_changes.append(change) +}) + +# When client sends AUTH, respond with CONNECTED then channel-level ERROR +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + # Reauth succeeds at connection level + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + # Then server sends channel-level ERROR (capability downgrade) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: "private-channel", + error: ErrorInfo( + code: 40160, + statusCode: 401, + message: "Channel denied access based on given capability" + ) + )) +}) + +# Call authorize (to downgrade capabilities) +AWAIT client.auth.authorize() +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel entered FAILED state +ASSERT channel.state == ChannelState.failed + +# Channel state change event includes the error reason +failed_changes = channel_state_changes.filter(c => c.current == ChannelState.failed) +ASSERT failed_changes.length == 1 +ASSERT failed_changes[0].reason IS NOT null +ASSERT failed_changes[0].reason.code == 40160 +ASSERT failed_changes[0].reason.statusCode == 401 + +# Connection remains CONNECTED (channel-level ERROR doesn't close connection) +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## RTC8a2 - Failed reauth transitions connection to FAILED + +**Spec requirement:** If the authentication token change fails, Ably will send an ERROR ProtocolMessage triggering the connection to transition to the FAILED state. A test should exist for a token change that fails (such as sending a new token with an incompatible clientId). + +Tests that a failed in-band reauthorization (e.g. incompatible clientId) causes +the connection to transition to FAILED. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Track state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH, respond with connection-level ERROR (incompatible clientId) +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + mock_ws.active_connection.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40012, + statusCode: 400, + message: "Incompatible clientId" + ) + )) +}) + +AWAIT client.auth.authorize() FAILS WITH error +ASSERT error.code == 40012 +``` + +### Assertions +```pseudo +# Connection transitioned to FAILED +ASSERT client.connection.state == ConnectionState.failed + +# Error reason is set on the connection +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 40012 + +# State changes include FAILED +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.failed +] +``` + +--- + +## RTC8a3 - authorize() completes only after server response + +**Spec requirement:** The authorize call should be indicated as completed with the new token or error only once realtime has responded to the AUTH with either a CONNECTED or ERROR respectively. + +Tests that the Future/Promise returned by `authorize()` does not resolve until +the server responds to the AUTH message. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start authorize — do NOT await +authorize_future = client.auth.authorize() +authorize_completed = false +authorize_future.then((_) => { authorize_completed = true }) + +# Wait for the client to send the AUTH message (confirms token was obtained +# and AUTH was sent, but server hasn't responded yet) +auth_msg = AWAIT mock_ws.await_client_message(action: AUTH) + +# authorize() should NOT have completed yet (server hasn't responded) +ASSERT authorize_completed == false + +# Now send the server response +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) +)) + +# Now await completion +token_details = AWAIT authorize_future +``` + +### Assertions +```pseudo +# authorize() completed after server response +ASSERT authorize_completed == true +ASSERT token_details.token == "token-2" +``` + +--- + +## RTC8b - authorize() while CONNECTING halts current attempt + +**Spec requirement:** If the connection is in the CONNECTING state when auth.authorize is called, all current connection attempts should be halted, and after obtaining a new token the library should immediately initiate a connection attempt using the new token. + +Tests that calling `authorize()` while in the CONNECTING state cancels the +current connection attempt and reconnects with the new token. + +### Setup +```pseudo +auth_callback_count = 0 +captured_ws_urls = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + captured_ws_urls.append(conn.url) + + IF connection_attempt_count == 1: + # First attempt: respond with success but delay CONNECTED + # (simulating CONNECTING state) + conn.respond_with_success() + # Don't send CONNECTED — client stays in CONNECTING + ELSE: + # Second attempt (after authorize): complete normally + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Start connection — will enter CONNECTING +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Call authorize while CONNECTING +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-2" + +# Connection is now CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# authCallback was called twice (initial + authorize) +ASSERT auth_callback_count == 2 + +# Two connection attempts were made +ASSERT connection_attempt_count == 2 + +# Second attempt used the new token +ASSERT captured_ws_urls[1].queryParameters["accessToken"] == "token-2" +``` + +--- + +## RTC8b1 - authorize() while CONNECTING fails on FAILED state + +**Spec requirement:** The authorize call should be indicated as completed with the new token once the connection has moved to the CONNECTED state, or with an error if the connection instead moves to the FAILED, SUSPENDED, or CLOSED states. + +Tests that if the connection transitions to FAILED after `authorize()` is called +while CONNECTING, the authorize future completes with an error. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + + IF connection_attempt_count == 1: + # First attempt: keep in CONNECTING + conn.respond_with_success() + ELSE: + # Second attempt (after authorize): fail with fatal error + conn.respond_with_success() + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40101, + statusCode: 401, + message: "Invalid credentials" + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connecting + +# Call authorize while CONNECTING — should fail +AWAIT client.auth.authorize() FAILS WITH error +ASSERT error.code == 40101 +``` + +### Assertions +```pseudo +# Connection is in FAILED state +ASSERT client.connection.state == ConnectionState.failed +``` + +--- + +## RTC8c - authorize() from DISCONNECTED initiates connection + +**Spec requirement:** If the connection is in the DISCONNECTED, SUSPENDED, FAILED, or CLOSED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token, and RTC8b1 applies. + +Tests that calling `authorize()` from a non-connected state obtains a new token +and initiates a connection. + +### Setup +```pseudo +auth_callback_count = 0 +captured_ws_urls = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + captured_ws_urls.append(conn.url) + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Client starts in INITIALIZED (autoConnect: false, connect() not called) +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Verify client is not connected +ASSERT client.connection.state == ConnectionState.initialized + +# Track state changes +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Call authorize from non-connected state +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-1" + +# Connection is now CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# State transitions included CONNECTING +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] + +# Connection used the token from authorize +ASSERT captured_ws_urls[0].queryParameters["accessToken"] == "token-1" +``` + +--- + +## RTC8c - authorize() from FAILED initiates connection + +**Spec requirement:** If the connection is in the FAILED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token. + +Tests that `authorize()` can recover a FAILED connection by obtaining a new token +and reconnecting. + +### Setup +```pseudo +auth_callback_count = 0 +captured_ws_urls = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + captured_ws_urls.append(conn.url) + conn.respond_with_success() + + IF connection_attempt_count == 1: + # First attempt: fail with fatal error + conn.send_to_client_and_close(ProtocolMessage( + action: ERROR, + error: ErrorInfo( + code: 40101, + statusCode: 401, + message: "Invalid credentials" + ) + )) + ELSE: + # Second attempt (after authorize): succeed + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Connect — will fail +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.failed + +# Track state changes from FAILED onwards +state_changes = [] +client.connection.on((change) => { + state_changes.append(change.current) +}) + +# Call authorize from FAILED state +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-2" + +# Connection recovered to CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# State transitions went through CONNECTING +ASSERT state_changes CONTAINS_IN_ORDER [ + ConnectionState.connecting, + ConnectionState.connected +] + +# Second connection used the new token +ASSERT captured_ws_urls[1].queryParameters["accessToken"] == "token-2" +``` + +--- + +## RTC8c - authorize() from CLOSED initiates connection + +**Spec requirement:** If the connection is in the CLOSED state when auth.authorize is called, after obtaining a token the library should move to the CONNECTING state and initiate a connection attempt using the new token. + +Tests that `authorize()` from CLOSED state opens a new connection. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count = connection_attempt_count + 1 + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id-" + str(connection_attempt_count), + connectionKey: "connection-key-" + str(connection_attempt_count), + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-" + str(connection_attempt_count), + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +# Connect, then close +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +client.close() +AWAIT_STATE client.connection.state == ConnectionState.closed + +# Call authorize from CLOSED state +token_details = AWAIT client.auth.authorize() +``` + +### Assertions +```pseudo +# authorize() completed successfully +ASSERT token_details.token == "token-2" + +# Connection is now CONNECTED again +ASSERT client.connection.state == ConnectionState.connected +``` + +--- + +## Notes + +- **RTC8a4** (tests for both Ably token string and JWT token string) is covered implicitly: all tests above use opaque token strings. For unit tests, token format is irrelevant since tokens are passed through to the server without client-side parsing. Integration tests should verify both formats against the sandbox. +- For token **acquisition** before the initial connection, see `connection_auth_test.md` (RTN2e, RTN27b). +- For server-initiated reauthorization (RTN22), see `connection_failures_test.md`. diff --git a/uts/realtime/unit/connection/server_initiated_reauth_test.md b/uts/realtime/unit/connection/server_initiated_reauth_test.md new file mode 100644 index 000000000..9b2589938 --- /dev/null +++ b/uts/realtime/unit/connection/server_initiated_reauth_test.md @@ -0,0 +1,287 @@ +# Server-Initiated Re-authentication Tests + +Spec points: `RTN22`, `RTN22a` + +## Test Type +Unit test with mocked WebSocket client and authCallback + +## Mock Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Purpose + +These tests verify that when Ably sends an `AUTH` protocol message to a connected client, +the client immediately starts a new authentication process as described in RTC8: it obtains +a new token via the configured auth mechanism and sends an `AUTH` protocol message back to +Ably containing the new token. + +RTN22a covers the fallback: if the client does not re-authenticate within an acceptable +period, Ably forcibly disconnects via a `DISCONNECTED` message with a token error code +(40140–40149), triggering RTN15h token-error recovery. + +--- + +## RTN22 - Server sends AUTH, client re-authenticates + +**Spec requirement:** Ably can request that a connected client re-authenticates by sending the client an `AUTH` ProtocolMessage. The client must then immediately start a new authentication process as described in RTC8. + +Tests that receiving an `AUTH` message from the server triggers the client to obtain a new token and send an `AUTH` message back. + +### Setup +```pseudo +auth_callback_count = 0 +captured_auth_messages = [] + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Record state changes during reauth +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# When client sends AUTH back, record it and respond with CONNECTED (update) +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + captured_auth_messages.append(msg) + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key-2", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key-2", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +}) + +# Server requests re-authentication +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Wait for the UPDATE event that signals reauth completion +AWAIT UNTIL state_changes.any(c => c.event == ConnectionEvent.update) +``` + +### Assertions +```pseudo +# authCallback was called twice: once for initial connect, once for reauth +ASSERT auth_callback_count == 2 + +# Client sent AUTH message back with new token +ASSERT captured_auth_messages.length == 1 +ASSERT captured_auth_messages[0].auth IS NOT null +ASSERT captured_auth_messages[0].auth.accessToken == "token-2" + +# Connection stayed CONNECTED throughout (no state transitions, only UPDATE) +connected_to_other = state_changes.filter(c => c.current != ConnectionState.connected) +ASSERT connected_to_other.length == 0 + +# UPDATE event was emitted (RTN24) +update_events = state_changes.filter(c => c.event == ConnectionEvent.update) +ASSERT update_events.length == 1 +``` + +--- + +## RTN22 - Connection remains CONNECTED during server-initiated reauth + +**Spec requirement:** The re-authentication triggered by the server's AUTH message must follow the RTC8 flow — if the connection is CONNECTED, an AUTH message is sent without disconnecting. + +Tests that the connection state does not change during server-initiated re-authentication. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "reauth-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +# Auto-respond to AUTH with CONNECTED +mock_ws.on_client_message((msg) => { + IF msg.action == AUTH: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-1", + connectionKey: "key-1-updated", + connectionDetails: ConnectionDetails( + connectionKey: "key-1-updated", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) +}) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# Server sends AUTH +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Wait for UPDATE event +AWAIT UNTIL state_changes.length >= 1 +``` + +### Assertions +```pseudo +# Connection never left CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# Only an UPDATE event, no state change events +ASSERT state_changes.length == 1 +ASSERT state_changes[0].event == ConnectionEvent.update +ASSERT state_changes[0].current == ConnectionState.connected +ASSERT state_changes[0].previous == ConnectionState.connected +``` + +--- + +## RTN22a - Forced disconnect on reauth failure + +**Spec requirement:** Ably reserves the right to forcibly disconnect a client that does not re-authenticate within an acceptable period. A client is forcibly disconnected following a `DISCONNECTED` message containing an error code in the range 40140–40149. This forces the client to re-authenticate and resume via RTN15h. + +Tests that when the server sends a `DISCONNECTED` message with a token error code after requesting reauth, the client transitions to DISCONNECTED and initiates token-error recovery. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + RETURN TokenDetails( + token: "recovery-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-1", + connectionKey: "key-1", + connectionDetails: ConnectionDetails( + connectionKey: "key-1", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +state_changes = [] +client.connection.on((change) => { + state_changes.append(change) +}) + +# Server forcibly disconnects with token error (simulating reauth timeout) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: DISCONNECTED, + error: ErrorInfo( + message: "Token expired", + code: 40142, + statusCode: 401 + ) +)) + +# Wait for client to transition to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions +```pseudo +# Client transitioned to DISCONNECTED with the token error +disconnected_change = state_changes.find(c => c.current == ConnectionState.disconnected) +ASSERT disconnected_change IS NOT null +ASSERT disconnected_change.reason.code == 40142 + +# The client should attempt to reconnect (RTN15h token-error recovery +# will obtain a new token and reconnect) +``` + +### Note +The full RTN15h recovery flow (obtain new token, reconnect) is tested in `connection_failures_test.md`. This test only verifies that the forced disconnect with a token error code is handled correctly as the entry point for that recovery. diff --git a/uts/rest/integration/auth.md b/uts/rest/integration/auth.md index eb136b74d..9a0bab544 100644 --- a/uts/rest/integration/auth.md +++ b/uts/rest/integration/auth.md @@ -234,6 +234,103 @@ ASSERT error.code >= 40100 AND error.code < 40200 --- +## RSC10 - Token renewal with expired JWT + +**Spec requirement:** RSC10 - When a REST request fails with a token error (40140-40149), the client should automatically renew the token and retry the request. + +Tests that an expired JWT triggers automatic token renewal via authCallback. + +### Setup +```pseudo +# Track how many times the callback is invoked +callback_count = 0 + +auth_callback = FUNCTION(params): + callback_count = callback_count + 1 + IF callback_count == 1: + # First call: return an already-expired JWT (expired 5 seconds ago) + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + expires_at: now() - 5_seconds + ) + ELSE: + # Subsequent calls: return a valid JWT + RETURN generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + ttl: 3600000 + ) + +channel_name = "test-RSC10-renewal-" + random_id() +client = Rest(options: ClientOptions( + authCallback: auth_callback, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +# Make a REST request — first token is expired, should trigger renewal +result = AWAIT client.request("GET", "/channels/" + channel_name) +``` + +### Assertions +```pseudo +# The request succeeded (token was renewed and retried) +ASSERT result.statusCode >= 200 AND result.statusCode < 300 + +# The authCallback was called twice: once for expired token, once for renewal +ASSERT callback_count == 2 +``` + +--- + +## RSA8 - Capability restriction + +**Spec requirement:** RSA8 - Tokens with restricted capabilities should only allow the permitted operations. + +Tests that a JWT with restricted capability is enforced by the server. + +### Setup +```pseudo +# Create a JWT with capability restricted to a specific channel +allowed_channel = "test-RSA8-cap-allowed-" + random_id() +denied_channel = "test-RSA8-cap-denied-" + random_id() + +jwt = generate_jwt( + key_name: extract_key_name(api_key), + key_secret: extract_key_secret(api_key), + capability: '{"' + allowed_channel + '":["publish","subscribe"]}', + ttl: 3600000 +) + +client = Rest(options: ClientOptions( + token: jwt, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +# Request to allowed channel should succeed +allowed_result = AWAIT client.request("GET", "/channels/" + allowed_channel) + +# Request to denied channel should fail with 40160 (capability refused) +AWAIT client.request("POST", "/channels/" + denied_channel + "/messages", + body: {"name": "test", "data": "hello"} +) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT allowed_result.statusCode >= 200 AND allowed_result.statusCode < 300 +ASSERT error.code == 40160 +ASSERT error.statusCode == 401 +``` + +--- + ## Notes ### Tests moved to unit tests diff --git a/uts/rest/unit/rest_client.md b/uts/rest/unit/rest_client.md index 94ae4893e..4d7720571 100644 --- a/uts/rest/unit/rest_client.md +++ b/uts/rest/unit/rest_client.md @@ -457,6 +457,26 @@ ASSERT client.clientId == client.auth.clientId --- +## RSC17 - ClientId Attribute + +**Spec requirement:** When instantiating a `RestClient`, if a `clientId` attribute is set in `ClientOptions`, then the `Auth#clientId` attribute will contain the provided `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: "appId.keyId:keySecret", + clientId: "explicit-client-id" +)) +``` + +### Assertions +```pseudo +ASSERT client.clientId == "explicit-client-id" +ASSERT client.clientId == client.auth.clientId +``` + +--- + ## RSC18 - TLS configuration **Spec requirement:** The `tls` option controls whether HTTPS (true, default) or HTTP (false) is used for REST requests. From 31e3b3faf24d7100ecee44447f05fcb05f08f26a Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 18/32] Add test specs for realtime presence (RTP) Add comprehensive test specs covering presence enter, leave, update, subscribe, presence map synchronisation, and presence history. --- uts/completion-status.md | 52 +- .../unit/presence/local_presence_map.md | 469 +++++++++ uts/realtime/unit/presence/presence_map.md | 733 +++++++++++++ uts/realtime/unit/presence/presence_sync.md | 496 +++++++++ .../realtime_presence_channel_state.md | 797 ++++++++++++++ .../unit/presence/realtime_presence_enter.md | 979 ++++++++++++++++++ .../unit/presence/realtime_presence_get.md | 479 +++++++++ .../presence/realtime_presence_history.md | 125 +++ .../presence/realtime_presence_reentry.md | 437 ++++++++ .../presence/realtime_presence_subscribe.md | 580 +++++++++++ 10 files changed, 5121 insertions(+), 26 deletions(-) create mode 100644 uts/realtime/unit/presence/local_presence_map.md create mode 100644 uts/realtime/unit/presence/presence_map.md create mode 100644 uts/realtime/unit/presence/presence_sync.md create mode 100644 uts/realtime/unit/presence/realtime_presence_channel_state.md create mode 100644 uts/realtime/unit/presence/realtime_presence_enter.md create mode 100644 uts/realtime/unit/presence/realtime_presence_get.md create mode 100644 uts/realtime/unit/presence/realtime_presence_history.md create mode 100644 uts/realtime/unit/presence/realtime_presence_reentry.md create mode 100644 uts/realtime/unit/presence/realtime_presence_subscribe.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 4ae917734..78b7b8b9a 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -89,7 +89,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSL1 | Publish function (RSL1a–RSL1n1) | Yes — `rest/unit/channel/publish.md`, `rest/integration/publish.md` | | RSL1k | Idempotent publishing (RSL1k1–RSL1k5) | Yes — `rest/unit/channel/idempotency.md` | | RSL2 | History function (RSL2a–RSL2b3) | Yes — `rest/unit/channel/history.md`, `rest/integration/history.md` | -| RSL3 | Presence attribute | | +| RSL3 | Presence attribute | Yes — `rest/unit/presence/rest_presence.md` (with RSP1a) | | RSL4 | Message encoding (RSL4a–RSL4d4) | Yes — `rest/unit/encoding/message_encoding.md` | | RSL5 | Message encryption (RSL5a–RSL5c) | | | RSL6 | Message decoding (RSL6a–RSL6b) | Yes — `rest/unit/encoding/message_encoding.md` | @@ -215,9 +215,9 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL6 | Publish function (RTL6a–RTL6k) | Yes — `realtime/unit/channels/channel_publish.md` | | RTL7 | Subscribe function (RTL7a–RTL7h) | Yes — `realtime/unit/channels/channel_subscribe.md` | | RTL8 | Unsubscribe function (RTL8a–RTL8c) | Yes — `realtime/unit/channels/channel_subscribe.md` | -| RTL9 | Presence attribute (RTL9a) | | +| RTL9 | Presence attribute (RTL9a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTL10 | History function (RTL10a–RTL10d) | Yes — `realtime/unit/channels/channel_history.md` covers RTL10a, RTL10b, RTL10c (proxies to RSL2 tests); `realtime/integration/channel_history_test.md` covers RTL10d | -| RTL11 | Channel state effect on presence (RTL11a) | | +| RTL11 | Channel state effect on presence (RTL11a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTL12 | Additional ATTACHED message handling | | | RTL13 | Server-initiated DETACHED handling (RTL13a–RTL13c) | Yes — `realtime/unit/channels/channel_server_initiated_detach.md` | | RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md` | @@ -242,24 +242,24 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RTP1 | HAS_PRESENCE flag and SYNC | | -| RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | | -| RTP4 | Large member count test | | -| RTP5 | Channel state side effects (RTP5a–RTP5f) | | -| RTP6 | Subscribe function (RTP6a–RTP6e) | | -| RTP7 | Unsubscribe function (RTP7a–RTP7c) | | -| RTP8 | Enter function (RTP8a–RTP8j) | | -| RTP9 | Update function (RTP9a–RTP9e) | | -| RTP10 | Leave function (RTP10a–RTP10e) | | -| RTP11 | Get function (RTP11a–RTP11d) | | -| RTP12 | History function (RTP12a–RTP12d) | | -| RTP13 | SyncComplete attribute | | -| RTP14 | EnterClient function (RTP14a–RTP14d) | | -| RTP15 | EnterClient/UpdateClient/LeaveClient (RTP15a–RTP15f) | | -| RTP16 | Connection state conditions (RTP16a–RTP16c) | | -| RTP17 | Internal PresenceMap (RTP17a–RTP17j) | | -| RTP18 | Server-initiated sync (RTP18a–RTP18c) | | -| RTP19 | PresenceMap cleanup on sync (RTP19a) | | +| RTP1 | HAS_PRESENCE flag and SYNC | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | Yes — `realtime/unit/presence/presence_map.md` | +| RTP4 | Large member count test | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP5 | Channel state side effects (RTP5a–RTP5f) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTP6 | Subscribe function (RTP6a–RTP6e) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md` | +| RTP7 | Unsubscribe function (RTP7a–RTP7c) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md` | +| RTP8 | Enter function (RTP8a–RTP8j) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP9 | Update function (RTP9a–RTP9e) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP10 | Leave function (RTP10a–RTP10e) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP11 | Get function (RTP11a–RTP11d) | Yes — `realtime/unit/presence/realtime_presence_get.md` | +| RTP12 | History function (RTP12a–RTP12d) | Yes — `realtime/unit/presence/realtime_presence_history.md` | +| RTP13 | SyncComplete attribute | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | +| RTP14 | EnterClient function (RTP14a–RTP14d) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP15 | EnterClient/UpdateClient/LeaveClient (RTP15a–RTP15f) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP16 | Connection state conditions (RTP16a–RTP16c) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP17 | Internal PresenceMap (RTP17a–RTP17j) | Partial — `realtime/unit/presence/local_presence_map.md` covers RTP17, RTP17b, RTP17h; `realtime/unit/presence/realtime_presence_reentry.md` covers RTP17a, RTP17e, RTP17g, RTP17g1, RTP17i | +| RTP18 | Server-initiated sync (RTP18a–RTP18c) | Yes — `realtime/unit/presence/presence_sync.md` | +| RTP19 | PresenceMap cleanup on sync (RTP19a) | Yes — `realtime/unit/presence/presence_sync.md`, `realtime/unit/presence/realtime_presence_channel_state.md` | ### RealtimeAnnotations @@ -315,7 +315,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati |-----------|-------------|---------------| | TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5 | | DE1–DE2 | DeltaExtras | | -| TP1–TP5 | PresenceMessage | | +| TP1–TP5 | PresenceMessage | Yes — `rest/unit/types/presence_message_types.md` | | OM1–OM5 | ObjectMessage | | | OOP1–OOP5 | ObjectOperation | | | OST1–OST3 | ObjectState | | @@ -394,22 +394,22 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **REST client** (RSC) | 18 | 15 | Partial | | **REST auth** (RSA) | 15 | 15 | Full | | **REST channels** (RSN) | 4 | 0 | None | -| **REST channel** (RSL) | 13 | 6 | Partial | +| **REST channel** (RSL) | 13 | 7 | Partial | | **REST presence** (RSP) | 5 | 4 | Mostly | | **REST encryption** (RSE) | 2 | 0 | None | | **REST annotations** (RSAN) | 3 | 0 | None | | **Realtime client** (RTC) | 14 | 12 | Partial | | **Connection** (RTN) | 23 | 17 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | -| **Realtime channel** (RTL) | 24 | 14 | Partial | -| **Realtime presence** (RTP) | 15 | 0 | None | +| **Realtime channel** (RTL) | 24 | 16 | Partial | +| **Realtime presence** (RTP) | 15 | 15 | Full | | **Realtime annotations** (RTAN) | 5 | 0 | None | | **EventEmitter** (RTE) | 6 | 0 | None | | **Backoff/jitter** (RTB) | 1 | 0 | None | | **Wrapper SDK** (WP) | 7 | 0 | None | | **Push notifications** (RSH) | 8 | 0 | None | | **Plugins** (PC/PT/VD) | 3 | 0 | None | -| **Data types** | 30 | 7 | Partial | +| **Data types** | 30 | 8 | Partial | | **Option types** | 8 | 5 | Partial | | **Push types** | 3 | 0 | None | | **Introspection** (CR) | 1 | 0 | None | diff --git a/uts/realtime/unit/presence/local_presence_map.md b/uts/realtime/unit/presence/local_presence_map.md new file mode 100644 index 000000000..eacdf189a --- /dev/null +++ b/uts/realtime/unit/presence/local_presence_map.md @@ -0,0 +1,469 @@ +# LocalPresenceMap Tests + +Spec points: `RTP17`, `RTP17b`, `RTP17h` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +Tests the `LocalPresenceMap` (internal PresenceMap per RTP17) that maintains a map of +members entered by the current connection. This map is used for automatic re-entry +(RTP17i, RTP17g) when the channel reattaches. + +Key differences from the main PresenceMap: +- Keyed by `clientId` only (RTP17h), not by `memberKey` (`connectionId:clientId`) +- Only stores members matching the current `connectionId` (RTP17b) +- Applies ENTER, PRESENT, UPDATE, and non-synthesized LEAVE events (RTP17b) +- Ignores synthesized LEAVE events — where connectionId is not a prefix of id (RTP17b, per RTP2b1) +- No sync protocol (startSync/endSync) — that is only on the main PresenceMap +- No newness comparison — entries are simply overwritten + +## Interface Under Test + +``` +LocalPresenceMap: + put(message: PresenceMessage) + remove(message: PresenceMessage) -> bool # returns true if removed, false if synthesized leave (ignored) + get(clientId: String) -> PresenceMessage? + values() -> List + clear() +``` + +--- + +## RTP17h - Keyed by clientId, not memberKey + +**Spec requirement:** Unlike the main PresenceMap (keyed by memberKey), the RTP17 +PresenceMap must be keyed only by clientId. Otherwise, entries associated with old +connectionIds would never be removed, even if the user deliberately leaves presence. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +msg1 = PresenceMessage( + action: ENTER, + clientId: "user-1", + connectionId: "conn-A", + id: "conn-A:0:0", + timestamp: 1000, + data: "first" +) +msg2 = PresenceMessage( + action: ENTER, + clientId: "user-1", + connectionId: "conn-B", + id: "conn-B:0:0", + timestamp: 2000, + data: "second" +) + +map.put(msg1) +map.put(msg2) +``` + +### Assertions +```pseudo +# Only one entry — keyed by clientId, second put overwrites the first +ASSERT map.values().length == 1 +ASSERT map.get("user-1") IS NOT null +ASSERT map.get("user-1").data == "second" +ASSERT map.get("user-1").connectionId == "conn-B" +``` + +--- + +## RTP17b - ENTER adds to map + +**Spec requirement:** Any ENTER event with a connectionId matching the current client's +connectionId should be applied to the RTP17 presence map. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "hello" +)) +``` + +### Assertions +```pseudo +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").action == ENTER +ASSERT map.get("client-1").data == "hello" +ASSERT map.values().length == 1 +``` + +--- + +## RTP17b - UPDATE with no prior entry adds to map + +**Spec requirement:** ENTER and UPDATE are interchangeable — both add a member to the +map. An UPDATE on a clientId that has no prior entry behaves identically to an ENTER. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "from-update" +)) +``` + +### Assertions +```pseudo +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").action == UPDATE +ASSERT map.get("client-1").data == "from-update" +ASSERT map.values().length == 1 +``` + +--- + +## RTP17b - ENTER after ENTER overwrites + +**Spec requirement:** ENTER and UPDATE are interchangeable. A second ENTER for the same +clientId overwrites the first, just as an UPDATE would. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "first" +)) + +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "second" +)) +``` + +### Assertions +```pseudo +ASSERT map.values().length == 1 +ASSERT map.get("client-1").action == ENTER +ASSERT map.get("client-1").data == "second" +``` + +--- + +## RTP17b - UPDATE after ENTER overwrites + +**Spec requirement:** UPDATE overwrites a prior ENTER for the same clientId. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "initial" +)) + +map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "updated" +)) +``` + +### Assertions +```pseudo +ASSERT map.values().length == 1 +ASSERT map.get("client-1").action == UPDATE +ASSERT map.get("client-1").data == "updated" +``` + +--- + +## RTP17b - PRESENT adds to map + +**Spec requirement:** Any PRESENT event with a matching connectionId should be applied. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "present" +)) +``` + +### Assertions +```pseudo +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").action == PRESENT +ASSERT map.get("client-1").data == "present" +``` + +--- + +## RTP17b - Non-synthesized LEAVE removes from map + +**Spec requirement:** Any LEAVE event with a connectionId matching the current client's +connectionId that is NOT a synthesized leave should remove the member. + +A non-synthesized leave has a connectionId that IS an initial substring of its id +(normal server-delivered leave, e.g. id="conn-1:1:0" starts with connectionId="conn-1"). + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +# Add member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +ASSERT map.get("client-1") IS NOT null + +# Non-synthesized LEAVE: connectionId "conn-1" IS an initial substring of id "conn-1:1:0" +result = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +ASSERT result == true +ASSERT map.get("client-1") IS null +ASSERT map.values().length == 0 +``` + +--- + +## RTP17b - Synthesized LEAVE is ignored + +**Spec requirement:** A synthesized leave event (where connectionId is NOT an initial +substring of its id, per RTP2b1) should NOT be applied to the RTP17 presence map. +The remove method checks whether the connectionId is a prefix of the message id. +If it is not, the leave is synthesized and the member must NOT be removed. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +# Add member +map.put(PresenceMessage( + 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" +result = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-leave-id", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# remove returns false — synthesized leave was ignored +ASSERT result == false + +# Member is still present +ASSERT map.get("client-1") IS NOT null +ASSERT map.get("client-1").data == "entered" +ASSERT map.values().length == 1 +``` + +--- + +## RTP17 - Multiple clientIds coexist + +**Spec requirement:** The local presence map can contain multiple members with different +clientIds (e.g., when a single connection enters presence with multiple clientIds using +enterClient). + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100, data: "alice-data")) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "conn-1", id: "conn-1:0:1", timestamp: 100, data: "bob-data")) +map.put(PresenceMessage(action: ENTER, clientId: "carol", connectionId: "conn-1", id: "conn-1:0:2", timestamp: 100, data: "carol-data")) +``` + +### Assertions +```pseudo +ASSERT map.values().length == 3 +ASSERT map.get("alice") IS NOT null +ASSERT map.get("bob") IS NOT null +ASSERT map.get("carol") IS NOT null +ASSERT map.get("alice").data == "alice-data" +ASSERT map.get("bob").data == "bob-data" +ASSERT map.get("carol").data == "carol-data" +``` + +--- + +## RTP17 - Remove one of multiple members + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "conn-1", id: "conn-1:0:1", timestamp: 100)) + +map.remove(PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "conn-1", id: "conn-1:1:0", timestamp: 200)) +``` + +### Assertions +```pseudo +ASSERT map.get("alice") IS null +ASSERT map.get("bob") IS NOT null +ASSERT map.values().length == 1 +``` + +--- + +## clear() resets all state + +**Spec requirement (RTP5a):** When the channel enters DETACHED or FAILED state, the +internal PresenceMap is cleared. This ensures members are not automatically re-entered +if the channel later becomes attached. + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "conn-1", id: "conn-1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "conn-1", id: "conn-1:0:1", timestamp: 100)) + +ASSERT map.values().length == 2 + +map.clear() +``` + +### Assertions +```pseudo +ASSERT map.values().length == 0 +ASSERT map.get("alice") IS null +ASSERT map.get("bob") IS null +``` + +--- + +## RTP17 - Get returns null for unknown clientId + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +result = map.get("nonexistent") +``` + +### Assertions +```pseudo +ASSERT result IS null +``` + +--- + +## RTP17 - Remove for unknown clientId is a no-op + +### Setup +```pseudo +map = LocalPresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(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(PresenceMessage(action: LEAVE, clientId: "nonexistent", connectionId: "conn-1", id: "conn-1:1:0", timestamp: 200)) +``` + +### Assertions +```pseudo +# Original member is unaffected +ASSERT map.get("alice") IS NOT null +ASSERT map.values().length == 1 +``` diff --git a/uts/realtime/unit/presence/presence_map.md b/uts/realtime/unit/presence/presence_map.md new file mode 100644 index 000000000..99d860c98 --- /dev/null +++ b/uts/realtime/unit/presence/presence_map.md @@ -0,0 +1,733 @@ +# PresenceMap Tests + +Spec points: `RTP2`, `RTP2a`, `RTP2b`, `RTP2b1`, `RTP2b1a`, `RTP2b2`, `RTP2c`, `RTP2d`, `RTP2d1`, `RTP2d2`, `RTP2h`, `RTP2h1`, `RTP2h1a`, `RTP2h1b`, `RTP2h2`, `RTP2h2a`, `RTP2h2b` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +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). + +This is a portable data structure test — no WebSocket, connection, or channel infrastructure +is needed. Tests operate directly on the PresenceMap by calling `put()` and `remove()` with +constructed `PresenceMessage` objects. + +## Interface Under Test + +``` +PresenceMap: + put(message: PresenceMessage) -> PresenceMessage? # returns message to emit, or null if stale + remove(message: PresenceMessage) -> PresenceMessage? # returns LEAVE to emit, or null + get(memberKey: String) -> PresenceMessage? + values() -> List # only PRESENT members + clear() + startSync() + endSync() -> List # returns synthesized LEAVE events + isSyncInProgress -> bool +``` + +--- + +## RTP2 - Basic put and get + +**Spec requirement:** Use a PresenceMap to maintain a list of members present on a channel, +a map of memberKeys to presence messages. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +msg = PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +) +result = map.put(msg) +``` + +### Assertions +```pseudo +ASSERT result IS NOT null +ASSERT map.get("conn-1:client-1") IS NOT null +ASSERT map.get("conn-1:client-1").clientId == "client-1" +ASSERT map.get("conn-1:client-1").connectionId == "conn-1" +``` + +--- + +## RTP2d2 - ENTER stored as PRESENT + +**Spec requirement:** When an ENTER, UPDATE, or PRESENT message is received, add to the +presence map with action set to PRESENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +enter_msg = PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "entered" +) +map.put(enter_msg) +``` + +### Assertions +```pseudo +stored = map.get("conn-1:client-1") +ASSERT stored IS NOT null +ASSERT stored.action == PRESENT # RTP2d2: stored as PRESENT regardless of original action +ASSERT stored.data == "entered" +``` + +--- + +## RTP2d2 - UPDATE stored as PRESENT + +**Spec requirement:** UPDATE messages are also stored with action PRESENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# First enter +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000, + data: "initial" +)) + +# Then update +map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "updated" +)) +``` + +### Assertions +```pseudo +stored = map.get("conn-1:client-1") +ASSERT stored.action == PRESENT +ASSERT stored.data == "updated" +``` + +--- + +## RTP2d2 - PRESENT stored as PRESENT + +**Spec requirement:** PRESENT messages (from SYNC) are stored with action PRESENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) +``` + +### Assertions +```pseudo +stored = map.get("conn-1:client-1") +ASSERT stored IS NOT null +ASSERT stored.action == PRESENT +``` + +--- + +## RTP2d1 - put returns message with original action + +**Spec requirement:** Emit to subscribers with the original action (ENTER, UPDATE, or PRESENT), +not the stored PRESENT action. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +emitted_enter = map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +emitted_update = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000, + data: "updated" +)) +``` + +### Assertions +```pseudo +ASSERT emitted_enter IS NOT null +ASSERT emitted_enter.action == ENTER # Original action preserved for emission + +ASSERT emitted_update IS NOT null +ASSERT emitted_update.action == UPDATE # Original action preserved for emission +``` + +--- + +## RTP2h1 - LEAVE outside sync removes member + +**Spec requirement:** When a LEAVE message is received and SYNC is NOT in progress, +emit LEAVE and delete from presence map. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add a member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +# Remove the member +emitted = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# RTP2h1a: Emit LEAVE to subscribers +ASSERT emitted IS NOT null +ASSERT emitted.action == LEAVE + +# RTP2h1b: Delete from presence map +ASSERT map.get("conn-1:client-1") IS null +ASSERT map.values().length == 0 +``` + +--- + +## RTP2h1 - LEAVE for non-existent member returns null + +**Spec requirement:** If there is no matching memberKey in the map, there is nothing to remove. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +emitted = map.remove(PresenceMessage( + action: LEAVE, + clientId: "unknown", + connectionId: "conn-x", + id: "conn-x:0:0", + timestamp: 1000 +)) +``` + +### Assertions +```pseudo +ASSERT emitted IS null +``` + +--- + +## RTP2h2a - LEAVE during sync stores as ABSENT + +**Spec requirement:** If a SYNC is in progress and a LEAVE message is received, +store the member in the presence map with action set to ABSENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add a member +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 +)) + +# Start sync +map.startSync() + +# LEAVE during sync +emitted = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:1:0", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# No LEAVE emitted during sync +ASSERT emitted IS null + +# Member is stored as ABSENT (not deleted) +stored = map.get("conn-1:client-1") +ASSERT stored IS NOT null +ASSERT stored.action == ABSENT +``` + +--- + +## RTP2h2b - ABSENT members deleted on endSync + +**Spec requirement:** When SYNC completes, delete all members with action ABSENT. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add two members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(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(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob sends LEAVE during sync (stored as ABSENT) +map.remove(PresenceMessage(action: LEAVE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200)) + +# End sync +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob's ABSENT entry was deleted +ASSERT map.get("c2:bob") IS null + +# Alice remains +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c1:alice").action == PRESENT + +ASSERT map.values().length == 1 +``` + +--- + +## RTP2b2 - Newness comparison by id (msgSerial:index) + +**Spec requirement:** When the connectionId IS an initial substring of the message id, +split the id into `connectionId:msgSerial:index` and compare msgSerial then index numerically. +Larger values are newer. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add initial message with msgSerial=5, index=0 +map.put(PresenceMessage( + 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) +stale_result = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:3:0", + timestamp: 2000, + data: "stale" +)) + +# Put a newer message (msgSerial=7) +newer_result = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:7:0", + timestamp: 500, + data: "newer" +)) +``` + +### Assertions +```pseudo +# Stale message rejected (RTP2a) +ASSERT stale_result IS null +ASSERT map.get("conn-1:client-1").data == "first" + +# Newer message accepted (even though timestamp is older) +ASSERT newer_result IS NOT null +ASSERT map.get("conn-1:client-1").data == "newer" +``` + +--- + +## RTP2b2 - Newness comparison by index when msgSerial equal + +**Spec requirement:** When msgSerial values are equal, compare by index. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:2", + timestamp: 1000, + data: "index-2" +)) + +# Same msgSerial, lower index — stale +stale = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:1", + timestamp: 2000, + data: "index-1" +)) + +# Same msgSerial, higher index — newer +newer = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:5:5", + timestamp: 500, + data: "index-5" +)) +``` + +### Assertions +```pseudo +ASSERT stale IS null +ASSERT newer IS NOT null +ASSERT map.get("conn-1:client-1").data == "index-5" +``` + +--- + +## RTP2b1 - Newness comparison by timestamp (synthesized leave) + +**Spec requirement:** If either message has a connectionId which is NOT an initial substring +of its id, compare by timestamp. This handles "synthesized leave" events where the server +generates a LEAVE on behalf of a disconnected client. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Add member with normal id (connectionId is prefix of id) +map.put(PresenceMessage( + 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 +# (server-generated, uses a different id format) +synth_leave = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-leave-id", + timestamp: 2000 +)) +``` + +### Assertions +```pseudo +# Timestamp 2000 > 1000, so the synthesized leave is newer +ASSERT synth_leave IS NOT null +ASSERT synth_leave.action == LEAVE +ASSERT map.get("conn-1:client-1") IS null +``` + +--- + +## RTP2b1 - Synthesized leave rejected when older by timestamp + +**Spec requirement:** When comparing by timestamp, an older synthesized leave is rejected. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 5000, + data: "entered" +)) + +# Synthesized leave with older timestamp +result = map.remove(PresenceMessage( + action: LEAVE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-leave-id", + timestamp: 3000 +)) +``` + +### Assertions +```pseudo +# Rejected — existing message (timestamp 5000) is newer +ASSERT result IS null +ASSERT map.get("conn-1:client-1") IS NOT null +ASSERT map.get("conn-1:client-1").data == "entered" +``` + +--- + +## RTP2b1a - Equal timestamps: incoming message is newer + +**Spec requirement:** If timestamps are equal, the newly-incoming message is considered newer. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-id-1", + timestamp: 1000, + data: "first" +)) + +# Same timestamp, incoming wins +result = map.put(PresenceMessage( + action: UPDATE, + clientId: "client-1", + connectionId: "conn-1", + id: "synthesized-id-2", + timestamp: 1000, + data: "second" +)) +``` + +### Assertions +```pseudo +ASSERT result IS NOT null +ASSERT map.get("conn-1:client-1").data == "second" +``` + +--- + +## RTP2c - SYNC messages use same newness comparison + +**Spec requirement:** Presence events from a SYNC must be compared for newness +the same way as PRESENCE messages. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.startSync() + +# First SYNC message +map.put(PresenceMessage( + 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 +stale = map.put(PresenceMessage( + 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 +newer = map.put(PresenceMessage( + action: PRESENT, + clientId: "client-1", + connectionId: "conn-1", + id: "conn-1:8:0", + timestamp: 500, + data: "sync-newer" +)) +``` + +### Assertions +```pseudo +ASSERT stale IS null +ASSERT newer IS NOT null +ASSERT map.get("conn-1:client-1").data == "sync-newer" +``` + +--- + +## RTP2 - Multiple members coexist + +**Spec requirement:** The presence map maintains multiple members with different memberKeys. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c3", id: "c3:0:0", timestamp: 100)) +``` + +### Assertions +```pseudo +# Three distinct members (alice on c1, bob on c2, alice on c3) +ASSERT map.values().length == 3 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c3:alice") IS NOT null +``` + +--- + +## RTP2 - values() excludes ABSENT members + +**Spec requirement:** The values() method returns only PRESENT members. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# Start sync and mark bob as ABSENT +map.startSync() +map.remove(PresenceMessage(action: LEAVE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200)) +``` + +### Assertions +```pseudo +# Bob is stored as ABSENT but excluded from values() +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c2:bob").action == ABSENT + +members = map.values() +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +``` + +--- + +## clear() resets all state + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.startSync() + +map.clear() +``` + +### Assertions +```pseudo +ASSERT map.values().length == 0 +ASSERT map.get("c1:alice") IS null +ASSERT map.isSyncInProgress == false +``` diff --git a/uts/realtime/unit/presence/presence_sync.md b/uts/realtime/unit/presence/presence_sync.md new file mode 100644 index 000000000..6c0f6fd60 --- /dev/null +++ b/uts/realtime/unit/presence/presence_sync.md @@ -0,0 +1,496 @@ +# Presence Sync Tests + +Spec points: `RTP18`, `RTP18a`, `RTP18b`, `RTP18c`, `RTP19`, `RTP19a` + +## Test Type +Unit test — pure data structure, no mocks required. + +## Purpose + +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: +1. `startSync()` — marks existing members as potentially stale (residual) +2. `put()` during sync — marks members as current (removes from residual set) +3. `endSync()` — removes stale members not seen during sync, returns synthesized LEAVE events + +These tests operate directly on the PresenceMap, verifying the sync lifecycle without +any WebSocket, connection, or channel infrastructure. + +## Interface Under Test + +``` +PresenceMap: + put(message: PresenceMessage) -> PresenceMessage? + remove(message: PresenceMessage) -> PresenceMessage? + get(memberKey: String) -> PresenceMessage? + values() -> List + clear() + startSync() + endSync() -> List # returns synthesized LEAVE events for stale members + isSyncInProgress -> bool +``` + +--- + +## RTP18a - startSync sets isSyncInProgress + +**Spec requirement:** A new sync has started. The client library must track that a sync +is in progress. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +ASSERT map.isSyncInProgress == false + +map.startSync() +``` + +### Assertions +```pseudo +ASSERT map.isSyncInProgress == true +``` + +--- + +## RTP18b - endSync clears isSyncInProgress + +**Spec requirement:** The sync operation has completed once the cursor is empty. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.startSync() +ASSERT map.isSyncInProgress == true + +map.endSync() +``` + +### Assertions +```pseudo +ASSERT map.isSyncInProgress == false +``` + +--- + +## RTP19 - Stale members get LEAVE events after sync + +**Spec requirement:** If the PresenceMap has existing members when a SYNC is started, +members no longer present on the channel are removed from the local PresenceMap once +the sync is complete. A LEAVE event should be emitted for each removed member. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with two members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +ASSERT map.values().length == 2 + +# Start sync — only alice appears in the sync data +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# End sync — bob was not updated, gets removed +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob gets a synthesized LEAVE +ASSERT leave_events.length == 1 +ASSERT leave_events[0].clientId == "bob" +ASSERT leave_events[0].action == LEAVE + +# Only alice remains +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS null +``` + +--- + +## RTP19 - Synthesized LEAVE has id=null and current timestamp + +**Spec requirement:** The PresenceMessage emitted should contain the original attributes +of the presence member with the action set to LEAVE, PresenceMessage#id set to null, +and the timestamp set to the current time. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage( + action: ENTER, + clientId: "bob", + connectionId: "c2", + id: "c2:0:0", + timestamp: 100, + data: "bob-data" +)) + +before_time = NOW() + +map.startSync() +# No messages for bob during sync +leave_events = map.endSync() + +after_time = NOW() +``` + +### Assertions +```pseudo +ASSERT leave_events.length == 1 + +leave = leave_events[0] +ASSERT leave.action == LEAVE +ASSERT leave.clientId == "bob" +ASSERT leave.connectionId == "c2" +ASSERT leave.data == "bob-data" # Original attributes preserved +ASSERT leave.id IS null # RTP19: id set to null +ASSERT leave.timestamp >= before_time # RTP19: timestamp set to current time +ASSERT leave.timestamp <= after_time +``` + +--- + +## RTP19 - Members updated during sync survive + +**Spec requirement:** A member can be added or updated when received in a SYNC message +or when received in a PRESENCE message during the sync process. Members that have been +added or updated should NOT be removed. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with three members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "carol", connectionId: "c3", id: "c3:0:0", timestamp: 100)) + +map.startSync() + +# Alice arrives via SYNC (PRESENT action) +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob arrives via PRESENCE during sync (UPDATE action) +map.put(PresenceMessage(action: UPDATE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200, data: "new-data")) + +# Carol does NOT appear during sync + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Only carol is stale +ASSERT leave_events.length == 1 +ASSERT leave_events[0].clientId == "carol" + +# Alice and bob survive +ASSERT map.values().length == 2 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c2:bob").data == "new-data" +``` + +--- + +## RTP18a - New sync discards previous in-flight sync + +**Spec requirement:** If a new sequence identifier is sent from Ably, then the client +library must consider that to be the start of a new sync sequence and any previous +in-flight sync should be discarded. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# First sync starts — only alice appears +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Before first sync ends, a NEW sync starts (new sequence identifier) +# This discards the previous sync — bob is no longer marked as residual from the first sync +map.startSync() + +# In the new sync, both alice and bob appear +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 300)) +map.put(PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 300)) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# No stale members — both were seen in the new sync +ASSERT leave_events.length == 0 + +ASSERT map.values().length == 2 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +``` + +--- + +## RTP18c - Single-message sync (no channelSerial) + +**Spec requirement:** A SYNC may also be sent with no channelSerial attribute. In this +case, the sync data is entirely contained within that ProtocolMessage. This is modeled +as a startSync + put + endSync in one step. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with alice and bob +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +# Single-message sync: start, put one member, end immediately +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob was not in the sync — gets LEAVE +ASSERT leave_events.length == 1 +ASSERT leave_events[0].clientId == "bob" +ASSERT leave_events[0].action == LEAVE + +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.isSyncInProgress == false +``` + +--- + +## RTP19a - ATTACHED without HAS_PRESENCE clears all members + +**Spec requirement:** If the PresenceMap has existing members when an ATTACHED message +is received without a HAS_PRESENCE flag, emit a LEAVE event for each existing member +and remove all members from the PresenceMap. + +Note: The detection of HAS_PRESENCE is handled by the RealtimeChannel, which calls +PresenceMap methods. At the data structure level, this scenario is equivalent to +startSync() followed immediately by endSync() with no puts — all existing members +become stale and are removed. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with members +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "a")) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100, data: "b")) +map.put(PresenceMessage(action: ENTER, clientId: "carol", connectionId: "c3", id: "c3:0:0", timestamp: 100, data: "c")) + +# No HAS_PRESENCE: immediate sync with no members +map.startSync() +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# All members get LEAVE events +ASSERT leave_events.length == 3 + +# Verify each leave preserves original attributes +alice_leave = leave_events.find(e => e.clientId == "alice") +bob_leave = leave_events.find(e => e.clientId == "bob") +carol_leave = leave_events.find(e => e.clientId == "carol") + +ASSERT alice_leave IS NOT null +ASSERT alice_leave.action == LEAVE +ASSERT alice_leave.data == "a" +ASSERT alice_leave.id IS null + +ASSERT bob_leave IS NOT null +ASSERT bob_leave.action == LEAVE +ASSERT bob_leave.data == "b" +ASSERT bob_leave.id IS null + +ASSERT carol_leave IS NOT null +ASSERT carol_leave.action == LEAVE +ASSERT carol_leave.data == "c" +ASSERT carol_leave.id IS null + +# Map is empty +ASSERT map.values().length == 0 +``` + +--- + +## RTP2h2a - LEAVE during sync stored as ABSENT (in sync context) + +**Spec requirement:** If a SYNC is in progress and a LEAVE message is received, store +the member with action set to ABSENT. On endSync, ABSENT members are deleted (RTP2h2b). + +This test verifies the interaction between LEAVE-during-sync and endSync cleanup. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100)) + +map.startSync() + +# Alice appears in sync +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob sends LEAVE during sync — stored as ABSENT, not emitted yet +leave_result = map.remove(PresenceMessage(action: LEAVE, clientId: "bob", connectionId: "c2", id: "c2:1:0", timestamp: 200)) + +# Verify bob is ABSENT but still in map +ASSERT leave_result IS null +ASSERT map.get("c2:bob") IS NOT null +ASSERT map.get("c2:bob").action == ABSENT + +# End sync +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# Bob's ABSENT entry is cleaned up — no additional LEAVE emitted since +# bob was explicitly marked ABSENT (not stale-by-absence-from-sync) +# Implementation note: ABSENT members are simply deleted on endSync. +# The stale-member LEAVE events are only for members that were PRESENT +# but not updated during sync. +ASSERT map.get("c2:bob") IS null + +# Alice survives +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +``` + +--- + +## RTP19 - Empty map sync produces no leave events + +**Spec requirement:** If there are no existing members when sync starts, endSync +produces no leave events. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.startSync() +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) +leave_events = map.endSync() +``` + +### Assertions +```pseudo +ASSERT leave_events.length == 0 +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +``` + +--- + +## RTP18 - endSync without startSync is a no-op + +**Spec requirement:** Calling endSync when no sync is in progress should not +corrupt the map state. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) + +# endSync without startSync +leave_events = map.endSync() +``` + +### Assertions +```pseudo +ASSERT leave_events.length == 0 +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.isSyncInProgress == false +``` + +--- + +## RTP19 - New member added during sync is not stale + +**Spec requirement:** A member can be added during the sync process. New members +that did not exist before the sync should survive endSync. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with alice only +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100)) + +map.startSync() + +# Alice appears in sync +map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 200)) + +# Bob is NEW — entered via PRESENCE message during sync (not from SYNC data) +map.put(PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 200)) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# No leave events — both alice and bob are current +ASSERT leave_events.length == 0 +ASSERT map.values().length == 2 +ASSERT map.get("c1:alice") IS NOT null +ASSERT map.get("c2:bob") IS NOT null +``` diff --git a/uts/realtime/unit/presence/realtime_presence_channel_state.md b/uts/realtime/unit/presence/realtime_presence_channel_state.md new file mode 100644 index 000000000..21d43c7bd --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_channel_state.md @@ -0,0 +1,797 @@ +# RealtimePresence Channel State Tests + +Spec points: `RTL9`, `RTL9a`, `RTL11`, `RTL11a`, `RTP1`, `RTP5`, `RTP5a`, `RTP5b`, `RTP5f`, `RTP13` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests the interaction between channel state transitions and presence. Covers the +HAS_PRESENCE flag triggering a sync, channel state side effects on presence maps, +the syncComplete attribute, the RealtimeChannel#presence attribute (RTL9), and +channel state effects on queued presence actions (RTL11). + +--- + +## RTP1 - HAS_PRESENCE flag triggers sync + +**Spec requirement:** When a channel ATTACHED ProtocolMessage is received with the +HAS_PRESENCE flag set, the server will perform a SYNC operation. If the flag is 0 +or absent, the presence map should be considered in sync immediately with no members. + +### Setup +```pseudo +channel_name = "test-RTP1-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Server follows up with SYNC + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Wait for sync to complete +members = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +ASSERT channel.presence.syncComplete == true +``` + +--- + +## RTP1 - No HAS_PRESENCE flag means empty presence + +**Spec requirement:** If the flag is 0 or absent, the presence map should be considered +in sync immediately with no members present on the channel. + +### Setup +```pseudo +channel_name = "test-RTP1-empty-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # No HAS_PRESENCE flag + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +ASSERT members.length == 0 +ASSERT channel.presence.syncComplete == true # Immediately in sync +``` + +--- + +## RTP1, RTP19a - No HAS_PRESENCE clears existing members + +**Spec requirement (RTP19a):** If the PresenceMap has existing members when an ATTACHED +message is received without a HAS_PRESENCE flag, emit a LEAVE event for each existing +member and remove all members from the PresenceMap. + +### Setup +```pseudo +channel_name = "test-RTP19a-${random_id()}" + +connection_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + IF connection_count == 1: + # First attach: has presence + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] + )) + ELSE: + # Second attach: no HAS_PRESENCE + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Verify members exist after first sync +members = AWAIT channel.presence.get() +ASSERT members.length == 2 + +# Track LEAVE events +leave_events = [] +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Simulate disconnect and reconnect +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Reconnect — this time ATTACHED without HAS_PRESENCE +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached + +members_after = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +# All members removed +ASSERT members_after.length == 0 + +# LEAVE events emitted for each member +ASSERT leave_events.length == 2 +ASSERT leave_events.any(e => e.clientId == "alice") +ASSERT leave_events.any(e => e.clientId == "bob") + +# LEAVE events have id=null per RTP19a +ASSERT leave_events.every(e => e.id IS null) +``` + +--- + +## RTP5a - DETACHED clears both presence maps + +**Spec requirement:** If the channel enters the DETACHED state, all queued presence +messages fail immediately, and both the PresenceMap and internal PresenceMap (RTP17) +are cleared. LEAVE events should NOT be emitted when clearing. + +### Setup +```pseudo +channel_name = "test-RTP5a-detached-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Verify member exists +members = AWAIT channel.presence.get() +ASSERT members.length == 1 + +# Track events — LEAVE should NOT be emitted on clear +leave_events = [] +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Detach the channel +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached +``` + +### Assertions +```pseudo +# RTP5a: No LEAVE events emitted when clearing on DETACHED +ASSERT leave_events.length == 0 + +# Presence map is cleared +members_after = channel.presence.get(waitForSync: false) +ASSERT members_after.length == 0 +``` + +--- + +## RTP5a - FAILED clears both presence maps + +**Spec requirement:** Same as DETACHED — FAILED state clears both maps, no LEAVE emitted. + +### Setup +```pseudo +channel_name = "test-RTP5a-failed-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get() +ASSERT members.length == 1 + +leave_events = [] +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Server sends channel ERROR to put channel in FAILED state +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel failed") +)) + +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# RTP5a: No LEAVE events emitted +ASSERT leave_events.length == 0 +``` + +--- + +## RTP5b - ATTACHED sends queued presence messages + +**Spec requirement:** If a channel enters the ATTACHED state then all queued presence +messages will be sent immediately. + +### Setup +```pseudo +channel_name = "test-RTP5b-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Delay attach response + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — channel goes to ATTACHING +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence while channel is ATTACHING +enter_future = channel.presence.enter(data: "queued") + +# No presence sent yet +ASSERT captured_presence.length == 0 + +# Complete the attach +mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + +AWAIT enter_future +``` + +### Assertions +```pseudo +# Queued presence was sent after attach completed +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].data == "queued" +``` + +--- + +## RTP5f - SUSPENDED maintains presence map + +**Spec requirement:** If the channel enters SUSPENDED, all queued presence messages fail +immediately, but the PresenceMap is maintained. This ensures that when the channel later +becomes ATTACHED, it will only emit presence events for changes that occurred while +disconnected. + +### Setup +```pseudo +channel_name = "test-RTP5f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get() +ASSERT members.length == 2 + +# Channel becomes SUSPENDED (e.g., connection transitions to SUSPENDED) +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended + +# PresenceMap is maintained during SUSPENDED +members_during_suspended = channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +# Members still exist in the map +ASSERT members_during_suspended.length == 2 +``` + +--- + +## RTP13 - syncComplete attribute + +**Spec requirement:** RealtimePresence#syncComplete is true if the initial SYNC +operation has completed for the members present on the channel. + +### Setup +```pseudo +channel_name = "test-RTP13-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Start multi-message SYNC (cursor is non-empty) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:cursor1", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Sync is in progress — not yet complete +ASSERT channel.presence.syncComplete == false + +# Complete the sync (empty cursor) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] +)) +``` + +### Assertions +```pseudo +ASSERT channel.presence.syncComplete == true +``` + +--- + +## RTL9, RTL9a - RealtimeChannel#presence attribute + +**Spec requirement (RTL9):** `RealtimeChannel#presence` attribute. +**Spec requirement (RTL9a):** Returns the `RealtimePresence` object for this channel. + +### Setup +```pseudo +channel_name = "test-RTL9a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => {} +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +presence = channel.presence +``` + +### Assertions +```pseudo +ASSERT presence IS RealtimePresence +ASSERT presence IS NOT null +``` + +### RTL9a - Same presence object returned for same channel + +```pseudo +ASSERT channel.presence === channel.presence # identity check — same instance +``` + +--- + +## RTL11 - Queued presence actions fail on DETACHED + +**Spec requirement (RTL11):** If a channel enters the DETACHED, SUSPENDED or FAILED +state, then all presence actions that are still queued for send on that channel per +RTP16b should be deleted from the queue, and any callback passed to the corresponding +presence method invocation should be called with an ErrorInfo indicating the failure. + +### Setup +```pseudo +channel_name = "test-RTL11-detached-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — leave channel in ATTACHING so presence queues + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — channel goes to ATTACHING +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence while channel is ATTACHING (per RTP16b) +enter_future = channel.presence.enter(data: "queued-enter") + +# Verify nothing sent yet +ASSERT captured_presence.length == 0 + +# Server sends DETACHED — channel transitions to DETACHED +mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel detached") +)) + +AWAIT_STATE channel.state == ChannelState.detached +``` + +### Assertions +```pseudo +# Queued presence was NOT sent +ASSERT captured_presence.length == 0 + +# The enter future completed with an error +AWAIT enter_future FAILS WITH error +ASSERT error IS ErrorInfo +ASSERT error.code IS NOT null +``` + +--- + +## RTL11 - Queued presence actions fail on SUSPENDED + +### Setup +```pseudo +channel_name = "test-RTL11-suspended-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — leave channel in ATTACHING + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue multiple presence actions +enter_future = channel.presence.enter(data: "queued-enter") +update_future = channel.presence.update(data: "queued-update") + +ASSERT captured_presence.length == 0 + +# Connection goes SUSPENDED, causing channel to go SUSPENDED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended +``` + +### Assertions +```pseudo +# No presence messages were sent +ASSERT captured_presence.length == 0 + +# Both queued futures completed with errors +AWAIT enter_future FAILS WITH enter_error +ASSERT enter_error IS ErrorInfo + +AWAIT update_future FAILS WITH update_error +ASSERT update_error IS ErrorInfo +``` + +--- + +## RTL11 - Queued presence actions fail on FAILED + +### Setup +```pseudo +channel_name = "test-RTL11-failed-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — leave channel in ATTACHING + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence +enter_future = channel.presence.enter(data: "queued-enter") + +ASSERT captured_presence.length == 0 + +# Server sends ERROR for this channel — channel goes FAILED +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel failed") +)) + +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# No presence messages were sent +ASSERT captured_presence.length == 0 + +# Queued future completed with an error +AWAIT enter_future FAILS WITH error +ASSERT error IS ErrorInfo +``` + +--- + +## RTL11a - ACK/NACK unaffected by channel state changes + +**Spec requirement (RTL11a):** For clarity, any messages awaiting an ACK or NACK are +unaffected by channel state changes i.e. a channel that becomes detached following an +explicit request to detach may still receive an ACK or NACK for messages published on +that channel later. + +### Setup +```pseudo +channel_name = "test-RTL11a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + # Do NOT send ACK yet — hold it + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send presence — it goes to the server, but no ACK yet +enter_future = channel.presence.enter(data: "awaiting-ack") +ASSERT captured_presence.length == 1 + +# Detach the channel +channel.detach() +AWAIT_STATE channel.state == ChannelState.detached + +# Now the server sends the ACK for the presence message that was already sent +mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: captured_presence[0].msgSerial, + count: 1 +)) +``` + +### Assertions +```pseudo +# The enter future resolves successfully — ACK was processed despite channel being DETACHED +AWAIT enter_future # should complete without error +``` diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md new file mode 100644 index 000000000..61a35dd4b --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -0,0 +1,979 @@ +# RealtimePresence Enter/Update/Leave Tests + +Spec points: `RTP4`, `RTP8`, `RTP8a`–`RTP8j`, `RTP9`, `RTP9a`–`RTP9e`, `RTP10`, `RTP10a`–`RTP10e`, `RTP14`, `RTP14a`–`RTP14d`, `RTP15`, `RTP15a`–`RTP15f`, `RTP16`, `RTP16a`–`RTP16c` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +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. + +--- + +## RTP8a, RTP8c - enter sends PRESENCE with ENTER action + +**Spec requirement:** Enters the current client into this channel. A PRESENCE +ProtocolMessage with a PresenceMessage with action ENTER is sent. The clientId +attribute of the PresenceMessage must not be present (implicitly uses the connection's +clientId). + +### Setup +```pseudo +channel_name = "test-RTP8a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ACK, + msgSerial: msg.msgSerial, + count: 1 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].action == PRESENCE +ASSERT captured_presence[0].channel == channel_name +ASSERT captured_presence[0].presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +# RTP8c: clientId must NOT be present in the PresenceMessage +ASSERT captured_presence[0].presence[0].clientId IS null +``` + +--- + +## RTP8e - enter with data + +**Spec requirement:** Optional data can be included when entering. Data will be encoded +and decoded as with normal messages. + +### Setup +```pseudo +channel_name = "test-RTP8e-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello world") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].data == "hello world" +``` + +--- + +## RTP8d - enter implicitly attaches channel + +**Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the +INITIALIZED state. + +### Setup +```pseudo +channel_name = "test-RTP8d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +# enter() on INITIALIZED channel triggers implicit attach +AWAIT channel.presence.enter() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +``` + +--- + +## RTP8g - enter on DETACHED or FAILED channel errors + +**Spec requirement:** If the channel is DETACHED or FAILED, the enter request results +in an error immediately. + +### Setup +```pseudo +channel_name = "test-RTP8g-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Respond with error to put channel in FAILED state + mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Channel failed") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Put channel into FAILED state +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.failed + +# enter() on FAILED channel should error immediately +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTP8j - enter with wildcard or null clientId errors + +**Spec requirement:** If the connection is CONNECTED and the clientId is '*' (wildcard) +or null (anonymous), the enter request results in an error immediately. + +### Setup +```pseudo +channel_name = "test-RTP8j-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# No clientId — anonymous client +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# enter() without clientId should error +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTP8j - enter with wildcard clientId errors + +### Setup +```pseudo +channel_name = "test-RTP8j-wild-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Wildcard clientId +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTP8h - NACK for missing presence permission + +**Spec requirement:** If the Ably service determines that the client does not have +required presence permission, a NACK is sent resulting in an error. + +### Setup +```pseudo +channel_name = "test-RTP8h-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + mock_ws.send_to_client(ProtocolMessage( + action: NACK, + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Presence permission denied") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code == 40160 +``` + +--- + +## RTP9a, RTP9d - update sends PRESENCE with UPDATE action + +**Spec requirement:** Updates the data for the present member. A PRESENCE ProtocolMessage +with action UPDATE is sent. The clientId must not be present. + +### Setup +```pseudo +channel_name = "test-RTP9a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.update(data: "new-status") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == UPDATE +ASSERT captured_presence[0].presence[0].data == "new-status" +ASSERT captured_presence[0].presence[0].clientId IS null # RTP9d +``` + +--- + +## RTP10a, RTP10c - leave sends PRESENCE with LEAVE action + +**Spec requirement:** Leaves this client from the channel. A PRESENCE ProtocolMessage +with action LEAVE is sent. The clientId must not be present. + +### Setup +```pseudo +channel_name = "test-RTP10a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.leave() +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == LEAVE +ASSERT captured_presence[0].presence[0].clientId IS null # RTP10c +``` + +--- + +## RTP10a - leave with data updates the member data + +**Spec requirement:** The data will be updated with the values provided when leaving. + +### Setup +```pseudo +channel_name = "test-RTP10a-data-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.leave(data: "goodbye") +``` + +### Assertions +```pseudo +ASSERT captured_presence[0].presence[0].action == LEAVE +ASSERT captured_presence[0].presence[0].data == "goodbye" +``` + +--- + +## RTP14a - enterClient enters on behalf of another clientId + +**Spec requirement:** Enters into presence on a channel on behalf of another clientId. +This allows a single client with suitable permissions to register presence on behalf +of any number of clients using a single connection. + +### Setup +```pseudo +channel_name = "test-RTP14a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enterClient("user-alice", data: "alice-data") +AWAIT channel.presence.enterClient("user-bob", data: "bob-data") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 2 + +# First enter: user-alice +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].clientId == "user-alice" +ASSERT captured_presence[0].presence[0].data == "alice-data" + +# Second enter: user-bob +ASSERT captured_presence[1].presence[0].action == ENTER +ASSERT captured_presence[1].presence[0].clientId == "user-bob" +ASSERT captured_presence[1].presence[0].data == "bob-data" +``` + +--- + +## RTP15a - updateClient and leaveClient + +**Spec requirement:** Performs update or leave for a given clientId. Functionally +equivalent to the corresponding enter, update, and leave methods. + +### Setup +```pseudo +channel_name = "test-RTP15a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enterClient("user-1", data: "entered") +AWAIT channel.presence.updateClient("user-1", data: "updated") +AWAIT channel.presence.leaveClient("user-1", data: "leaving") +``` + +### Assertions +```pseudo +ASSERT captured_presence.length == 3 + +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].clientId == "user-1" +ASSERT captured_presence[0].presence[0].data == "entered" + +ASSERT captured_presence[1].presence[0].action == UPDATE +ASSERT captured_presence[1].presence[0].clientId == "user-1" +ASSERT captured_presence[1].presence[0].data == "updated" + +ASSERT captured_presence[2].presence[0].action == LEAVE +ASSERT captured_presence[2].presence[0].clientId == "user-1" +ASSERT captured_presence[2].presence[0].data == "leaving" +``` + +--- + +## RTP15e - enterClient implicitly attaches channel + +**Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the +INITIALIZED state. If the channel is in or enters the DETACHED or FAILED state, error. + +### Setup +```pseudo +channel_name = "test-RTP15e-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +AWAIT channel.presence.enterClient("user-1") +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +``` + +--- + +## RTP15f - enterClient with mismatched clientId errors + +**Spec requirement:** If the client is identified and has a valid clientId, and the +clientId argument does not match the client's clientId, then it should indicate an error. + +### Setup +```pseudo +channel_name = "test-RTP15f-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Client has a specific (non-wildcard) clientId +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# enterClient with a different clientId than the connection's clientId +AWAIT channel.presence.enterClient("other-client") FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +# Connection and channel remain available +ASSERT client.connection.state == ConnectionState.connected +ASSERT channel.state == ChannelState.attached +``` + +--- + +## RTP16a - Presence message sent when channel is ATTACHED + +**Spec requirement:** If the channel is ATTACHED then presence messages are sent +immediately to the connection. + +### Setup +```pseudo +channel_name = "test-RTP16a-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() +``` + +### Assertions +```pseudo +# Message was sent immediately +ASSERT captured_presence.length == 1 +``` + +--- + +## RTP16b - Presence message queued when channel is ATTACHING + +**Spec requirement:** If the channel is ATTACHING or INITIALIZED and queueMessages is +true, presence messages are queued at channel level, sent once channel becomes ATTACHED. + +### Setup +```pseudo +channel_name = "test-RTP16b-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Delay the ATTACHED response + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach but don't complete it +channel.attach() +AWAIT_STATE channel.state == ChannelState.attaching + +# Queue presence while ATTACHING +enter_future = channel.presence.enter() + +# No messages sent yet +ASSERT captured_presence.length == 0 + +# Now complete the attach +mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + +AWAIT enter_future +``` + +### Assertions +```pseudo +# Queued presence message was sent after attach completed +ASSERT captured_presence.length == 1 +ASSERT captured_presence[0].presence[0].action == ENTER +``` + +--- + +## RTP16c - Presence message errors in other channel states + +**Spec requirement:** In any other case (channel not ATTACHED, ATTACHING, or INITIALIZED +with queueMessages) the operation should result in an error. + +### Setup +```pseudo +channel_name = "test-RTP16c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo(code: 90001, message: "Detached") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Put channel in DETACHED state +AWAIT channel.attach() FAILS WITH attach_error +ASSERT channel.state == ChannelState.detached + +AWAIT channel.presence.enter() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +``` + +--- + +## RTP15c - enterClient has no side effects on normal enter + +**Spec requirement:** Using enterClient, updateClient, and leaveClient methods should +have no side effects on a client that has entered normally using enter. + +### Setup +```pseudo +channel_name = "test-RTP15c-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +# Wildcard client to allow both enter() and enterClient() +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Normal enter for the wildcard client +AWAIT channel.presence.enter(data: "main-client") + +# enterClient for a different user +AWAIT channel.presence.enterClient("other-user", data: "other-data") + +# leaveClient for the other user +AWAIT channel.presence.leaveClient("other-user") +``` + +### Assertions +```pseudo +# Three presence messages sent: enter, enterClient, leaveClient +ASSERT captured_presence.length == 3 + +# The main client's enter is unaffected by the enterClient/leaveClient calls +ASSERT captured_presence[0].presence[0].action == ENTER +ASSERT captured_presence[0].presence[0].data == "main-client" +ASSERT captured_presence[0].presence[0].clientId IS null # Uses connection clientId + +ASSERT captured_presence[1].presence[0].action == ENTER +ASSERT captured_presence[1].presence[0].clientId == "other-user" + +ASSERT captured_presence[2].presence[0].action == LEAVE +ASSERT captured_presence[2].presence[0].clientId == "other-user" +``` + +--- + +## RTP4 - 250 members via enterClient + +**Spec requirement:** Ensure a test exists that enters 250 members using +RealtimePresence#enterClient on a single connection, and checks for PRESENT events +to be emitted on another connection for each member, and once sync is complete, all +250 members should be present in a RealtimePresence#get request. + +Note: The spec says 250 but we use 50 as a practical test size that validates the +same behavior (bulk enterClient, SYNC delivery, get correctness) without excessive +test runtime. + +### Setup +```pseudo +channel_name = "test-RTP4-${random_id()}" +member_count = 50 + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + + # Server echoes back the ENTER as a PRESENCE event (as it would for a second client) + FOR p IN msg.presence: + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage( + action: ENTER, + clientId: p.clientId, + connectionId: "conn-1", + id: "conn-1:${msg.msgSerial}:0", + timestamp: NOW() + ) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Track ENTER events received by subscriber +received_enters = [] +channel.presence.subscribe(action: ENTER, (event) => { + received_enters.append(event) +}) + +# Enter 50 members +FOR i IN 0..member_count-1: + AWAIT channel.presence.enterClient("user-${i}", data: "data-${i}") + +# Send a complete SYNC with all 50 members as PRESENT +sync_members = [] +FOR i IN 0..member_count-1: + sync_members.append(PresenceMessage( + action: PRESENT, + clientId: "user-${i}", + connectionId: "conn-1", + id: "conn-1:${i}:0", + timestamp: NOW(), + data: "data-${i}" + )) + +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: sync_members +)) + +# Get all members after sync +members = AWAIT channel.presence.get() +``` + +### Assertions +```pseudo +# All 50 members entered +ASSERT captured_presence.length == member_count + +# All 50 ENTER events received by subscriber +ASSERT received_enters.length == member_count + +# All 50 members present after sync +ASSERT members.length == member_count + +# Verify each member exists with correct data +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" +``` diff --git a/uts/realtime/unit/presence/realtime_presence_get.md b/uts/realtime/unit/presence/realtime_presence_get.md new file mode 100644 index 000000000..61abc59a4 --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_get.md @@ -0,0 +1,479 @@ +# RealtimePresence Get Tests + +Spec points: `RTP11`, `RTP11a`, `RTP11b`, `RTP11c`, `RTP11c1`, `RTP11c2`, `RTP11c3`, `RTP11d` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +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 +before returning. It supports filtering by clientId and connectionId, and has specific +error behaviour for SUSPENDED channels. + +--- + +## RTP11a - get returns current members (single-message sync) + +**Spec requirement:** Returns the list of current members on the channel. By default, +will wait for the SYNC to be completed. + +This test uses a single-message sync: the ATTACHED has HAS_PRESENCE, but the SYNC +message is not sent immediately. The get() call must wait until the sync arrives +and completes. + +### Setup +```pseudo +channel_name = "test-RTP11a-single-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Send ATTACHED with HAS_PRESENCE but do NOT send SYNC yet + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Start get() — sync has not arrived yet, so this must wait +get_future = channel.presence.get() + +# Verify the get has not resolved yet (sync still pending) +ASSERT get_future IS NOT complete + +# Now send a single-message SYNC (channelSerial with empty cursor = complete) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "a"), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100, data: "b") + ] +)) + +members = AWAIT get_future +``` + +### Assertions +```pseudo +ASSERT members.length == 2 +client_ids = members.map(m => m.clientId).sort() +ASSERT client_ids == ["alice", "bob"] +``` + +--- + +## RTP11a, RTP11c1 - get waits for multi-message sync + +**Spec requirement:** When waitForSync is true (default), the method will wait until +SYNC is complete before returning a list of members. A multi-message sync has a +non-empty cursor in the first message and an empty cursor in the final message. + +### Setup +```pseudo +channel_name = "test-RTP11c1-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Send ATTACHED with HAS_PRESENCE but do NOT send SYNC yet + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Start get() — sync has not arrived yet +get_future = channel.presence.get() + +# Verify the get has not resolved yet +ASSERT get_future IS NOT complete + +# Send first SYNC message (non-empty cursor = more to come) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:cursor1", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] +)) + +# get() should still be waiting — sync not complete +ASSERT get_future IS NOT complete + +# Send final SYNC message (empty cursor = sync complete) +mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100) + ] +)) + +members = AWAIT get_future +``` + +### Assertions +```pseudo +# Both alice (from first SYNC message) and bob (from second) are present +ASSERT members.length == 2 +client_ids = members.map(m => m.clientId).sort() +ASSERT client_ids == ["alice", "bob"] +``` + +--- + +## RTP11c1 - get with waitForSync=false returns immediately + +**Spec requirement:** When waitForSync is false, the known set of presence members is +returned immediately, which may be incomplete if the SYNC is not finished. + +### Setup +```pseudo +channel_name = "test-RTP11c1-nowait-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Start SYNC but don't complete it (cursor is non-empty) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:cursor1", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Sync is in progress but we don't wait +members = AWAIT channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +# Returns what's available so far (may be incomplete) +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +``` + +--- + +## RTP11c2 - get filtered by clientId + +**Spec requirement:** clientId param filters members by the provided clientId. + +### Setup +```pseudo +channel_name = "test-RTP11c2-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c3", id: "c3:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get(clientId: "alice") +``` + +### Assertions +```pseudo +# Only alice entries returned (from two different connections) +ASSERT members.length == 2 +ASSERT members.every(m => m.clientId == "alice") +``` + +--- + +## RTP11c3 - get filtered by connectionId + +**Spec requirement:** connectionId param filters members by the provided connectionId. + +### Setup +```pseudo +channel_name = "test-RTP11c3-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 100), + PresenceMessage(action: PRESENT, clientId: "carol", connectionId: "c1", id: "c1:0:1", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +members = AWAIT channel.presence.get(connectionId: "c1") +``` + +### Assertions +```pseudo +# Only members from connection c1 (alice and carol) +ASSERT members.length == 2 +ASSERT members.every(m => m.connectionId == "c1") +``` + +--- + +## RTP11b - get implicitly attaches channel + +**Spec requirement:** Implicitly attaches the RealtimeChannel if the channel is in the +INITIALIZED state. If the channel enters DETACHED or FAILED before the operation +succeeds, error. + +### Setup +```pseudo +channel_name = "test-RTP11b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +members = AWAIT channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT members IS NOT null +``` + +--- + +## RTP11d - get on SUSPENDED channel errors by default + +**Spec requirement:** If the RealtimeChannel is SUSPENDED, get will by default (or if +waitForSync is true) result in an error with code 91005. If waitForSync is false, +it returns the members currently stored in the PresenceMap. + +### Setup +```pseudo +channel_name = "test-RTP11d-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + # Deliver a member via SYNC + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Simulate channel becoming SUSPENDED (e.g., connection drops) +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended + +# Default get (waitForSync=true) should error +AWAIT channel.presence.get() FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error IS NOT null +ASSERT error.code == 91005 +``` + +--- + +## RTP11d - get on SUSPENDED channel with waitForSync=false returns members + +**Spec requirement:** If waitForSync is false on a SUSPENDED channel, return the +members currently in the PresenceMap. + +### Setup +```pseudo +channel_name = "test-RTP11d-nowait-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + mock_ws.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: [ + PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 100) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Simulate channel becoming SUSPENDED +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE channel.state == ChannelState.suspended + +# waitForSync=false returns what's in the PresenceMap +members = AWAIT channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +``` diff --git a/uts/realtime/unit/presence/realtime_presence_history.md b/uts/realtime/unit/presence/realtime_presence_history.md new file mode 100644 index 000000000..a815e4e48 --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_history.md @@ -0,0 +1,125 @@ +# RealtimePresence History Tests + +Spec points: `RTP12`, `RTP12a`, `RTP12c`, `RTP12d` + +## Test Type +Unit test — mock WebSocket required (for channel setup), REST mock for history request. + +## Purpose + +Tests the `RealtimePresence#history` function which delegates to `RestPresence#history`. +It supports the same parameters as `RestPresence#history` and returns a `PaginatedResult`. + +--- + +## RTP12a - history supports same params as RestPresence#history + +**Spec requirement:** Supports all the same params as RestPresence#history. + +### Setup +```pseudo +channel_name = "test-RTP12a-${random_id()}" + +captured_history_requests = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +# Mock the REST history endpoint +mock_rest = MockRest( + onRequest: (method, path, params) => { + captured_history_requests.append({ method: method, path: path, params: params }) + RETURN { + items: [], + statusCode: 200 + } + } +) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.presence.history( + start: 1000, + end: 2000, + direction: "backwards", + limit: 50 +) +``` + +### Assertions +```pseudo +ASSERT captured_history_requests.length == 1 +ASSERT captured_history_requests[0].path == "/channels/${channel_name}/presence/history" +ASSERT captured_history_requests[0].params.start == 1000 +ASSERT captured_history_requests[0].params.end == 2000 +ASSERT captured_history_requests[0].params.direction == "backwards" +ASSERT captured_history_requests[0].params.limit == 50 +``` + +--- + +## RTP12c - history returns PaginatedResult + +**Spec requirement:** Returns a PaginatedResult page containing the first page of +messages in the PaginatedResult#items attribute. + +### Setup +```pseudo +channel_name = "test-RTP12c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +mock_rest = MockRest( + onRequest: (method, path, params) => { + RETURN { + items: [ + PresenceMessage(action: ENTER, clientId: "alice", timestamp: 1000), + PresenceMessage(action: UPDATE, clientId: "alice", timestamp: 2000), + PresenceMessage(action: LEAVE, clientId: "alice", timestamp: 3000) + ], + statusCode: 200 + } + } +) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +result = AWAIT channel.presence.history() +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 3 +ASSERT result.items[0].clientId == "alice" +ASSERT result.items[0].action == ENTER +ASSERT result.items[2].action == LEAVE +``` diff --git a/uts/realtime/unit/presence/realtime_presence_reentry.md b/uts/realtime/unit/presence/realtime_presence_reentry.md new file mode 100644 index 000000000..02a3949f2 --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_reentry.md @@ -0,0 +1,437 @@ +# RealtimePresence Automatic Re-entry Tests + +Spec points: `RTP17a`, `RTP17e`, `RTP17g`, `RTP17g1`, `RTP17i` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests automatic re-entry of presence members when a channel reattaches. The +RealtimePresence object maintains an internal PresenceMap (RTP17) of locally-entered +members. When the channel receives an ATTACHED ProtocolMessage (except when already +attached with RESUMED flag), it re-publishes an ENTER for each member in the internal map. + +--- + +## RTP17i - Automatic re-entry on ATTACHED (non-RESUMED) + +**Spec requirement:** The RealtimePresence object should perform automatic re-entry +whenever the channel receives an ATTACHED ProtocolMessage, except in the case where +the channel is already attached and the ProtocolMessage has the RESUMED bit flag set. + +### Setup +```pseudo +channel_name = "test-RTP17i-${random_id()}" + +connection_count = 0 +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Enter presence +AWAIT channel.presence.enter(data: "hello") + +ASSERT captured_presence.length == 1 + +# Simulate disconnect and reconnect (new connectionId) +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Clear captured to track only re-entry messages +captured_presence = [] + +# Reconnect — triggers reattach with new ATTACHED (non-RESUMED) +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# RTP17i: Automatic re-entry sends ENTER for the member +ASSERT captured_presence.length >= 1 + +reenter = captured_presence.find(m => m.presence[0].action == ENTER) +ASSERT reenter IS NOT null +``` + +--- + +## RTP17g - Re-entry publishes ENTER with stored clientId and data + +**Spec requirement:** For each member of the RTP17 internal PresenceMap, publish a +PresenceMessage with an ENTER action using the clientId, data, and id attributes +from that member. + +### Setup +```pseudo +channel_name = "test-RTP17g-${random_id()}" + +connection_count = 0 +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +# Wildcard client to test enterClient with multiple members +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Enter multiple members +AWAIT channel.presence.enterClient("alice", data: "alice-data") +AWAIT channel.presence.enterClient("bob", data: "bob-data") + +ASSERT captured_presence.length == 2 + +# Simulate disconnect and reconnect +captured_presence = [] +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# Both members re-entered with ENTER action and original data +reentry_messages = captured_presence.filter(m => m.action == PRESENCE) +presence_items = [] +FOR msg IN reentry_messages: + FOR p IN msg.presence: + presence_items.append(p) + +ASSERT presence_items.length >= 2 + +alice_reentry = presence_items.find(p => p.clientId == "alice") +bob_reentry = presence_items.find(p => p.clientId == "bob") + +ASSERT alice_reentry IS NOT null +ASSERT alice_reentry.action == ENTER +ASSERT alice_reentry.data == "alice-data" + +ASSERT bob_reentry IS NOT null +ASSERT bob_reentry.action == ENTER +ASSERT bob_reentry.data == "bob-data" +``` + +--- + +## RTP17g1 - Re-entry omits id when connectionId changed + +**Spec requirement:** If the current connection id is different from the connectionId +attribute of the stored member, the published PresenceMessage must not have its id set. + +### Setup +```pseudo +channel_name = "test-RTP17g1-${random_id()}" + +connection_count = 0 +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello") + +# First connection is conn-1 +ASSERT connection_count == 1 + +# Disconnect and reconnect — new connectionId (conn-2) +captured_presence = [] +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_count == 2 + +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# Re-entry message should NOT have id set because connectionId changed +reentry = captured_presence.find(m => m.action == PRESENCE) +ASSERT reentry IS NOT null + +reentry_presence = reentry.presence[0] +ASSERT reentry_presence.action == ENTER +ASSERT reentry_presence.id IS null # RTP17g1: id not set when connectionId changed +ASSERT reentry_presence.data == "hello" +``` + +--- + +## RTP17i - No re-entry when ATTACHED with RESUMED flag + +**Spec requirement:** Automatic re-entry is NOT performed when the channel is already +attached and the ProtocolMessage has the RESUMED bit flag set. + +### Setup +```pseudo +channel_name = "test-RTP17i-resumed-${random_id()}" + +captured_presence = [] +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1", connectionKey: "key-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello") + +# Clear captured +captured_presence = [] + +# Server sends ATTACHED with RESUMED flag while already attached +# (e.g., after a brief transport-level reconnect that preserved the connection) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: RESUMED +)) +``` + +### Assertions +```pseudo +# No re-entry — RESUMED flag means the server still has our presence state +ASSERT captured_presence.length == 0 +``` + +--- + +## RTP17e - Failed re-entry emits UPDATE with error + +**Spec requirement:** If an automatic presence ENTER fails (e.g., NACK), emit an UPDATE +event on the channel with resumed=true and reason set to ErrorInfo with code 91004, +message indicating the failure and clientId, and cause set to the NACK error. + +### Setup +```pseudo +channel_name = "test-RTP17e-${random_id()}" + +connection_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_count++ + conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "conn-${connection_count}" + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == PRESENCE: + IF connection_count == 1: + # First connection: ACK the enter + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + ELSE: + # Second connection: NACK the re-entry + mock_ws.send_to_client(ProtocolMessage( + action: NACK, + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, statusCode: 401, message: "Presence denied") + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter(data: "hello") + +# Listen for channel UPDATE events +channel_events = [] +channel.on(ChannelEvent.update, (change) => { + channel_events.append(change) +}) + +# Disconnect and reconnect — re-entry will be NACKed +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached + +# Wait for the re-entry NACK to be processed +AWAIT UNTIL channel_events.length >= 1 +``` + +### Assertions +```pseudo +ASSERT channel_events.length >= 1 + +update_event = channel_events[0] +ASSERT update_event.resumed == true +ASSERT update_event.reason IS NOT null +ASSERT update_event.reason.code == 91004 +ASSERT update_event.reason.message CONTAINS "my-client" +ASSERT update_event.reason.cause IS NOT null +ASSERT update_event.reason.cause.code == 40160 +``` + +--- + +## RTP17a - Server publishes member regardless of subscribe capability + +**Spec requirement:** All members belonging to the current connection are published as a +PresenceMessage on the channel by the server irrespective of whether the client has +permission to subscribe. The member should be present in both the internal and public +presence set via get. + +### Setup +```pseudo +channel_name = "test-RTP17a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Channel with presence capability but no subscribe capability + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: PRESENCE + )) + ELSE IF msg.action == PRESENCE: + # ACK the enter + mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server delivers the presence event back to the client + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage( + action: ENTER, + clientId: "my-client", + connectionId: "conn-1", + id: "conn-1:0:0", + timestamp: 1000 + ) + ] + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "my-client", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +AWAIT channel.presence.enter() + +# Check public presence map +members = channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "my-client" +``` diff --git a/uts/realtime/unit/presence/realtime_presence_subscribe.md b/uts/realtime/unit/presence/realtime_presence_subscribe.md new file mode 100644 index 000000000..9cb4d370d --- /dev/null +++ b/uts/realtime/unit/presence/realtime_presence_subscribe.md @@ -0,0 +1,580 @@ +# RealtimePresence Subscribe/Unsubscribe Tests + +Spec points: `RTP6`, `RTP6a`, `RTP6b`, `RTP6d`, `RTP6e`, `RTP7`, `RTP7a`, `RTP7b`, `RTP7c` + +## Test Type +Unit test — mock WebSocket required. + +## Purpose + +Tests the `RealtimePresence#subscribe` and `RealtimePresence#unsubscribe` functions. +Subscribe registers listeners for incoming presence events (ENTER, LEAVE, UPDATE, PRESENT). +Unsubscribe removes previously registered listeners. Subscribe may implicitly attach the +channel depending on the `attachOnSubscribe` channel option. + +--- + +## RTP6a - Subscribe to all presence events + +**Spec requirement:** Subscribe with a single listener argument subscribes a listener to +all presence messages. + +### Setup +```pseudo +channel_name = "test-RTP6a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +received_events = [] +channel.presence.subscribe((event) => { + received_events.append(event) +}) + +AWAIT_STATE channel.state == ChannelState.attached + +# Server delivers ENTER, UPDATE, and LEAVE events +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000) + ] +)) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: UPDATE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000, data: "updated") + ] +)) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 3000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT received_events.length == 3 +ASSERT received_events[0].action == ENTER +ASSERT received_events[0].clientId == "alice" +ASSERT received_events[1].action == UPDATE +ASSERT received_events[1].data == "updated" +ASSERT received_events[2].action == LEAVE +``` + +--- + +## RTP6b - Subscribe filtered by action + +**Spec requirement:** Subscribe with an action argument and a listener subscribes the +listener to receive only presence messages with that action. + +### Setup +```pseudo +channel_name = "test-RTP6b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +enter_events = [] +leave_events = [] + +channel.presence.subscribe(action: ENTER, (event) => { + enter_events.append(event) +}) + +channel.presence.subscribe(action: LEAVE, (event) => { + leave_events.append(event) +}) + +# Server delivers all three action types +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: UPDATE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000), + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 3000) + ] +)) +``` + +### Assertions +```pseudo +# ENTER listener only gets ENTER events +ASSERT enter_events.length == 1 +ASSERT enter_events[0].action == ENTER + +# LEAVE listener only gets LEAVE events +ASSERT leave_events.length == 1 +ASSERT leave_events[0].action == LEAVE + +# Neither listener receives UPDATE +``` + +--- + +## RTP6b - Subscribe filtered by multiple actions + +**Spec requirement:** The action argument may also be an array of actions. + +### Setup +```pseudo +channel_name = "test-RTP6b-multi-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +enter_leave_events = [] +channel.presence.subscribe(actions: [ENTER, LEAVE], (event) => { + enter_leave_events.append(event) +}) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: UPDATE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000), + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:2:0", timestamp: 3000) + ] +)) +``` + +### Assertions +```pseudo +# Only ENTER and LEAVE events received — UPDATE filtered out +ASSERT enter_leave_events.length == 2 +ASSERT enter_leave_events[0].action == ENTER +ASSERT enter_leave_events[1].action == LEAVE +``` + +--- + +## RTP6d - Subscribe implicitly attaches channel + +**Spec requirement:** If the `attachOnSubscribe` channel option is true (default), +implicitly attach the RealtimeChannel if the channel is in the INITIALIZED, DETACHING, +or DETACHED states. + +### Setup +```pseudo +channel_name = "test-RTP6d-${random_id()}" + +attach_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +# Subscribe without explicitly attaching — should trigger implicit attach +channel.presence.subscribe((event) => {}) + +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +ASSERT attach_count == 1 +ASSERT channel.state == ChannelState.attached +``` + +--- + +## RTP6e - Subscribe with attachOnSubscribe=false does not attach + +**Spec requirement:** If the `attachOnSubscribe` channel option is false, do not +implicitly attach. + +### Setup +```pseudo +channel_name = "test-RTP6e-${random_id()}" + +attach_count = 0 +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name, options: ChannelOptions(attachOnSubscribe: false)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +ASSERT channel.state == ChannelState.initialized + +channel.presence.subscribe((event) => {}) +``` + +### Assertions +```pseudo +# Channel stays in INITIALIZED — no implicit attach +ASSERT channel.state == ChannelState.initialized +ASSERT attach_count == 0 +``` + +--- + +## RTP7c - Unsubscribe all listeners + +**Spec requirement:** Unsubscribe with no arguments unsubscribes all listeners. + +### Setup +```pseudo +channel_name = "test-RTP7c-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +events_a = [] +events_b = [] + +channel.presence.subscribe((event) => { events_a.append(event) }) +channel.presence.subscribe((event) => { events_b.append(event) }) + +# Deliver first event — both listeners receive it +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000) + ] +)) + +ASSERT events_a.length == 1 +ASSERT events_b.length == 1 + +# Unsubscribe all +channel.presence.unsubscribe() + +# Deliver second event — no listeners receive it +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 2000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT events_a.length == 1 # No new events after unsubscribe +ASSERT events_b.length == 1 +``` + +--- + +## RTP7a - Unsubscribe specific listener + +**Spec requirement:** Unsubscribe with a single listener argument unsubscribes that +specific listener. + +### Setup +```pseudo +channel_name = "test-RTP7a-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +events_a = [] +events_b = [] + +listener_a = (event) => { events_a.append(event) } +listener_b = (event) => { events_b.append(event) } + +channel.presence.subscribe(listener_a) +channel.presence.subscribe(listener_b) + +# Unsubscribe only listener_a +channel.presence.unsubscribe(listener_a) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT events_a.length == 0 # Unsubscribed — no events +ASSERT events_b.length == 1 # Still subscribed — receives event +``` + +--- + +## RTP7b - Unsubscribe listener for specific action + +**Spec requirement:** Unsubscribe with an action argument and a listener unsubscribes +the listener for that action only. + +### Setup +```pseudo +channel_name = "test-RTP7b-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received = [] +listener = (event) => { received.append(event) } + +# Subscribe to both ENTER and LEAVE +channel.presence.subscribe(action: ENTER, listener) +channel.presence.subscribe(action: LEAVE, listener) + +# Unsubscribe only for ENTER +channel.presence.unsubscribe(action: ENTER, listener) + +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: LEAVE, clientId: "alice", connectionId: "c1", id: "c1:1:0", timestamp: 2000) + ] +)) +``` + +### Assertions +```pseudo +# Only LEAVE received — ENTER subscription was removed +ASSERT received.length == 1 +ASSERT received[0].action == LEAVE +``` + +--- + +## RTP6 - Presence events update the PresenceMap + +**Spec requirement:** Incoming presence messages are applied to the PresenceMap (RTP2) +before being emitted to subscribers. + +### Setup +```pseudo +channel_name = "test-RTP6-map-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +channel.presence.subscribe((event) => {}) + +# Server delivers ENTER +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000, data: "hello") + ] +)) + +members = channel.presence.get(waitForSync: false) +``` + +### Assertions +```pseudo +ASSERT members.length == 1 +ASSERT members[0].clientId == "alice" +ASSERT members[0].data == "hello" +ASSERT members[0].action == PRESENT # Stored as PRESENT per RTP2d2 +``` + +--- + +## RTP6 - Multiple presence messages in single ProtocolMessage + +**Spec requirement:** A PRESENCE ProtocolMessage may contain multiple PresenceMessages. + +### Setup +```pseudo +channel_name = "test-RTP6-batch-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +received = [] +channel.presence.subscribe((event) => { received.append(event) }) + +# Server delivers multiple presence events in one ProtocolMessage +mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:0:0", timestamp: 1000), + PresenceMessage(action: ENTER, clientId: "bob", connectionId: "c2", id: "c2:0:0", timestamp: 1000), + PresenceMessage(action: ENTER, clientId: "carol", connectionId: "c3", id: "c3:0:0", timestamp: 1000) + ] +)) +``` + +### Assertions +```pseudo +ASSERT received.length == 3 +ASSERT received[0].clientId == "alice" +ASSERT received[1].clientId == "bob" +ASSERT received[2].clientId == "carol" +``` From bce930b442ab07383f39b4da228de4835b1484c8 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 19/32] Fix path component encoding in test specs to use encode_uri_component() Update test specs to use encode_uri_component() for channel names in URL paths, ensuring correct handling of special characters. Add a README documenting the convention and update the write-test-spec skill. --- uts/.claude/skills/write-test-spec.md | 15 +++++++++++++++ .../unit/presence/realtime_presence_history.md | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index 6cdadf70c..b291c8df6 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -259,6 +259,18 @@ Tests that all REST requests include the `Ably-Agent` header with correct format ## Pseudocode Conventions +### URI Path Component Encoding + +Use `encode_uri_component()` for any variable path segment or query parameter in URL assertions. This is defined in `uts/test/README.md`. Always use exact equality (`==`) for path assertions, not `CONTAINS`. + +```pseudo +# Correct — exact path with encoded variable +ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + "/messages" + +# Wrong — loose match, misses encoding bugs +ASSERT request.url.path CONTAINS "/channels/" +``` + ### Type Assertions Type assertions verify object types/interfaces. Implementation varies by language: @@ -869,3 +881,6 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null 16. ❌ Using exact `ADVANCE_TIME` calculations for multi-retry scenarios: `ADVANCE_TIME(6000); ADVANCE_TIME(1000)` ✅ Use a time-advancement loop: `LOOP up to N times: ADVANCE_TIME(increment)` + +17. ❌ Loose path assertions: `ASSERT request.url.path CONTAINS "/channels/"` + ✅ Exact path with encoding: `ASSERT request.url.path == "/channels/" + encode_uri_component(name) + "/messages"` diff --git a/uts/realtime/unit/presence/realtime_presence_history.md b/uts/realtime/unit/presence/realtime_presence_history.md index a815e4e48..7e8f0f65c 100644 --- a/uts/realtime/unit/presence/realtime_presence_history.md +++ b/uts/realtime/unit/presence/realtime_presence_history.md @@ -62,7 +62,7 @@ result = AWAIT channel.presence.history( ### Assertions ```pseudo ASSERT captured_history_requests.length == 1 -ASSERT captured_history_requests[0].path == "/channels/${channel_name}/presence/history" +ASSERT captured_history_requests[0].path == "/channels/${encode_uri_component(channel_name)}/presence/history" ASSERT captured_history_requests[0].params.start == 1000 ASSERT captured_history_requests[0].params.end == 2000 ASSERT captured_history_requests[0].params.direction == "backwards" From 15e211e962e98d1d35d786e9d84805a54ea9d809 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 20/32] Update presence test specs and add integration tests Refine presence test specs based on implementation experience, add integration test specs for presence operations against a live server, and fix various issues in the presence specs. --- uts/completion-status.md | 12 +- .../integration/presence_lifecycle_test.md | 245 ++++++++++++++++++ uts/realtime/unit/presence/presence_sync.md | 91 +++++++ .../unit/presence/realtime_presence_enter.md | 158 ++++++++++- 4 files changed, 492 insertions(+), 14 deletions(-) create mode 100644 uts/realtime/integration/presence_lifecycle_test.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 78b7b8b9a..913256efa 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -244,14 +244,14 @@ This matrix lists all spec items from the [Ably features spec](../../specificati |-----------|-------------|---------------| | RTP1 | HAS_PRESENCE flag and SYNC | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTP2 | PresenceMap maintenance (RTP2a–RTP2h2) | Yes — `realtime/unit/presence/presence_map.md` | -| RTP4 | Large member count test | Yes — `realtime/unit/presence/realtime_presence_enter.md` | +| RTP4 | Large member count test | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | | RTP5 | Channel state side effects (RTP5a–RTP5f) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | -| RTP6 | Subscribe function (RTP6a–RTP6e) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md` | +| RTP6 | Subscribe function (RTP6a–RTP6e) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md`, `realtime/integration/presence_lifecycle_test.md` | | RTP7 | Unsubscribe function (RTP7a–RTP7c) | Yes — `realtime/unit/presence/realtime_presence_subscribe.md` | -| RTP8 | Enter function (RTP8a–RTP8j) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | -| RTP9 | Update function (RTP9a–RTP9e) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | -| RTP10 | Leave function (RTP10a–RTP10e) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | -| RTP11 | Get function (RTP11a–RTP11d) | Yes — `realtime/unit/presence/realtime_presence_get.md` | +| RTP8 | Enter function (RTP8a–RTP8j) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP9 | Update function (RTP9a–RTP9e) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP10 | Leave function (RTP10a–RTP10e) | Yes — `realtime/unit/presence/realtime_presence_enter.md`, `realtime/integration/presence_lifecycle_test.md` | +| RTP11 | Get function (RTP11a–RTP11d) | Yes — `realtime/unit/presence/realtime_presence_get.md`, `realtime/integration/presence_lifecycle_test.md` | | RTP12 | History function (RTP12a–RTP12d) | Yes — `realtime/unit/presence/realtime_presence_history.md` | | RTP13 | SyncComplete attribute | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTP14 | EnterClient function (RTP14a–RTP14d) | Yes — `realtime/unit/presence/realtime_presence_enter.md` | diff --git a/uts/realtime/integration/presence_lifecycle_test.md b/uts/realtime/integration/presence_lifecycle_test.md new file mode 100644 index 000000000..9accb0bbf --- /dev/null +++ b/uts/realtime/integration/presence_lifecycle_test.md @@ -0,0 +1,245 @@ +# Realtime Presence Lifecycle Integration Tests + +Spec points: `RTP4`, `RTP6`, `RTP8`, `RTP9`, `RTP10`, `RTP11a` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification of the realtime presence lifecycle using two connections +against the Ably sandbox. Client A enters/updates/leaves members, Client B observes +presence events via subscribe and verifies member state via get(). + +These tests complement the unit tests by verifying that the real server correctly +broadcasts presence events, delivers SYNC data, and maintains presence state. + +## Test Environment + +### Prerequisites +- Ably sandbox app provisioned via `POST https://sandbox-rest.ably.io/apps` +- API key with `{"*":["*"]}` capability +- `useBinaryProtocol: false` (SDK does not implement msgpack) + +### Setup Pattern +```pseudo +BEFORE ALL TESTS: + app_config = provision_sandbox_app( + keys: [{ capability: '{"*":["*"]}' }] + ) + app_id = app_config.app_id + api_key = app_config.keys[0].key_str + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RTP4, RTP6, RTP11a - Bulk enterClient observed on different connection + +**Spec requirement:** Enter multiple members on connection A, verify they are observed +on connection B via subscribe (RTP6) and get() after sync (RTP11a). This is the +integration equivalent of the RTP4 unit test. + +Note: The spec says 250 but we use 50 as a practical test size that validates the +same behavior without excessive test runtime. + +### Setup +```pseudo +channel_name = "presence-bulk-" + random_id() +member_count = 50 + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Connect both clients +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected + +client_b.connect() +AWAIT_STATE client_b.connection.state == ConnectionState.connected + +# Attach both to the channel +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +AWAIT channel_b.attach() + +# Subscribe on client B before client A enters +received_enters = [] +channel_b.presence.subscribe(action: ENTER, (event) => { + received_enters.append(event) +}) + +# Attach client A (after B is attached and subscribed) +AWAIT channel_a.attach() + +# Client A enters members in parallel +futures = [] +FOR i IN 0..member_count-1: + futures.append(channel_a.presence.enterClient("user-${i}", data: "data-${i}")) +AWAIT_ALL futures + +# Wait for client B to receive all ENTER events +poll_until( + condition: FUNCTION() => received_enters.length >= member_count, + interval: 200ms, + timeout: 15s +) + +# Client B gets all members +members = AWAIT channel_b.presence.get() +``` + +### Assertions +```pseudo +# Client B received all ENTER events via subscribe +ASSERT received_enters.length == member_count + +# All members present via get() +ASSERT members.length == member_count + +# Verify each member has correct clientId and data +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTP8, RTP9, RTP10 - Enter, update, leave lifecycle + +**Spec requirement:** Verify the complete presence lifecycle: enter populates the +presence set (RTP8), update modifies the data (RTP9), and leave removes the member +(RTP10). All transitions are observed on a separate connection. + +### Setup +```pseudo +channel_name = "presence-lifecycle-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + clientId: "lifecycle-client", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Connect and attach both clients +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected +client_b.connect() +AWAIT_STATE client_b.connection.state == ConnectionState.connected + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +AWAIT channel_b.attach() + +# Collect all presence events on client B +all_events = [] +channel_b.presence.subscribe((event) => { + all_events.append(event) +}) + +AWAIT channel_a.attach() + +# --- Phase 1: Enter --- +AWAIT channel_a.presence.enter(data: "hello") + +# Wait for ENTER event on client B +poll_until( + condition: FUNCTION() => all_events.length >= 1, + interval: 200ms, + timeout: 10s +) + +# Verify member is present via get() +members_after_enter = AWAIT channel_b.presence.get() +ASSERT members_after_enter.length == 1 +ASSERT members_after_enter[0].clientId == "lifecycle-client" +ASSERT members_after_enter[0].data == "hello" + +# --- Phase 2: Update --- +AWAIT channel_a.presence.update(data: "world") + +# Wait for UPDATE event on client B +poll_until( + condition: FUNCTION() => all_events.length >= 2, + interval: 200ms, + timeout: 10s +) + +# Verify member data updated via get() +members_after_update = AWAIT channel_b.presence.get() +ASSERT members_after_update.length == 1 +ASSERT members_after_update[0].data == "world" + +# --- Phase 3: Leave --- +AWAIT channel_a.presence.leave(data: "goodbye") + +# Wait for LEAVE event on client B +poll_until( + condition: FUNCTION() => all_events.length >= 3, + interval: 200ms, + timeout: 10s +) + +# Verify member is gone via get() +members_after_leave = AWAIT channel_b.presence.get() +ASSERT members_after_leave.length == 0 +``` + +### Assertions +```pseudo +# Verify the sequence of events +ASSERT all_events.length >= 3 + +enter_event = all_events[0] +ASSERT enter_event.action == ENTER +ASSERT enter_event.clientId == "lifecycle-client" +ASSERT enter_event.data == "hello" + +update_event = all_events[1] +ASSERT update_event.action == UPDATE +ASSERT update_event.clientId == "lifecycle-client" +ASSERT update_event.data == "world" + +leave_event = all_events[2] +ASSERT leave_event.action == LEAVE +ASSERT leave_event.clientId == "lifecycle-client" +ASSERT leave_event.data == "goodbye" +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` diff --git a/uts/realtime/unit/presence/presence_sync.md b/uts/realtime/unit/presence/presence_sync.md index 6c0f6fd60..92bf51b48 100644 --- a/uts/realtime/unit/presence/presence_sync.md +++ b/uts/realtime/unit/presence/presence_sync.md @@ -460,6 +460,97 @@ ASSERT map.isSyncInProgress == false --- +## RTP19 - Stale SYNC message still removes member from residuals + +**Spec requirement:** When a member exists from a PRESENCE event and a SYNC starts, +a SYNC message arriving with the same or older id for that member is stale (rejected +by the newness check). However, the member has been "seen" during sync — it must NOT +be evicted as residual on endSync. The residual removal must happen before the newness +check in put(). + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Pre-populate with a member via ENTER +map.put(PresenceMessage(action: ENTER, clientId: "alice", connectionId: "c1", id: "c1:5:0", timestamp: 500, data: "original")) + +# Start sync +map.startSync() + +# SYNC message arrives with OLDER id (stale — same connectionId, lower msgSerial) +result = map.put(PresenceMessage(action: PRESENT, clientId: "alice", connectionId: "c1", id: "c1:3:0", timestamp: 300, data: "stale")) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# The stale put was rejected (returns null) +ASSERT result IS null + +# But alice must NOT be evicted — she was "seen" during sync +ASSERT leave_events.length == 0 +ASSERT map.values().length == 1 +ASSERT map.get("c1:alice") IS NOT null + +# Original data is preserved (stale message did not overwrite) +ASSERT map.get("c1:alice").data == "original" +``` + +--- + +## RTP19 - PRESENCE echoes followed by SYNC preserves all members + +**Spec requirement:** When a client enters multiple members, the server echoes each +as a PRESENCE event. When the server subsequently sends a SYNC containing the same +members, all members should survive even though the SYNC messages may have the same +or older ids as the PRESENCE echoes. + +This tests the real protocol flow where PRESENCE echoes populate the map before SYNC. + +### Setup +```pseudo +map = PresenceMap() +``` + +### Test Steps +```pseudo +# Simulate server echoing PRESENCE events for 3 members +map.put(PresenceMessage(action: ENTER, clientId: "user-0", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "data-0")) +map.put(PresenceMessage(action: ENTER, clientId: "user-1", connectionId: "c1", id: "c1:1:0", timestamp: 100, data: "data-1")) +map.put(PresenceMessage(action: ENTER, clientId: "user-2", connectionId: "c1", id: "c1:2:0", timestamp: 100, data: "data-2")) + +ASSERT map.values().length == 3 + +# Server starts SYNC — members already exist from PRESENCE echoes +map.startSync() + +# SYNC messages arrive with the SAME ids as the PRESENCE echoes (stale) +map.put(PresenceMessage(action: PRESENT, clientId: "user-0", connectionId: "c1", id: "c1:0:0", timestamp: 100, data: "data-0")) +map.put(PresenceMessage(action: PRESENT, clientId: "user-1", connectionId: "c1", id: "c1:1:0", timestamp: 100, data: "data-1")) +map.put(PresenceMessage(action: PRESENT, clientId: "user-2", connectionId: "c1", id: "c1:2:0", timestamp: 100, data: "data-2")) + +leave_events = map.endSync() +``` + +### Assertions +```pseudo +# No members evicted — all were seen during sync despite stale ids +ASSERT leave_events.length == 0 +ASSERT map.values().length == 3 + +FOR i IN 0..2: + member = map.get("c1:user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" +``` + +--- + ## RTP19 - New member added during sync is not stale **Spec requirement:** A member can be added during the sync process. New members diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md index 61a35dd4b..fdfe08ddd 100644 --- a/uts/realtime/unit/presence/realtime_presence_enter.md +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -866,20 +866,23 @@ ASSERT captured_presence[2].presence[0].clientId == "other-user" --- -## RTP4 - 250 members via enterClient +## RTP4 - 50 members via enterClient (same connection) **Spec requirement:** Ensure a test exists that enters 250 members using RealtimePresence#enterClient on a single connection, and checks for PRESENT events -to be emitted on another connection for each member, and once sync is complete, all -250 members should be present in a RealtimePresence#get request. +to be emitted for each member, and once sync is complete, all members should be +present in a RealtimePresence#get request. Note: The spec says 250 but we use 50 as a practical test size that validates the same behavior (bulk enterClient, SYNC delivery, get correctness) without excessive test runtime. +This test variant uses a single connection that both enters members and subscribes +to presence. The server echoes ENTER events back on the same connection. + ### Setup ```pseudo -channel_name = "test-RTP4-${random_id()}" +channel_name = "test-RTP4-same-${random_id()}" member_count = 50 captured_presence = [] @@ -898,8 +901,8 @@ mock_ws = MockWebSocket( captured_presence.append(msg) mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) - # Server echoes back the ENTER as a PRESENCE event (as it would for a second client) - FOR p IN msg.presence: + # Server echoes back the ENTER as a PRESENCE event + FOR idx, p IN enumerate(msg.presence): mock_ws.send_to_client(ProtocolMessage( action: PRESENCE, channel: channel_name, @@ -908,8 +911,9 @@ mock_ws = MockWebSocket( action: ENTER, clientId: p.clientId, connectionId: "conn-1", - id: "conn-1:${msg.msgSerial}:0", - timestamp: NOW() + id: "conn-1:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data ) ] )) @@ -977,3 +981,141 @@ FOR i IN 0..member_count-1: ASSERT member IS NOT null ASSERT member.data == "data-${i}" ``` + +--- + +## RTP4 - 50 members via enterClient (different connections) + +**Spec requirement:** Same as above, but the original intent: one connection enters +members, a different connection observes the ENTER events and verifies all members +via get(). This is the more realistic scenario where one client populates presence +and another client discovers the members. + +### Setup +```pseudo +channel_name = "test-RTP4-diff-${random_id()}" +member_count = 50 + +# --- Connection A: the entering client --- +captured_presence_a = [] +mock_ws_a = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-A") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws_a.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + ELSE IF msg.action == PRESENCE: + captured_presence_a.append(msg) + mock_ws_a.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + } +) + +# --- Connection B: the observing client --- +mock_ws_b = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-B") + ), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws_b.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: HAS_PRESENCE + )) + } +) + +install_mock(mock_ws_a, client: "A") +install_mock(mock_ws_b, client: "B") + +client_a = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +client_b = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Connect and attach both clients +client_a.connect() +AWAIT_STATE client_a.connection.state == ConnectionState.connected +AWAIT channel_a.attach() + +client_b.connect() +AWAIT_STATE client_b.connection.state == ConnectionState.connected +AWAIT channel_b.attach() + +# Subscribe on client B to observe remote presence events +received_enters_b = [] +channel_b.presence.subscribe(action: ENTER, (event) => { + received_enters_b.append(event) +}) + +# Client A enters 50 members +FOR i IN 0..member_count-1: + AWAIT channel_a.presence.enterClient("user-${i}", data: "data-${i}") + +# Server delivers those ENTER events to client B as PRESENCE messages +# (In real Ably, the server broadcasts to all connections on the channel) +FOR i IN 0..member_count-1: + mock_ws_b.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + presence: [ + PresenceMessage( + action: ENTER, + clientId: "user-${i}", + connectionId: "conn-A", + id: "conn-A:${i}:0", + timestamp: NOW(), + data: "data-${i}" + ) + ] + )) + +# Server sends a SYNC to client B with all 50 members +sync_members = [] +FOR i IN 0..member_count-1: + sync_members.append(PresenceMessage( + action: PRESENT, + clientId: "user-${i}", + connectionId: "conn-A", + id: "conn-A:${i}:0", + timestamp: NOW(), + data: "data-${i}" + )) + +mock_ws_b.send_to_client(ProtocolMessage( + action: SYNC, + channel: channel_name, + channelSerial: "seq1:", + presence: sync_members +)) + +# Client B gets all members +members = AWAIT channel_b.presence.get() +``` + +### Assertions +```pseudo +# Client A sent all 50 presence messages +ASSERT captured_presence_a.length == member_count + +# Client B received all 50 ENTER events +ASSERT received_enters_b.length == member_count + +# All 50 members present via get() on client B +ASSERT members.length == member_count + +# Verify each member has correct data and connectionId from conn-A +FOR i IN 0..member_count-1: + member = members.find(m => m.clientId == "user-${i}") + ASSERT member IS NOT null + ASSERT member.data == "data-${i}" + ASSERT member.connectionId == "conn-A" +``` From a0df0697e5ca597227acbeea8ce1400eac240d83 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 21/32] Update write-test-spec skill to emphasise keeping specs in sync Extend the skill documentation to note the importance of keeping UTS portable test specs synchronised with language-specific tests. --- uts/.claude/skills/write-test-spec.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index b291c8df6..5bf5c6e3b 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -884,3 +884,13 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null 17. ❌ Loose path assertions: `ASSERT request.url.path CONTAINS "/channels/"` ✅ Exact path with encoding: `ASSERT request.url.path == "/channels/" + encode_uri_component(name) + "/messages"` + +18. ❌ Mock echo missing fields that the test later asserts on (e.g. omitting `data` from a PRESENCE echo, then asserting `member.data`) + ✅ Include all fields in the mock echo that the test assertions depend on + +### Keeping UTS and Dart Tests in Sync + +When a Dart test reveals a bug or gap in a UTS spec (or vice versa), **always update both**. Common cases: +- Mock missing a field (e.g. `data: p.data` in a PRESENCE echo) — fix in both +- Loop index bugs (e.g. hardcoded `:0` instead of `:${idx}`) — fix in both +- Dart-specific patterns (e.g. `authCallback` to avoid real HTTP for clientId) don't need UTS changes, but note the reason if the approaches differ significantly From 3de2c049da3484417b6f7b6bf8ae694cb0de6dd6 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 22/32] Add test specs for batch presence (RSP4) Add specs covering the batch presence API for retrieving presence state across multiple channels in a single request. --- uts/completion-status.md | 8 +- uts/rest/integration/batch_presence.md | 291 +++++++++++++++++++++++++ 2 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 uts/rest/integration/batch_presence.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 913256efa..90fa64e33 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -50,8 +50,8 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSC20 | Deprecated exception reporting (RSC20a–RSC20f) |N/A | | RSC21 | Push object attribute | | | RSC22 | BatchPublish (RSC22a–RSC22d) | Yes — `rest/unit/batch_publish.md` | -| RSC23 | Deleted | | -| RSC24 | BatchPresence | | +| RSC23 | Deleted | N/A | +| RSC24 | BatchPresence | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | | RSC25 | Request endpoint | Yes — `rest/unit/request_endpoint.md` | | RSC26 | CreateWrapperSDKProxy (RSC26a–RSC26c) | | @@ -341,10 +341,10 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | CD1–CD2 | ConnectionDetails | | | CP1–CP2 | ChannelProperties | | | CHD1–CHD2, CHS1–CHS2, CHO1–CHO2, CHM1–CHM2 | Channel status types | | -| BAR1–BAR2 | BatchResult | | +| BAR1–BAR2 | BatchResult | Partial — `rest/unit/batch_presence.md` covers BAR2 | | BSP1–BSP2 | BatchPublishSpec | | | BPR1–BPR2, BPF1–BPF2 | BatchPublish result types | | -| BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | | +| BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | | PBR1–PBR2 | PublishResult | Yes — `realtime/unit/channels/channel_publish.md` | | UDR1–UDR2 | UpdateDeleteResult | | | TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | | diff --git a/uts/rest/integration/batch_presence.md b/uts/rest/integration/batch_presence.md new file mode 100644 index 000000000..49c919b9e --- /dev/null +++ b/uts/rest/integration/batch_presence.md @@ -0,0 +1,291 @@ +# Batch Presence Integration Tests + +Spec points: `RSC24`, `BGR2`, `BGF2` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification of `RestClient#batchPresence` against the Ably sandbox. +Client A enters presence members via Realtime, then the REST client calls +`batchPresence` and verifies the response structure and content. + +These tests complement the unit tests (which use mock HTTP) by verifying that the +real server returns correct batch presence responses, including per-channel success +and failure results. + +## Server Response Format + +The Ably server returns batch presence in two formats depending on success: + +- **All success (HTTP 200):** Body is a **plain array** of per-channel results: + `[{"channel": "ch1", "presence": [...]}, {"channel": "ch2", "presence": [...]}]` + +- **Mixed success/failure (HTTP 400):** Body is an object with an `error` field + and a `batchResponse` array: + `{"error": {"code": 40020, ...}, "batchResponse": [{"channel": "ch1", "presence": [...]}, {"channel": "ch2", "error": {...}}]}` + +The `successCount` and `failureCount` fields (BAR2a, BAR2b) are computed +client-side from the per-channel results, not returned by the server. + +## Test Environment + +### Prerequisites +- Ably sandbox app provisioned via `POST https://sandbox-rest.ably.io/apps` +- Two keys: one with full access, one with restricted capability + +### App Configuration + +The restricted key uses an **explicit channel name** (not a wildcard pattern). +Wildcard capability patterns (e.g. `"allowed-*"`) do not work reliably with the +batch presence endpoint. + +```json +{ + "keys": [ + { + "capability": "{\"*\":[\"*\"]}" + }, + { + "capability": "{\"batch-allowed\":[\"*\"]}" + } + ] +} +``` + +### Setup Pattern +```pseudo +BEFORE ALL TESTS: + app_config = provision_sandbox_app(config_with_multiple_keys) + app_id = app_config.app_id + full_access_key = app_config.keys[0].key_str + restricted_key = app_config.keys[1].key_str + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +--- + +## RSC24, BGR2 - batchPresence returns members across multiple channels + +**Spec requirement:** `batchPresence` sends a GET to `/presence` with a `channels` +query parameter and returns a `BatchResult` containing per-channel presence data. +Each successful result contains the channel name and an array of `PresenceMessage`. + +This test enters members on two channels via Realtime, then queries both channels +in a single `batchPresence` call via REST and verifies the returned members. + +### Setup +```pseudo +channel_a_name = "batch-presence-a-" + random_id() +channel_b_name = "batch-presence-b-" + random_id() + +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Connect and enter members on two channels +realtime.connect() +AWAIT_STATE realtime.connection.state == ConnectionState.connected + +ch_a = realtime.channels.get(channel_a_name) +AWAIT ch_a.attach() +AWAIT ch_a.presence.enterClient("user-1", data: "data-a1") +AWAIT ch_a.presence.enterClient("user-2", data: "data-a2") + +ch_b = realtime.channels.get(channel_b_name) +AWAIT ch_b.attach() +AWAIT ch_b.presence.enterClient("user-3", data: "data-b1") + +# Query via REST batchPresence (keep realtime open so presence persists) +rest = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +result = AWAIT rest.batchPresence([channel_a_name, channel_b_name]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 + +# Find results by channel name +result_a = result.results.find(r => r.channel == channel_a_name) +result_b = result.results.find(r => r.channel == channel_b_name) + +ASSERT result_a IS BatchPresenceSuccessResult +ASSERT result_a.presence.length == 2 +client_ids_a = [m.clientId FOR m IN result_a.presence] +ASSERT "user-1" IN client_ids_a +ASSERT "user-2" IN client_ids_a + +# Verify data round-trips correctly +member_1 = result_a.presence.find(m => m.clientId == "user-1") +ASSERT member_1.data == "data-a1" + +ASSERT result_b IS BatchPresenceSuccessResult +ASSERT result_b.presence.length == 1 +ASSERT result_b.presence[0].clientId == "user-3" +ASSERT result_b.presence[0].data == "data-b1" +``` + +### Cleanup +```pseudo +AWAIT realtime.close() +``` + +--- + +## RSC24, BGF2 - Restricted key returns per-channel failure for unauthorized channels + +**Spec requirement:** When a key lacks capability for a channel, the per-channel +result is a `BatchPresenceFailureResult` containing an `ErrorInfo`. Channels the key +does have access to return success results in the same batch response. + +The server returns HTTP 400 with `{"error": {"code": 40020, ...}, "batchResponse": [...]}` +when the batch contains any per-channel errors. The client extracts the `batchResponse` +array and builds results from it. + +### Setup +```pseudo +# Use the fixed channel name matching the restricted key capability +allowed_channel = "batch-allowed" +denied_channel = "denied-batch-" + random_id() + +# Enter members on both channels using the full-access key +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +realtime.connect() +AWAIT_STATE realtime.connection.state == ConnectionState.connected + +ch_allowed = realtime.channels.get(allowed_channel) +AWAIT ch_allowed.attach() +AWAIT ch_allowed.presence.enterClient("member-1", data: "hello") + +ch_denied = realtime.channels.get(denied_channel) +AWAIT ch_denied.attach() +AWAIT ch_denied.presence.enterClient("member-2", data: "world") + +AWAIT realtime.close() +``` + +### Test Steps +```pseudo +# Query with restricted key (only has access to "batch-allowed" channel) +restricted_rest = Rest(options: ClientOptions( + key: restricted_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +result = AWAIT restricted_rest.batchPresence([allowed_channel, denied_channel]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 1 +ASSERT result.failureCount == 1 +ASSERT result.results.length == 2 + +# Find results by channel name +success = result.results.find(r => r.channel == allowed_channel) +failure = result.results.find(r => r.channel == denied_channel) + +# Allowed channel succeeds with presence data +ASSERT success IS BatchPresenceSuccessResult +ASSERT success.presence.length == 1 +ASSERT success.presence[0].clientId == "member-1" + +# Denied channel fails with capability error +ASSERT failure IS BatchPresenceFailureResult +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.code == 40160 +ASSERT failure.error.statusCode == 401 +``` + +### Cleanup + +No cleanup needed — the Realtime client was already closed during setup, +and the REST client has no persistent connection to close. + +--- + +## RSC24 - batchPresence with empty channel returns empty presence array + +**Spec requirement:** A channel with no presence members returns a success result +with an empty `presence` array. + +### Setup +```pseudo +empty_channel = "batch-empty-" + random_id() +populated_channel = "batch-populated-" + random_id() + +# Enter a member on only the populated channel +realtime = Realtime(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +realtime.connect() +AWAIT_STATE realtime.connection.state == ConnectionState.connected + +ch = realtime.channels.get(populated_channel) +AWAIT ch.attach() +AWAIT ch.presence.enterClient("someone", data: "here") + +# NOTE: Keep realtime open during the REST query so the presence member +# persists on the server. Closing realtime before the query would cause +# the member to leave. +``` + +### Test Steps +```pseudo +rest = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +result = AWAIT rest.batchPresence([empty_channel, populated_channel]) +``` + +### Assertions +```pseudo +ASSERT result.successCount == 2 +ASSERT result.failureCount == 0 +ASSERT result.results.length == 2 + +empty_result = result.results.find(r => r.channel == empty_channel) +populated_result = result.results.find(r => r.channel == populated_channel) + +# Empty channel succeeds with no members +ASSERT empty_result IS BatchPresenceSuccessResult +ASSERT empty_result.presence.length == 0 + +# Populated channel succeeds with the member +ASSERT populated_result IS BatchPresenceSuccessResult +ASSERT populated_result.presence.length == 1 +ASSERT populated_result.presence[0].clientId == "someone" +``` + +### Cleanup +```pseudo +AWAIT realtime.close() +``` From dc783aa5c4b77f3b8626705a0ed89f663bd68ee1 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 23/32] Add test specs for token revocation (RSA10) Add specs covering the revokeTokens API for invalidating issued authentication tokens. --- uts/completion-status.md | 14 +- uts/realtime/integration/auth.md | 15 +- .../integration/presence_lifecycle_test.md | 21 +- uts/rest/integration/auth.md | 17 +- uts/rest/integration/batch_presence.md | 39 +-- uts/rest/integration/history.md | 273 +++++++++++++++ uts/rest/integration/pagination.md | 280 ++++++++++++++++ uts/rest/integration/presence.md | 47 +-- uts/rest/integration/publish.md | 46 +-- uts/rest/integration/revoke_tokens.md | 311 ++++++++++++++++++ uts/rest/integration/time_stats.md | 127 +++++++ 11 files changed, 1084 insertions(+), 106 deletions(-) create mode 100644 uts/rest/integration/history.md create mode 100644 uts/rest/integration/pagination.md create mode 100644 uts/rest/integration/revoke_tokens.md create mode 100644 uts/rest/integration/time_stats.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 90fa64e33..4ceb99f0d 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -74,7 +74,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSA14 | Error when token auth selected without token | Yes — `rest/unit/auth/token_renewal.md`, `rest/integration/auth.md` | | RSA15 | ClientId validation (RSA15a–RSA15c) | Yes — `rest/unit/auth/client_id.md`, `realtime/integration/auth.md` (RSA15c Realtime case) | | RSA16 | TokenDetails attribute (RSA16a–RSA16d) | Yes — `rest/unit/auth/token_details.md` | -| RSA17 | RevokeTokens (RSA17a–RSA17g) | | +| RSA17 | RevokeTokens (RSA17a–RSA17g) | Yes — `rest/unit/auth/revoke_tokens.md`, `rest/integration/revoke_tokens.md` | ### Channels (REST) @@ -114,7 +114,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| | RSP1 | Associated with single channel | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | -| RSP2 | No presence registration via REST | | +| RSP2 | No presence registration via REST | Information only | | RSP3 | Get function (RSP3a–RSP3a3) | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | | RSP4 | History function (RSP4a–RSP4b3) | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | | RSP5 | Presence message decoding | Yes — `rest/unit/presence/rest_presence.md`, `rest/integration/presence.md` | @@ -155,7 +155,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC7 | Uses configured timeouts | | | RTC8 | Authorize function for realtime (RTC8a–RTC8c) | Yes — `realtime/unit/auth/realtime_authorize.md`, `realtime/integration/auth.md` | | RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | -| RTC10–RTC11 | Deleted | | +| RTC10–RTC11 | Deleted | N/A | | RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | | RTC13 | Push object attribute | | | RTC14 | CreateWrapperSDKProxy (RTC14a–RTC14c) | | @@ -167,12 +167,12 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RTN1 | Uses websocket connection | | +| RTN1 | Uses websocket connection | Information only | | RTN2 | Default host and query string params (RTN2a–RTN2g) | Partial — `realtime/unit/auth/connection_auth_test.md` covers RTN2e | | RTN3 | AutoConnect option | | | RTN4 | Connection event emission (RTN4a–RTN4i) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN4b, RTN4c; `realtime/unit/connection/update_events_test.md` covers RTN4h | | RTN5 | Concurrency test (50+ clients) | | -| RTN6 | Successful connection definition | | +| RTN6 | Successful connection definition | Information only| | RTN7 | ACK and NACK handling (RTN7a–RTN7e) | Yes — `realtime/unit/channels/channel_publish.md` covers RTN7a, RTN7b (via RTL6j tests), RTN7d, RTN7e | | RTN8 | Connection#id attribute (RTN8a–RTN8c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | | RTN9 | Connection#key attribute (RTN9a–RTN9c) | Yes — `realtime/unit/connection/connection_id_key_test.md` | @@ -347,7 +347,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | | PBR1–PBR2 | PublishResult | Yes — `realtime/unit/channels/channel_publish.md` | | UDR1–UDR2 | UpdateDeleteResult | | -| TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | | +| TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | Yes — `rest/unit/auth/revoke_tokens.md` | | MFI1–MFI2 | MessageFilter | | | REX1–REX2 | ReferenceExtras | | @@ -409,7 +409,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **Wrapper SDK** (WP) | 7 | 0 | None | | **Push notifications** (RSH) | 8 | 0 | None | | **Plugins** (PC/PT/VD) | 3 | 0 | None | -| **Data types** | 30 | 8 | Partial | +| **Data types** | 30 | 9 | Partial | | **Option types** | 8 | 5 | Partial | | **Push types** | 3 | 0 | None | | **Introspection** (CR) | 1 | 0 | None | diff --git a/uts/realtime/integration/auth.md b/uts/realtime/integration/auth.md index a91269ccd..663ef3757 100644 --- a/uts/realtime/integration/auth.md +++ b/uts/realtime/integration/auth.md @@ -9,17 +9,18 @@ Integration test against Ably sandbox Tests use JWTs generated using a third-party JWT library, signed with the app key secret using HMAC-SHA256. -## Test Environment +## Sandbox Setup -### Prerequisites -- Ably sandbox app provisioned via `POST https://sandbox-rest.ably.io/apps` -- API key from provisioned app -- Channel names must be unique per test (see README for naming convention) +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning -### Setup Pattern ```pseudo BEFORE ALL TESTS: - app_config = provision_sandbox_app() + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) api_key = app_config.keys[0].key_str app_id = app_config.app_id diff --git a/uts/realtime/integration/presence_lifecycle_test.md b/uts/realtime/integration/presence_lifecycle_test.md index 9accb0bbf..b5baa6461 100644 --- a/uts/realtime/integration/presence_lifecycle_test.md +++ b/uts/realtime/integration/presence_lifecycle_test.md @@ -14,21 +14,22 @@ presence events via subscribe and verifies member state via get(). These tests complement the unit tests by verifying that the real server correctly broadcasts presence events, delivers SYNC data, and maintains presence state. -## Test Environment +## Sandbox Setup -### Prerequisites -- Ably sandbox app provisioned via `POST https://sandbox-rest.ably.io/apps` -- API key with `{"*":["*"]}` capability -- `useBinaryProtocol: false` (SDK does not implement msgpack) +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +**Note:** `useBinaryProtocol: false` is required if the SDK does not implement msgpack. + +### App Provisioning -### Setup Pattern ```pseudo BEFORE ALL TESTS: - app_config = provision_sandbox_app( - keys: [{ capability: '{"*":["*"]}' }] - ) - app_id = app_config.app_id + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) api_key = app_config.keys[0].key_str + app_id = app_config.app_id AFTER ALL TESTS: DELETE https://sandbox-rest.ably.io/apps/{app_id} diff --git a/uts/rest/integration/auth.md b/uts/rest/integration/auth.md index 9a0bab544..08cd0a72e 100644 --- a/uts/rest/integration/auth.md +++ b/uts/rest/integration/auth.md @@ -13,22 +13,23 @@ All tests in this file should be run with **both**: JWT should be the primary token format. See README for details. -## Test Environment +## Sandbox Setup -### Prerequisites -- Ably sandbox app provisioned via `POST https://sandbox.realtime.ably-nonprod.net/apps` -- API key from provisioned app -- Channel names must be unique per test (see README for naming convention) +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning -### Setup Pattern ```pseudo BEFORE ALL TESTS: - app_config = provision_sandbox_app() + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) api_key = app_config.keys[0].key_str app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + DELETE https://sandbox-rest.ably.io/apps/{app_id} WITH Authorization: Basic {api_key} ``` diff --git a/uts/rest/integration/batch_presence.md b/uts/rest/integration/batch_presence.md index 49c919b9e..a26907066 100644 --- a/uts/rest/integration/batch_presence.md +++ b/uts/rest/integration/batch_presence.md @@ -29,38 +29,29 @@ The Ably server returns batch presence in two formats depending on success: The `successCount` and `failureCount` fields (BAR2a, BAR2b) are computed client-side from the per-channel results, not returned by the server. -## Test Environment +## Sandbox Setup -### Prerequisites -- Ably sandbox app provisioned via `POST https://sandbox-rest.ably.io/apps` -- Two keys: one with full access, one with restricted capability +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. -### App Configuration +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[2]` — per-channel capabilities including `"channel6":["*"]` The restricted key uses an **explicit channel name** (not a wildcard pattern). Wildcard capability patterns (e.g. `"allowed-*"`) do not work reliably with the batch presence endpoint. -```json -{ - "keys": [ - { - "capability": "{\"*\":[\"*\"]}" - }, - { - "capability": "{\"batch-allowed\":[\"*\"]}" - } - ] -} -``` - -### Setup Pattern ```pseudo BEFORE ALL TESTS: - app_config = provision_sandbox_app(config_with_multiple_keys) - app_id = app_config.app_id + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) full_access_key = app_config.keys[0].key_str - restricted_key = app_config.keys[1].key_str + restricted_key = app_config.keys[2].key_str # has "channel6":["*"] + app_id = app_config.app_id AFTER ALL TESTS: DELETE https://sandbox-rest.ably.io/apps/{app_id} @@ -160,8 +151,8 @@ array and builds results from it. ### Setup ```pseudo -# Use the fixed channel name matching the restricted key capability -allowed_channel = "batch-allowed" +# Use the fixed channel name matching keys[2] capability from ably-common +allowed_channel = "channel6" denied_channel = "denied-batch-" + random_id() # Enter members on both channels using the full-access key diff --git a/uts/rest/integration/history.md b/uts/rest/integration/history.md new file mode 100644 index 000000000..f84c50604 --- /dev/null +++ b/uts/rest/integration/history.md @@ -0,0 +1,273 @@ +# REST Channel History Integration Tests + +Spec points: `RSL2a`, `RSL2b`, `RSL2b1`, `RSL2b2`, `RSL2b3` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSL2a - History returns published messages + +**Spec requirement:** RSL2a - `history` returns a `PaginatedResult` containing messages for the channel. + +Tests that published messages appear in channel history. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "history-test-RSL2a-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish some messages +AWAIT channel.publish(name: "event1", data: "data1") +AWAIT channel.publish(name: "event2", data: "data2") +AWAIT channel.publish(name: "event3", data: { "key": "value" }) + +# Poll until messages appear in history +history = poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT history.items.length == 3 + +# Default order is backwards (newest first) +ASSERT history.items[0].name == "event3" +ASSERT history.items[0].data == { "key": "value" } + +ASSERT history.items[1].name == "event2" +ASSERT history.items[1].data == "data2" + +ASSERT history.items[2].name == "event1" +ASSERT history.items[2].data == "data1" + +# All messages should have timestamps +ASSERT ALL msg IN history.items: msg.timestamp IS NOT null +``` + +--- + +## RSL2b1 - History direction forwards + +**Spec requirement:** RSL2b1 - `direction` param controls message ordering (forwards = oldest first). + +Tests that `direction: forwards` returns messages oldest-first. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "history-direction-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish messages - ordering is determined by server timestamp +AWAIT channel.publish(name: "first", data: "1") +AWAIT channel.publish(name: "second", data: "2") +AWAIT channel.publish(name: "third", data: "3") + +# Poll until all messages appear +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) + +history = AWAIT channel.history(direction: "forwards") +``` + +### Assertions +```pseudo +ASSERT history.items.length == 3 +ASSERT history.items[0].name == "first" +ASSERT history.items[1].name == "second" +ASSERT history.items[2].name == "third" +``` + +--- + +## RSL2b2 - History limit parameter + +**Spec requirement:** RSL2b2 - `limit` param restricts the number of messages returned. + +Tests that `limit` parameter restricts number of returned messages. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "history-limit-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish multiple messages +FOR i IN 1..10: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 10, + interval: 500ms, + timeout: 10s +) + +history = AWAIT channel.history(limit: 5) +``` + +### Assertions +```pseudo +ASSERT history.items.length == 5 + +# Should get the 5 most recent (backwards direction by default) +ASSERT history.items[0].name == "event-10" +ASSERT history.items[4].name == "event-6" +``` + +--- + +## RSL2b3 - History time range parameters + +**Spec requirement:** RSL2b3 - `start` and `end` params filter messages by timestamp range. + +Tests that `start` and `end` parameters filter messages by time. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "history-timerange-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Record start time +time_before = now() + +# Publish some messages +AWAIT channel.publish(name: "early1", data: "e1") +AWAIT channel.publish(name: "early2", data: "e2") + +# Record middle time +time_middle = now() + +AWAIT channel.publish(name: "late1", data: "l1") +AWAIT channel.publish(name: "late2", data: "l2") + +# Record end time +time_after = now() + +# Poll until all messages appear +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 4, + interval: 500ms, + timeout: 10s +) + +# Query only early messages +early_history = AWAIT channel.history( + start: time_before, + end: time_middle +) + +# Query only late messages +late_history = AWAIT channel.history( + start: time_middle, + end: time_after +) +``` + +### Assertions +```pseudo +# Note: Due to timing precision, exact counts may vary +# The key test is that filtering by time range works +ASSERT early_history.items.length >= 1 +ASSERT late_history.items.length >= 1 + +# Early messages should contain "early" names +ASSERT ANY msg IN early_history.items: msg.name STARTS WITH "early" + +# Late messages should contain "late" names +ASSERT ANY msg IN late_history.items: msg.name STARTS WITH "late" +``` + +--- + +## RSL2 - History on channel with no messages + +**Spec requirement:** RSL2a - `history` returns empty `PaginatedResult` when channel has no messages. + +Tests that history on an empty channel returns empty result. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +# Use a fresh channel with no messages +channel_name = "history-empty-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +history = AWAIT channel.history() +``` + +### Assertions +```pseudo +ASSERT history.items IS List +ASSERT history.items.length == 0 +ASSERT history.hasNext() == false +ASSERT history.isLast() == true +``` diff --git a/uts/rest/integration/pagination.md b/uts/rest/integration/pagination.md new file mode 100644 index 000000000..05cd6d78e --- /dev/null +++ b/uts/rest/integration/pagination.md @@ -0,0 +1,280 @@ +# Pagination Integration Tests + +Spec points: `TG1`, `TG2`, `TG3`, `TG4`, `TG5` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## TG1, TG2 - PaginatedResult items and navigation + +| Spec ID | Requirement | +|---------|-------------| +| TG1 | `items` property contains array of results for current page | +| TG2 | `hasNext()` and `isLast()` indicate availability of more pages | + +Tests that `PaginatedResult` contains items and provides navigation methods. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "pagination-basic-" + random_id() +channel = client.channels.get(channel_name) + +# Publish enough messages to require pagination +FOR i IN 1..15: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 15, + interval: 500ms, + timeout: 15s +) +``` + +### Test Steps +```pseudo +# Request with small limit to force pagination +page1 = AWAIT channel.history(limit: 5) +``` + +### Assertions +```pseudo +# TG1 - items contains array of results +ASSERT page1.items IS List +ASSERT page1.items.length == 5 + +# TG2 - hasNext/isLast indicate more pages +ASSERT page1.hasNext() == true +ASSERT page1.isLast() == false +``` + +--- + +## TG3 - next() retrieves subsequent page + +**Spec requirement:** TG3 - `next()` returns a new `PaginatedResult` for the next page of results. + +Tests that `next()` retrieves the next page of results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "pagination-next-" + random_id() +channel = client.channels.get(channel_name) + +# Publish messages +FOR i IN 1..12: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 12, + interval: 500ms, + timeout: 15s +) +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history(limit: 5) +page2 = AWAIT page1.next() +page3 = AWAIT page2.next() +``` + +### Assertions +```pseudo +ASSERT page1.items.length == 5 +ASSERT page2.items.length == 5 +ASSERT page3.items.length == 2 # Remaining messages + +# Verify no duplicate messages across pages +all_ids = [] +FOR page IN [page1, page2, page3]: + FOR item IN page.items: + ASSERT item.id NOT IN all_ids + all_ids.append(item.id) + +ASSERT all_ids.length == 12 +``` + +--- + +## TG4 - first() retrieves first page + +**Spec requirement:** TG4 - `first()` returns a new `PaginatedResult` for the first page of results. + +Tests that `first()` returns to the first page of results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "pagination-first-" + random_id() +channel = client.channels.get(channel_name) + +FOR i IN 1..10: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 10, + interval: 500ms, + timeout: 15s +) +``` + +### Test Steps +```pseudo +page1 = AWAIT channel.history(limit: 3) +page2 = AWAIT page1.next() +first_page = AWAIT page2.first() +``` + +### Assertions +```pseudo +# first_page should have same items as page1 +ASSERT first_page.items.length == page1.items.length + +FOR i IN 0..first_page.items.length: + ASSERT first_page.items[i].id == page1.items[i].id +``` + +--- + +## TG5 - Iterate through all pages + +**Spec requirement:** TG5 - Pagination methods enable iteration through complete result set. + +Tests iteration through entire result set using pagination. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "pagination-iterate-" + random_id() +channel = client.channels.get(channel_name) + +# Publish known set of messages +message_count = 25 +FOR i IN 1..message_count: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until all messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == message_count, + interval: 500ms, + timeout: 30s +) +``` + +### Test Steps +```pseudo +all_messages = [] +page = AWAIT channel.history(limit: 7) + +WHILE true: + all_messages.extend(page.items) + + IF NOT page.hasNext(): + BREAK + + page = AWAIT page.next() +``` + +### Assertions +```pseudo +ASSERT all_messages.length == message_count + +# Verify all messages retrieved +event_names = [msg.name FOR msg IN all_messages] +FOR i IN 1..message_count: + ASSERT "event-" + str(i) IN event_names +``` + +--- + +## TG - next() on last page returns null + +**Spec requirement:** TG3 - `next()` returns null when called on the last page. + +Tests behavior when calling `next()` on the last page. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +channel_name = "pagination-lastnext-" + random_id() +channel = client.channels.get(channel_name) + +# Publish just a few messages +FOR i IN 1..3: + AWAIT channel.publish(name: "event-" + str(i), data: str(i)) + +# Poll until messages are persisted +poll_until( + condition: FUNCTION() => + result = AWAIT channel.history() + RETURN result.items.length == 3, + interval: 500ms, + timeout: 10s +) +``` + +### Test Steps +```pseudo +page = AWAIT channel.history(limit: 10) # Larger than message count +``` + +### Assertions +```pseudo +ASSERT page.items.length == 3 +ASSERT page.hasNext() == false +ASSERT page.isLast() == true + +# Calling next() should return null (or empty result) +next_page = AWAIT page.next() +ASSERT next_page IS null OR next_page.items.length == 0 +``` diff --git a/uts/rest/integration/presence.md b/uts/rest/integration/presence.md index 922614cb4..b13b3ebae 100644 --- a/uts/rest/integration/presence.md +++ b/uts/rest/integration/presence.md @@ -5,16 +5,35 @@ Spec points: `RSP1`, `RSP3`, `RSP3a`, `RSP4`, `RSP4b`, `RSP5` ## Test Type Integration test against Ably sandbox -## Test Environment +## Sandbox Setup -### Prerequisites -- Ably sandbox app provisioned via `POST https://sandbox.realtime.ably-nonprod.net/apps` -- API key from provisioned app -- Channel names must be unique per test (see README for naming convention) +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. -### Sandbox Presence Fixtures +### App Provisioning -The sandbox test app (from `ably-common/test-resources/test-app-setup.json`) includes pre-populated presence members on the channel `persisted:presence_fixtures`: +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[3]` — subscribe-only (`{"*":["subscribe"]}`) +- Pre-populated presence fixtures on `persisted:presence_fixtures` channel +- Cipher configuration for encrypted fixture data + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Presence Fixtures + +The `ably-common/test-resources/test-app-setup.json` includes pre-populated presence members on the channel `persisted:presence_fixtures`: | clientId | data | encoding | |----------|------|----------| @@ -25,25 +44,13 @@ The sandbox test app (from `ably-common/test-resources/test-app-setup.json`) inc | `client_decoded` | `{"example":{"json":"Object"}}` | `json` | | `client_encoded` | (encrypted) | `json/utf-8/cipher+aes-128-cbc/base64` | -**Cipher configuration** for `client_encoded`: +**Cipher configuration** for `client_encoded` (from `test-app-setup.json` `cipher` section): - Algorithm: `aes` - Mode: `cbc` - Key length: 128 - Key (base64): `WUP6u0K7MXI5Zeo0VppPwg==` - IV (base64): `HO4cYSP8LybPYBPZPHQOtg==` -### Setup Pattern -```pseudo -BEFORE ALL TESTS: - app_config = provision_sandbox_app() - app_id = app_config.app_id - api_key = app_config.keys[0].key_str - -AFTER ALL TESTS: - DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} - WITH Authorization: Basic {api_key} -``` - --- ## RSP1 - RestPresence accessible via channel diff --git a/uts/rest/integration/publish.md b/uts/rest/integration/publish.md index 12ac309c1..ca0b3e7ec 100644 --- a/uts/rest/integration/publish.md +++ b/uts/rest/integration/publish.md @@ -5,42 +5,28 @@ Spec points: `RSL1d`, `RSL1l1`, `RSL1m4`, `RSL1n` ## Test Type Integration test against Ably sandbox -## Test Environment - -### Prerequisites -- Ably sandbox app provisioned via `POST https://sandbox.realtime.ably-nonprod.net/apps` -- App must include multiple keys with different capabilities (see below) -- Channel names must be unique per test (see README for naming convention) - -### App Configuration - -The sandbox app must be provisioned with keys that have different capabilities: - -```json -{ - "keys": [ - { - "name": "full-access", - "capability": "{\"*\":[\"*\"]}" - }, - { - "name": "restricted", - "capability": "{\"allowed-channel\":[\"publish\",\"subscribe\"]}" - } - ] -} -``` +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[2]` — per-channel capabilities including `"channel2":["publish","subscribe"]` -### Setup Pattern ```pseudo BEFORE ALL TESTS: - app_config = provision_sandbox_app(config_with_multiple_keys) - app_id = app_config.app_id + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) full_access_key = app_config.keys[0].key_str - restricted_key = app_config.keys[1].key_str # Limited capabilities + restricted_key = app_config.keys[2].key_str # per-channel capabilities + app_id = app_config.app_id AFTER ALL TESTS: - DELETE https://sandbox.realtime.ably-nonprod.net/apps/{app_id} + DELETE https://sandbox-rest.ably.io/apps/{app_id} WITH Authorization: Basic {full_access_key} ``` diff --git a/uts/rest/integration/revoke_tokens.md b/uts/rest/integration/revoke_tokens.md new file mode 100644 index 000000000..d9af5bdde --- /dev/null +++ b/uts/rest/integration/revoke_tokens.md @@ -0,0 +1,311 @@ +# Revoke Tokens Integration Tests + +Spec points: `RSA17`, `RSA17b`, `RSA17c`, `RSA17d`, `RSA17e`, `RSA17f`, `RSA17g`, `TRS2`, `TRF2` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification of `Auth#revokeTokens` against the Ably sandbox. +These tests verify that token revocation actually prevents subsequent use +of the revoked token, in addition to confirming the response format. + +## Token Format + +All tests use JWTs generated using a third-party JWT library, signed with +the key secret using HMAC-SHA256. This avoids needing to call `requestToken()` +and keeps the tests self-contained. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[4]` — `revocableTokens: true` (required for the revokeTokens endpoint) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + revocable_key = app_config.keys[4].key_str # revocableTokens: true + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +--- + +## RSA17g, RSA17b, RSA17c, TRS2 - Token revocation prevents subsequent use + +**Spec requirement:** `Auth#revokeTokens` sends a POST to +`/keys/{keyName}/revokeTokens` with `targets` as `type:value` strings, and +returns a result containing per-target success information. After revocation, +the token must be rejected by the server. + +| Spec | Requirement | +|------|-------------| +| RSA17g | POST to `/keys/{keyName}/revokeTokens` | +| RSA17b | Targets mapped to `type:value` strings | +| RSA17c | Returns `BatchResult` with `successCount`, `failureCount`, `results` | +| TRS2a | Success result contains `target` string | +| TRS2b | Success result contains `appliesAt` timestamp | +| TRS2c | Success result contains `issuedBefore` timestamp | + +### Setup +```pseudo +channel_name = "revoke-test-" + random_id() +client_id = "revoke-client-" + random_id() + +# Generate a JWT with the revocable key, bound to a specific clientId +jwt = generate_jwt( + key_name: extract_key_name(revocable_key), + key_secret: extract_key_secret(revocable_key), + client_id: client_id, + ttl: 3600000 +) + +# Create a REST client using the JWT +token_client = Rest(options: ClientOptions( + token: jwt, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +# Create a key-auth REST client for revoking +key_client = Rest(options: ClientOptions( + key: revocable_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Step 1: Verify the JWT works — channel status request succeeds +result_before = AWAIT token_client.request("GET", "/channels/" + channel_name) +ASSERT result_before.statusCode >= 200 AND result_before.statusCode < 300 + +# Step 2: Revoke the token by clientId +revoke_result = AWAIT key_client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: client_id) +]) + +# Step 3: Verify the revokeTokens response structure (RSA17c, TRS2) +ASSERT revoke_result.successCount == 1 +ASSERT revoke_result.failureCount == 0 +ASSERT revoke_result.results.length == 1 + +success = revoke_result.results[0] +ASSERT success IS TokenRevocationSuccessResult +ASSERT success.target == "clientId:" + client_id +ASSERT success.issuedBefore IS number +ASSERT success.appliesAt IS number + +# Step 4: Wait for revocation to take effect +# appliesAt indicates when the revocation is enforced +WAIT UNTIL now() >= success.appliesAt + +# Step 5: Verify the JWT is now rejected +AWAIT token_client.request("GET", "/channels/" + channel_name) FAILS WITH error +ASSERT error.code == 40141 +``` + +--- + +## RSA17d - Token auth client rejected + +**Spec requirement:** If called from a client using token authentication, +should raise an error with code `40162` and status code `401`. This is a +client-side check — no HTTP request is made to the server. + +### Setup +```pseudo +# Generate a JWT using the revocable key +jwt = generate_jwt( + key_name: extract_key_name(revocable_key), + key_secret: extract_key_secret(revocable_key), + ttl: 3600000 +) + +# Create a client using token auth (JWT) +token_rest = Rest(options: ClientOptions( + token: jwt, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +AWAIT token_rest.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: "anyone") +]) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40162 +ASSERT error.statusCode == 401 +``` + +--- + +## RSA17e, RSA17f - issuedBefore and allowReauthMargin with verification + +| Spec | Requirement | +|------|-------------| +| RSA17e | Optional `issuedBefore` timestamp in milliseconds | +| RSA17f | Optional `allowReauthMargin` boolean delays revocation by ~30 seconds | + +**Spec requirement:** When `issuedBefore` is provided, only tokens issued before +that timestamp are revoked. When `allowReauthMargin` is true, the revocation is +delayed by approximately 30 seconds to allow token renewal. + +### Setup +```pseudo +channel_name = "revoke-margin-" + random_id() +client_id = "revoke-margin-client-" + random_id() + +# Generate a JWT with the revocable key, bound to a specific clientId +jwt = generate_jwt( + key_name: extract_key_name(revocable_key), + key_secret: extract_key_secret(revocable_key), + client_id: client_id, + ttl: 3600000 +) + +token_client = Rest(options: ClientOptions( + token: jwt, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +key_client = Rest(options: ClientOptions( + key: revocable_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Step 1: Verify the JWT works +result_before = AWAIT token_client.request("GET", "/channels/" + channel_name) +ASSERT result_before.statusCode >= 200 AND result_before.statusCode < 300 + +# Step 2: Revoke with issuedBefore and allowReauthMargin +server_time = AWAIT key_client.time() + +revoke_result = AWAIT key_client.auth.revokeTokens( + [TokenRevocationTargetSpecifier(type: "clientId", value: client_id)], + options: { issuedBefore: server_time, allowReauthMargin: true } +) + +ASSERT revoke_result.successCount == 1 +ASSERT revoke_result.results.length == 1 + +# RSA17e: issuedBefore should reflect what we sent +ASSERT revoke_result.results[0].issuedBefore == server_time + +# RSA17f: allowReauthMargin delays appliesAt by ~30 seconds +applies_at = revoke_result.results[0].appliesAt +ASSERT applies_at > server_time + (30 * 1000) + +# Step 3: Wait for revocation to take effect +WAIT UNTIL now() >= applies_at + +# Step 4: Verify the JWT is now rejected +AWAIT token_client.request("GET", "/channels/" + channel_name) FAILS WITH error +ASSERT error.code == 40141 +``` + +--- + +## RSA17c, TRF2 - Mixed success and failure (invalid specifier type) + +**Spec requirement:** The response can contain both successful and failed +per-target results. An invalid target type produces a failure result with +an `ErrorInfo`. + +| Spec | Requirement | +|------|-------------| +| RSA17c | `BatchResult` with `successCount` and `failureCount` | +| TRF2a | Failure result contains `target` string | +| TRF2b | Failure result contains `error` ErrorInfo | + +This test includes an invalid specifier type alongside a valid one, to +verify the server returns per-target error information. The valid revocation +is also verified by confirming the token is rejected afterwards. + +### Setup +```pseudo +channel_name = "revoke-mixed-" + random_id() +client_id = "revoke-mixed-client-" + random_id() + +jwt = generate_jwt( + key_name: extract_key_name(revocable_key), + key_secret: extract_key_secret(revocable_key), + client_id: client_id, + ttl: 3600000 +) + +token_client = Rest(options: ClientOptions( + token: jwt, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +key_client = Rest(options: ClientOptions( + key: revocable_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +# Step 1: Verify the JWT works +result_before = AWAIT token_client.request("GET", "/channels/" + channel_name) +ASSERT result_before.statusCode >= 200 AND result_before.statusCode < 300 + +# Step 2: Revoke with one valid and one invalid specifier +revoke_result = AWAIT key_client.auth.revokeTokens([ + TokenRevocationTargetSpecifier(type: "clientId", value: client_id), + TokenRevocationTargetSpecifier(type: "invalidType", value: "abc") +]) + +# Step 3: Verify the response contains both success and failure +ASSERT revoke_result.successCount == 1 +ASSERT revoke_result.failureCount == 1 +ASSERT revoke_result.results.length == 2 + +# Valid specifier succeeds +success = revoke_result.results[0] +ASSERT success IS TokenRevocationSuccessResult +ASSERT success.target == "clientId:" + client_id +ASSERT success.issuedBefore IS number +ASSERT success.appliesAt IS number + +# Invalid specifier fails +failure = revoke_result.results[1] +ASSERT failure IS TokenRevocationFailureResult +ASSERT failure.target == "invalidType:abc" +ASSERT failure.error IS ErrorInfo +ASSERT failure.error.statusCode == 400 + +# Step 4: Wait for revocation to take effect +WAIT UNTIL now() >= success.appliesAt + +# Step 5: Verify the JWT is now rejected (the valid revocation took effect) +AWAIT token_client.request("GET", "/channels/" + channel_name) FAILS WITH error +ASSERT error.code == 40141 +``` diff --git a/uts/rest/integration/time_stats.md b/uts/rest/integration/time_stats.md new file mode 100644 index 000000000..cb08a8b7e --- /dev/null +++ b/uts/rest/integration/time_stats.md @@ -0,0 +1,127 @@ +# Time and Stats Integration Tests + +Spec points: `RSC16`, `RSC6` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +--- + +## RSC16 - time() returns server time + +**Spec requirement:** RSC16 - `time()` obtains the current server time. + +Tests that `time()` returns the current server time. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +before_request = now() +server_time = AWAIT client.time() +after_request = now() +``` + +### Assertions +```pseudo +# Server time should be a DateTime +ASSERT server_time IS DateTime + +# Server time should be reasonably close to client time +# (allowing for network latency and minor clock differences) +ASSERT server_time >= before_request - 5000ms +ASSERT server_time <= after_request + 5000ms +``` + +--- + +## RSC6 - stats() returns application statistics + +**Spec requirement:** RSC6 - `stats()` returns a `PaginatedResult` containing application statistics. + +Tests that `stats()` returns stats for the application. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +# Stats may be empty for a new sandbox app, but the call should succeed +result = AWAIT client.stats() +``` + +### Assertions +```pseudo +# Result should be a PaginatedResult (may be empty) +ASSERT result IS PaginatedResult +ASSERT result.items IS List + +# If there are items, they should have expected structure +IF result.items.length > 0: + ASSERT result.items[0].intervalId IS String + ASSERT result.items[0].unit IN ["minute", "hour", "day", "month"] +``` + +--- + +## RSC6 - stats() with parameters + +**Spec requirement:** RSC6 - `stats()` supports `limit`, `direction`, and `unit` parameters. + +Tests that `stats()` correctly applies query parameters. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: api_key, + endpoint: "sandbox" +)) +``` + +### Test Steps +```pseudo +# Request stats with specific parameters +result = AWAIT client.stats( + limit: 5, + direction: "forwards", + unit: "hour" +) +``` + +### Assertions +```pseudo +# Should succeed with parameters applied +ASSERT result IS PaginatedResult +ASSERT result.items.length <= 5 +``` From 63e36b4174ac32d09dbc1ed1fc5592971ff8860a Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 24/32] Add test specs for RTL12 (channel UPDATE event handling) Add specs covering the handling of channel UPDATE protocol messages, including resumed and non-resumed flag behaviour. --- uts/completion-status.md | 4 +- .../channels/channel_additional_attached.md | 191 ++++++++++++++++++ .../unit/channels/channel_state_events.md | 27 ++- 3 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 uts/realtime/unit/channels/channel_additional_attached.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 4ceb99f0d..3f0fb5ec5 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -207,7 +207,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RTL1 | Message and presence processing | | +| RTL1 | Message and presence processing | Information only | | RTL2 | Channel event emission (RTL2a–RTL2i) | Yes — `realtime/unit/channels/channel_state_events.md` | | RTL3 | Connection state side effects (RTL3a–RTL3e) | Yes — `realtime/unit/channels/channel_connection_state.md` | | RTL4 | Attach function (RTL4a–RTL4m) | Yes — `realtime/unit/channels/channel_attach.md` | @@ -218,7 +218,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL9 | Presence attribute (RTL9a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | | RTL10 | History function (RTL10a–RTL10d) | Yes — `realtime/unit/channels/channel_history.md` covers RTL10a, RTL10b, RTL10c (proxies to RSL2 tests); `realtime/integration/channel_history_test.md` covers RTL10d | | RTL11 | Channel state effect on presence (RTL11a) | Yes — `realtime/unit/presence/realtime_presence_channel_state.md` | -| RTL12 | Additional ATTACHED message handling | | +| RTL12 | Additional ATTACHED message handling | Yes — `realtime/unit/channels/channel_additional_attached.md` | | RTL13 | Server-initiated DETACHED handling (RTL13a–RTL13c) | Yes — `realtime/unit/channels/channel_server_initiated_detach.md` | | RTL14 | ERROR message handling | Yes — `realtime/unit/channels/channel_error.md` | | RTL15 | Channel#properties attribute (RTL15a–RTL15b1) | Yes — `realtime/unit/channels/channel_properties.md` | diff --git a/uts/realtime/unit/channels/channel_additional_attached.md b/uts/realtime/unit/channels/channel_additional_attached.md new file mode 100644 index 000000000..9a09fcf3e --- /dev/null +++ b/uts/realtime/unit/channels/channel_additional_attached.md @@ -0,0 +1,191 @@ +# Additional ATTACHED Message Handling Tests + +Spec points: `RTL12` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL12 - Additional ATTACHED with resumed=false emits UPDATE with error + +**Spec requirement:** An attached channel may receive an additional `ATTACHED` +`ProtocolMessage` from Ably at any point. If and only if the `resumed` flag is +false, this should result in the channel emitting an `UPDATE` event with a +`ChannelStateChange` object. The `ChannelStateChange` object should have both +`previous` and `current` attributes set to `attached`, the `reason` attribute +set to the `error` member of the `ATTACHED` `ProtocolMessage` (if any), and the +`resumed` attribute set per the `RESUMED` bitflag of the `ATTACHED` +`ProtocolMessage`. + +Tests that an additional ATTACHED message without the RESUMED flag emits an +UPDATE event with the correct attributes including the error reason. + +### Setup +```pseudo +channel_name = "test-RTL12-update-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) + +# Server sends additional ATTACHED without RESUMED flag, with an error +# (e.g., loss of message continuity after transport resume) +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + error: ErrorInfo(code: 50000, statusCode: 500, message: "generic serverside failure") +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT length(update_events) == 1 +ASSERT update_events[0].event == ChannelEvent.update +ASSERT update_events[0].current == ChannelState.attached +ASSERT update_events[0].previous == ChannelState.attached +ASSERT update_events[0].resumed == false +ASSERT update_events[0].reason.code == 50000 +``` + +--- + +## RTL12 - Additional ATTACHED with resumed=true does NOT emit UPDATE + +**Spec requirement:** The UPDATE event should only be emitted if and only if the +`resumed` flag is false. When `resumed` is true, the additional ATTACHED message +indicates a successful resume with no loss of continuity, and no event should be +emitted to the public channel emitter. + +Tests that an additional ATTACHED message with the RESUMED flag does not emit an +UPDATE event. + +### Setup +```pseudo +channel_name = "test-RTL12-no-update-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) + +# Server sends additional ATTACHED WITH RESUMED flag +# This indicates successful resume with no loss of continuity +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: RESUMED +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT length(update_events) == 0 +``` + +--- + +## RTL12 - Additional ATTACHED without error has null reason + +**Spec requirement:** The `reason` attribute is set to the `error` member of the +`ATTACHED` `ProtocolMessage` (if any). + +Tests that when an additional ATTACHED message has no error field, the UPDATE +event's reason is null. + +### Setup +```pseudo +channel_name = "test-RTL12-no-error-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +update_events = [] +channel.on(ChannelEvent.update).listen((change) => update_events.append(change)) + +# Server sends additional ATTACHED without RESUMED flag and without error +mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT length(update_events) == 1 +ASSERT update_events[0].resumed == false +ASSERT update_events[0].reason IS null +``` diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md index 593d58389..713bae558 100644 --- a/uts/realtime/unit/channels/channel_state_events.md +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -268,9 +268,13 @@ ASSERT attached_events[0].event == ChannelEvent.attached ## RTL2g - UPDATE event for condition changes without state change -**Spec requirement:** It emits an UPDATE ChannelEvent for changes to channel conditions for which the ChannelState does not change. +**Spec requirement:** It emits an UPDATE ChannelEvent for changes to channel +conditions for which the ChannelState does not change, unless explicitly +prevented by a more specific condition (see RTL12). -Tests that UPDATE events are emitted when channel conditions change without state change. +Tests that UPDATE events are emitted when channel conditions change without +state change. Per RTL12, the additional ATTACHED must NOT have the RESUMED flag +set (resumed=true suppresses the UPDATE event). ### Setup ```pseudo @@ -301,25 +305,27 @@ client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected AWAIT channel.attach() -# Server sends another ATTACHED message (e.g., after resume) -# This should trigger UPDATE, not a state change +# Server sends another ATTACHED message without RESUMED flag +# (e.g., loss of message continuity after transport resume) +# Per RTL12, this should trigger UPDATE because resumed=false mock_ws.send_to_client(ProtocolMessage( action: ATTACHED, - channel: channel_name, - flags: RESUMED # Indicates resumed attachment (TR3c, bit 2) + channel: channel_name + # No RESUMED flag — indicates loss of continuity )) # Wait for the event to be processed -AWAIT Future.delayed(Duration(milliseconds: 100)) +AWAIT Future.delayed(Duration.zero) ``` ### Assertions ```pseudo ASSERT channel.state == ChannelState.attached # State unchanged -ASSERT length(update_events) >= 1 +ASSERT length(update_events) == 1 ASSERT update_events[0].event == ChannelEvent.update ASSERT update_events[0].current == ChannelState.attached ASSERT update_events[0].previous == ChannelState.attached +ASSERT update_events[0].resumed == false ``` --- @@ -361,13 +367,14 @@ AWAIT channel.attach() initial_count = length(all_events) -# Server sends another ATTACHED message +# Server sends another ATTACHED message (no RESUMED flag) +# Per RTL12, this triggers UPDATE (not a duplicate state event) mock_ws.send_to_client(ProtocolMessage( action: ATTACHED, channel: channel_name )) -AWAIT Future.delayed(Duration(milliseconds: 100)) +AWAIT Future.delayed(Duration.zero) ``` ### Assertions From fed299bb4248f5c02b0f51070d6f62fbbafbec78 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 25/32] Add test specs for VCDIFF delta message encoding (RTL18/RTL19) Add specs covering delta compression for channel messages using the VCDIFF format, including encoding, decoding, and error recovery. --- uts/completion-status.md | 18 +- .../integration/delta_decoding_test.md | 546 ++++++++ .../unit/channels/channel_delta_decoding.md | 1232 +++++++++++++++++ .../unit/channels/message_field_population.md | 540 ++++++++ uts/realtime/unit/helpers/mock_vcdiff.md | 210 +++ 5 files changed, 2537 insertions(+), 9 deletions(-) create mode 100644 uts/realtime/integration/delta_decoding_test.md create mode 100644 uts/realtime/unit/channels/channel_delta_decoding.md create mode 100644 uts/realtime/unit/channels/message_field_population.md create mode 100644 uts/realtime/unit/helpers/mock_vcdiff.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 3f0fb5ec5..cb1b7d714 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -105,9 +105,9 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| PC1–PC5 | Plugin architecture, VCDiff, Objects | | +| PC1–PC5 | Plugin architecture, VCDiff, Objects | Partial — `realtime/unit/channels/channel_delta_decoding.md` covers PC3, PC3a; `realtime/integration/delta_decoding_test.md` covers PC3 | | PT1–PT2 | PluginType enum | | -| VD1–VD2 | VCDiffDecoder | | +| VD1–VD2 | VCDiffDecoder | Partial — `realtime/unit/helpers/mock_vcdiff.md` references VD2a | ### RestPresence @@ -224,10 +224,10 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL15 | Channel#properties attribute (RTL15a–RTL15b1) | Yes — `realtime/unit/channels/channel_properties.md` | | RTL16 | SetOptions function (RTL16a) | Yes — `realtime/unit/channels/channel_options.md` | | RTL17 | No messages outside ATTACHED state | Yes — `realtime/unit/channels/channel_subscribe.md` | -| RTL18 | Vcdiff decoding failure recovery (RTL18a–RTL18c) | | -| RTL19 | Base payload storage for vcdiff (RTL19a–RTL19c) | | -| RTL20 | Last message ID storage | | -| RTL21 | Message ordering in arrays | | +| RTL18 | Vcdiff decoding failure recovery (RTL18a–RTL18c) | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | +| RTL19 | Base payload storage for vcdiff (RTL19a–RTL19c) | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | +| RTL20 | Last message ID storage | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | +| RTL21 | Message ordering in arrays | Yes — `realtime/unit/channels/channel_delta_decoding.md` | | RTL22 | Message filtering (RTL22a–RTL22d) | | | RTL23 | Name attribute | | | RTL24 | ErrorReason attribute | | @@ -313,7 +313,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5 | +| TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5; `realtime/unit/channels/message_field_population.md` covers TM2a, TM2c, TM2f (realtime field population) | | DE1–DE2 | DeltaExtras | | | TP1–TP5 | PresenceMessage | Yes — `rest/unit/types/presence_message_types.md` | | OM1–OM5 | ObjectMessage | | @@ -401,14 +401,14 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **Realtime client** (RTC) | 14 | 12 | Partial | | **Connection** (RTN) | 23 | 17 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | -| **Realtime channel** (RTL) | 24 | 16 | Partial | +| **Realtime channel** (RTL) | 24 | 20 | Partial | | **Realtime presence** (RTP) | 15 | 15 | Full | | **Realtime annotations** (RTAN) | 5 | 0 | None | | **EventEmitter** (RTE) | 6 | 0 | None | | **Backoff/jitter** (RTB) | 1 | 0 | None | | **Wrapper SDK** (WP) | 7 | 0 | None | | **Push notifications** (RSH) | 8 | 0 | None | -| **Plugins** (PC/PT/VD) | 3 | 0 | None | +| **Plugins** (PC/PT/VD) | 3 | 2 | Partial | | **Data types** | 30 | 9 | Partial | | **Option types** | 8 | 5 | Partial | | **Push types** | 3 | 0 | None | diff --git a/uts/realtime/integration/delta_decoding_test.md b/uts/realtime/integration/delta_decoding_test.md new file mode 100644 index 000000000..473901922 --- /dev/null +++ b/uts/realtime/integration/delta_decoding_test.md @@ -0,0 +1,546 @@ +# Delta Decoding Integration Tests + +Spec points: `PC3`, `PC3a`, `RTL18`, `RTL18b`, `RTL18c`, `RTL19b`, `RTL20` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification of vcdiff delta decoding using real connections against the +Ably sandbox. The server generates vcdiff-encoded deltas when a channel is attached +with `params: { delta: 'vcdiff' }`. These tests verify that the SDK correctly +decodes those deltas using a real vcdiff decoder plugin. + +These tests complement the unit tests (which use a mock vcdiff encoder/decoder) by +exercising the full pipeline: publish → server generates delta → SDK decodes with +real vcdiff decoder → subscriber receives original data. + +## Dependencies + +These tests require a real VCDiff decoder that implements the `VCDiffDecoder` +interface (`VD2a`). The decoder must accept `(delta: byte[], base: byte[]) -> byte[]`. + +Concrete implementations should adapt whichever vcdiff library is available for their +platform. For example, the Dart SDK uses the `vcdiff` package which exposes +`decode(Uint8List source, Uint8List delta) -> Uint8List` — note the swapped argument +order compared to `VD2a`. + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +**Note:** `useBinaryProtocol: false` is required if the SDK does not implement msgpack. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Test Data + +All tests that publish multiple messages use the same dataset: + +```pseudo +test_data = [ + { foo: "bar", count: 1, status: "active" }, + { foo: "bar", count: 2, status: "active" }, + { foo: "bar", count: 2, status: "inactive" }, + { foo: "bar", count: 3, status: "inactive" }, + { foo: "bar", count: 3, status: "active" } +] +``` + +The data is intentionally similar between messages so that the server generates +small vcdiff deltas rather than sending full messages. + +--- + +## PC3 - Delta plugin decodes messages end-to-end + +**Spec requirement:** A plugin provided with the PluginType key `vcdiff` should be +capable of decoding vcdiff-encoded messages. + +Tests that with a real vcdiff decoder plugin and a channel configured for delta +mode, all published messages are received with correct data, and that the decoder +was invoked for the delta messages (all except the first). + +### Setup +```pseudo +channel_name = "delta-PC3-" + random_id() + +# Use a wrapping decoder that counts invocations +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false, + plugins: { vcdiff: counting_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] + +# Fail the test if the channel reattaches (decode failure) +channel.on(ChannelEvent.attaching, (change) => { + FAIL("Channel reattaching due to decode failure: " + change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +# Publish all messages sequentially +FOR i IN 0..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +# Wait for all messages to be received +WAIT UNTIL length(received_messages) == length(test_data) + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +FOR i IN 0..length(test_data) - 1: + ASSERT received_messages[i].name == str(i) + ASSERT received_messages[i].data == test_data[i] + +# First message is sent as full payload, rest as deltas +ASSERT decode_count == length(test_data) - 1 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## RTL19b - Dissimilar payloads received without delta encoding + +**Spec requirement:** In the case of a non-delta message, the resulting `data` value +is stored as the base payload. + +Tests that when a channel is configured for delta mode but successive messages have +completely dissimilar payloads (random binary data), the server is expected to send +full messages rather than deltas. The SDK must handle this correctly — each non-delta +message updates the stored base payload and is delivered to subscribers. + +If the server nonetheless chooses to generate a delta, the test does not fail; it +verifies correct behaviour regardless of whether deltas are used. The assertion on +decode count is skipped if deltas were generated. + +### Setup +```pseudo +channel_name = "delta-dissimilar-" + random_id() +message_count = 5 + +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false, + plugins: { vcdiff: counting_decoder } +)) + +# Generate random binary payloads — 1KB each, completely dissimilar +payloads = [] +FOR i IN 0..message_count - 1: + payloads.append(random_bytes(1024)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] + +# Fail the test if the channel reattaches (decode failure) +channel.on(ChannelEvent.attaching, (change) => { + FAIL("Channel reattaching due to decode failure: " + change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +FOR i IN 0..message_count - 1: + AWAIT channel.publish(str(i), payloads[i]) + +WAIT UNTIL length(received_messages) == message_count + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +# All messages received with correct data +FOR i IN 0..message_count - 1: + ASSERT received_messages[i].name == str(i) + ASSERT received_messages[i].data == payloads[i] + +# The server is expected to send full messages (no deltas) for dissimilar +# random binary payloads. If so, the decoder should not have been called. +# However, the server may still choose to generate deltas, so we only log +# the decode count rather than asserting it is zero. +LOG "Decoder was called " + str(decode_count) + " times for " + str(message_count) + " dissimilar messages" +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## PC3 - No deltas without delta channel param + +**Spec requirement:** The vcdiff plugin is only used when the channel is configured to +request delta compression from the server. + +Tests that when a channel is attached without `params: { delta: 'vcdiff' }`, the +server sends full messages and the vcdiff decoder is never called. + +### Setup +```pseudo +channel_name = "delta-no-param-" + random_id() + +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false, + plugins: { vcdiff: counting_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach WITHOUT delta params +channel = client.channels.get(channel_name) + +AWAIT channel.attach() + +received_messages = [] +channel.subscribe((msg) => received_messages.append(msg)) + +FOR i IN 0..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +WAIT UNTIL length(received_messages) == length(test_data) + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +FOR i IN 0..length(test_data) - 1: + ASSERT received_messages[i].name == str(i) + ASSERT received_messages[i].data == test_data[i] + +# No deltas — decoder was never called +ASSERT decode_count == 0 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## RTL18, RTL18b, RTL18c, RTL20 - Recovery after last message ID mismatch + +| Spec | Requirement | +|------|-------------| +| RTL18 | Decode failure triggers automatic recovery | +| RTL18b | The failed message is discarded | +| RTL18c | ATTACH sent with channelSerial, channel transitions to ATTACHING with error 40018 | +| RTL20 | Delta reference ID must match stored last message ID | + +Tests that when the stored last message ID is cleared (simulating a gap), the next +delta message fails the RTL20 base reference check, triggering the RTL18 recovery +procedure. After recovery the channel reattaches and remaining messages are delivered. + +**Note:** This test manipulates internal SDK state (the stored last message ID) to +simulate a message gap. The mechanism for doing this is implementation-specific. + +### Setup +```pseudo +channel_name = "delta-recovery-mismatch-" + random_id() + +decode_count = 0 +counting_decoder = VCDiffDecoder( + onDecode: (delta, base) => { + decode_count++ + } +) + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false, + plugins: { vcdiff: counting_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] +attaching_reasons = [] + +channel.on(ChannelEvent.attaching, (change) => { + attaching_reasons.append(change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +# Publish first batch of messages and wait for them to arrive. +# Publishing in two batches ensures the server has sent and the client has +# processed the first batch before we clear the stored ID. If all messages +# were published at once, they could all arrive in a single ProtocolMessage +# before clearLastPayloadMessageId takes effect. +FOR i IN 0..2: + AWAIT channel.publish(str(i), test_data[i]) + +WAIT UNTIL length(received_messages) >= 3 + WITH timeout: 15 seconds + +# Simulate a message gap by clearing the stored last message ID. +# The next delta will fail the RTL20 check. +# (Implementation-specific: access internal _lastPayload.messageId or equivalent) +CLEAR channel._lastPayload.messageId + +# Publish remaining messages — the server should send these as deltas, +# which will fail the RTL20 check and trigger recovery +FOR i IN 3..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +# Wait for all messages to be received — recovery will reattach and +# the server will resend from the channelSerial +WAIT UNTIL (unique message names in received_messages) covers all 0..length(test_data)-1 + WITH timeout: 30 seconds +``` + +### Assertions +```pseudo +# All messages were eventually received with correct data (may have duplicates +# from the server resending after recovery) +FOR i IN 0..length(test_data) - 1: + msg = FIND received_messages WHERE name == str(i) + ASSERT msg IS NOT null + ASSERT msg.data == test_data[i] + +# RTL18c: Recovery was triggered with error code 40018 +ASSERT length(attaching_reasons) >= 1 +ASSERT attaching_reasons[0].code == 40018 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## RTL18, RTL18c - Recovery after decode failure + +| Spec | Requirement | +|------|-------------| +| RTL18 | Decode failure triggers automatic recovery | +| RTL18c | ATTACH sent with channelSerial, channel transitions to ATTACHING with error 40018 | + +Tests that when the vcdiff decoder throws an error, the channel transitions to +ATTACHING with error 40018 and recovers by reattaching. After recovery, remaining +messages are delivered (the server resends from the channelSerial as non-deltas +since the decode context is lost). + +### Setup +```pseudo +channel_name = "delta-recovery-decode-" + random_id() + +# Decoder that always fails +failing_decoder = FailingVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false, + plugins: { vcdiff: failing_decoder } +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +channel = client.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT channel.attach() + +received_messages = [] +attaching_reasons = [] + +channel.on(ChannelEvent.attaching, (change) => { + attaching_reasons.append(change.reason) +}) + +channel.subscribe((msg) => received_messages.append(msg)) + +FOR i IN 0..length(test_data) - 1: + AWAIT channel.publish(str(i), test_data[i]) + +# Wait for all messages — first arrives as non-delta, second triggers decode +# failure and recovery, then remaining messages arrive after reattach +WAIT UNTIL length(received_messages) >= length(test_data) + WITH timeout: 30 seconds +``` + +### Assertions +```pseudo +# All messages eventually received with correct data +FOR i IN 0..length(test_data) - 1: + msg = FIND received_messages WHERE name == str(i) + ASSERT msg IS NOT null + ASSERT msg.data == test_data[i] + +# RTL18c: At least one recovery was triggered +ASSERT length(attaching_reasons) >= 1 +ASSERT attaching_reasons[0].code == 40018 +``` + +### Cleanup +```pseudo +client.close() +``` + +--- + +## PC3 - No plugin causes FAILED state + +**Spec requirement:** Without a vcdiff decoder plugin, vcdiff-encoded messages cannot +be decoded and the channel should transition to FAILED. + +Tests that when a channel is configured for delta mode but no vcdiff plugin is +registered, receiving a delta-encoded message causes the channel to transition to +FAILED with error code 40019. + +**Note:** This test uses a separate publisher client because the subscribing client's +channel transitions to FAILED when it receives a delta it cannot decode. If the same +client were used for both publishing and subscribing, subsequent `publish()` calls +would fail with a "channel is FAILED" error, and pending publish ACKs could also +fail. Using a separate publisher avoids these complications. + +### Setup +```pseudo +channel_name = "delta-no-plugin-" + random_id() + +# Subscriber — no vcdiff plugin, but requests delta channel param +subscriber = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +# Publisher — separate connection, publishes without delta param +publisher = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps +```pseudo +subscriber.connect() +publisher.connect() +AWAIT_STATE subscriber.connection.state == ConnectionState.connected +AWAIT_STATE publisher.connection.state == ConnectionState.connected + +sub_channel = subscriber.channels.get(channel_name, options: ChannelOptions( + params: { "delta": "vcdiff" } +)) + +AWAIT sub_channel.attach() + +# Publisher uses a plain channel (no delta param) +pub_channel = publisher.channels.get(channel_name) +AWAIT pub_channel.attach() + +# Publish enough messages to trigger delta encoding on subscriber +FOR i IN 0..length(test_data) - 1: + AWAIT pub_channel.publish(str(i), test_data[i]) + +# Subscriber channel should transition to FAILED when it receives a delta +# it cannot decode (no vcdiff plugin registered) +WAIT UNTIL sub_channel.state == ChannelState.failed + WITH timeout: 15 seconds +``` + +### Assertions +```pseudo +ASSERT sub_channel.state == ChannelState.failed +ASSERT sub_channel.errorReason.code == 40019 +``` + +### Cleanup +```pseudo +subscriber.close() +publisher.close() +``` diff --git a/uts/realtime/unit/channels/channel_delta_decoding.md b/uts/realtime/unit/channels/channel_delta_decoding.md new file mode 100644 index 000000000..a7ab4195f --- /dev/null +++ b/uts/realtime/unit/channels/channel_delta_decoding.md @@ -0,0 +1,1232 @@ +# Channel Delta Decoding Tests + +Spec points: `RTL18`, `RTL18a`, `RTL18b`, `RTL18c`, `RTL19`, `RTL19a`, `RTL19b`, `RTL19c`, `RTL20`, `RTL21`, `PC3`, `PC3a` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +## Mock VCDiff Infrastructure + +See `uts/test/realtime/unit/helpers/mock_vcdiff.md` for the full Mock VCDiff Infrastructure specification. + +--- + +## RTL21 - Messages in array decoded in ascending index order + +**Spec requirement:** The messages in the `messages` array of a `ProtocolMessage` should each be decoded in ascending order of their index in the array. + +Tests that when a ProtocolMessage contains multiple messages where later messages +are deltas referencing earlier messages, they are decoded correctly because +processing happens in array order. + +### Setup +```pseudo +channel_name = "test-RTL21-order-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a ProtocolMessage with 3 messages: +# - msg-1: non-delta (establishes base) +# - msg-2: delta referencing msg-1 +# - msg-3: delta referencing msg-2 +# This only works if messages are decoded in order [0], [1], [2] + +base_data = "first message" +second_data = "second message" +third_data = "third message" + +delta_1_to_2 = encoder.encode(base_data, second_data) +delta_2_to_3 = encoder.encode(second_data, third_data) + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "serial:0", + messages: [ + { + id: "serial:0", + data: base_data, + encoding: null + }, + { + id: "serial:1", + data: delta_1_to_2, + encoding: "vcdiff", + extras: { delta: { from: "serial:0", format: "vcdiff" } } + }, + { + id: "serial:2", + data: delta_2_to_3, + encoding: "vcdiff", + extras: { delta: { from: "serial:1", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].data == "first message" +ASSERT received_messages[1].data == "second message" +ASSERT received_messages[2].data == "third message" +``` + +--- + +## RTL19b - Non-delta message stores base payload + +**Spec requirement:** In the case of a non-delta message, the resulting `data` value is stored as the base payload. + +Tests that after receiving a non-delta message, its data is stored as the base +payload so that a subsequent delta message can reference it. + +### Setup +```pseudo +channel_name = "test-RTL19b-base-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send non-delta message to establish base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "base payload", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send delta referencing the base +delta = encoder.encode("base payload", "updated payload") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].data == "base payload" +ASSERT received_messages[1].data == "updated payload" +``` + +--- + +## RTL19b - JSON-encoded non-delta message stores wire-form base payload + +**Spec requirement:** In the case of a non-delta message, the resulting `data` value +is stored as the base payload. + +Tests that when a non-delta message has `encoding: "json"`, the base payload stored +for subsequent delta decoding is the raw JSON **string** (the wire form after base64 +decoding, if any, but **before** json/utf-8 decoding), not the parsed object. This +matches the ably-js behaviour where `lastPayload` is only updated by `base64` +(outermost) and `vcdiff` steps, never by `json` or `utf-8`. + +This is critical because the vcdiff delta is computed by the server against the +wire-form payload. Storing the fully-decoded object (e.g., a Map) instead of the +JSON string would cause vcdiff decoding to fail with "no base payload available" +since the stored value would not be a String or Uint8List. + +### Setup +```pseudo +channel_name = "test-RTL19b-json-base-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a non-delta message with JSON encoding. +# The wire data is a JSON string; after decoding, the subscriber sees a Map. +# The base payload stored for delta decoding should be the JSON string, +# not the parsed Map. +json_string = '{"foo":"bar","count":1}' + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: json_string, + encoding: "json" + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send a delta referencing the JSON string base. +# The delta is computed against the JSON string, not the parsed object. +new_json_string = '{"foo":"baz","count":2}' +delta = encoder.encode(json_string, new_json_string) + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "utf-8/vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# First message: subscriber receives the parsed JSON object +ASSERT received_messages[0].data == { "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) +ASSERT received_messages[1].data == new_json_string +``` + +--- + +## RTL19a - Base64 encoding step decoded before storing base payload + +**Spec requirement:** When processing any message (whether a delta or a full message), if the message `encoding` string ends in `base64`, the message `data` should be base64-decoded (and the `encoding` string modified accordingly per RSL6). + +Tests that a base64-encoded non-delta message is decoded before its data is +stored as the base payload, so that subsequent delta application uses the decoded +(binary) form. + +### Setup +```pseudo +channel_name = "test-RTL19a-base64-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# The base payload is binary data [0x48, 0x65, 0x6C, 0x6C, 0x6F] ("Hello") +# Sent as base64-encoded string +base_binary = [0x48, 0x65, 0x6C, 0x6C, 0x6F] +base_as_base64 = "SGVsbG8=" + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: base_as_base64, + encoding: "base64" + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Now send a delta that references the binary base payload +new_binary = [0x57, 0x6F, 0x72, 0x6C, 0x64] # "World" +delta = encoder.encode(base_binary, new_binary) + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: base64_encode(delta), + encoding: "vcdiff/base64", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# First message decoded from base64 to binary +ASSERT received_messages[0].data == base_binary + +# Second message delta-decoded using the binary base, then delivered as binary +ASSERT received_messages[1].data == new_binary +``` + +--- + +## RTL19c - Delta application result stored as new base payload + +**Spec requirement:** In the case of a delta message with a `vcdiff` encoding step, the `vcdiff` decoder must be used to decode the base payload of the delta message, applying that delta to the stored base payload. The direct result of that vcdiff delta application, before performing any further decoding steps, is stored as the updated base payload. + +Tests that after decoding a delta message, the decoded result becomes the new +base payload for subsequent deltas (chained deltas). + +### Setup +```pseudo +channel_name = "test-RTL19c-chain-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message 1: non-delta, establishes base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "value-A", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Message 2: delta from msg-1 to value-B +delta_A_to_B = encoder.encode("value-A", "value-B") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta_A_to_B, + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 + +# Message 3: delta from msg-2 to value-C +# This verifies the base was updated to value-B after decoding msg-2 +delta_B_to_C = encoder.encode("value-B", "value-C") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-3:0", + messages: [ + { + id: "msg-3:0", + data: delta_B_to_C, + encoding: "vcdiff", + extras: { delta: { from: "msg-2:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].data == "value-A" +ASSERT received_messages[1].data == "value-B" +ASSERT received_messages[2].data == "value-C" +``` + +--- + +## RTL20 - Delta with mismatched base message ID triggers recovery + +**Spec requirement:** The `id` of the last received message on each channel must be stored along with the base payload. When processing a delta message, the stored last message `id` must be compared against the delta reference `id` in `Message.extras.delta.from`. If the delta reference `id` does not equal the stored `id`, the message decoding must fail and the recovery procedure from RTL18 must be executed. + +Tests that when a delta message references a message ID that doesn't match the +stored last message ID, the client initiates decode failure recovery. + +### Setup +```pseudo +channel_name = "test-RTL20-mismatch-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +state_changes = [] +attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Establish base with msg-1 +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-1", + messages: [ + { + id: "msg-1:0", + data: "base payload", + encoding: null + } + ] +)) + +# Wait for message to be processed +AWAIT Future.delayed(Duration.zero) + +# Clear state tracking from initial attach +state_changes = [] +initial_attach_count = length(attach_messages) + +# Send delta that references wrong message ID (msg-999 instead of msg-1) +delta = encoder.encode("base payload", "new payload") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "msg-999:0", format: "vcdiff" } } + } + ] +)) + +# RTL18c: channel transitions to ATTACHING and sends ATTACH +AWAIT_STATE channel.state == ChannelState.attaching +``` + +### Assertions +```pseudo +# RTL18c: A new ATTACH message was sent for recovery +ASSERT length(attach_messages) > initial_attach_count + +# RTL18c: The ATTACH message includes channelSerial from previous message +recovery_attach = attach_messages[length(attach_messages) - 1] +ASSERT recovery_attach.channelSerial == "serial-1" + +# RTL18c: Channel state went to ATTACHING with error code 40018 +ASSERT state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching +] +attaching_change = FIND state_changes WHERE current == ChannelState.attaching +ASSERT attaching_change.reason.code == 40018 +``` + +--- + +## RTL20 - Last message ID updated after successful decode + +**Spec requirement:** The `id` of the last received message on each channel must be stored along with the base payload. + +Tests that the stored last message ID is updated to the ID of the last message +in a ProtocolMessage after successful decoding, and is used correctly for the +next delta's base reference check. + +### Setup +```pseudo +channel_name = "test-RTL20-id-update-${random_id()}" +encoder = MockVCDiffEncoder() +decoder = MockVCDiffDecoder() + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send ProtocolMessage with 2 messages in the array +# The last message ID should be stored as "serial:1" (the last in the array) +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "serial:0", + messages: [ + { + id: "serial:0", + data: "first", + encoding: null + }, + { + id: "serial:1", + data: "second", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 2 + +# Now send a delta that references "serial:1" (the last message ID) +# This should succeed because the stored ID matches +delta = encoder.encode("second", "third") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "serial:1", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +# The delta was decoded successfully, confirming the stored ID was "serial:1" +ASSERT received_messages[0].data == "first" +ASSERT received_messages[1].data == "second" +ASSERT received_messages[2].data == "third" +``` + +--- + +## PC3, PC3a - VCDiff plugin decodes delta messages + +| Spec | Requirement | +|------|-------------| +| PC3 | A plugin provided with PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages | +| PC3a | The base argument of VCDiffDecoder.decode should receive the stored base payload; if the base is a string it should be UTF-8 encoded to binary before being passed | + +Tests that the vcdiff plugin is used to decode delta-encoded messages and that +string base payloads are UTF-8 encoded to binary before being passed to the +decoder. + +### Setup +```pseudo +channel_name = "test-PC3-decode-${random_id()}" +encoder = MockVCDiffEncoder() + +# Use a wrapping decoder that records the arguments it receives +decode_calls = [] + +recording_decoder = MockVCDiffDecoder( + onDecode: (delta, base) => { + decode_calls.append({ delta: delta, base: base }) + } +) + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: recording_decoder } +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a string non-delta message (establishes string base payload) +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "hello world", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send a delta message referencing the string base +delta = encoder.encode("hello world", "goodbye world") + +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: delta, + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# PC3: The decoder was called to decode the delta +ASSERT length(decode_calls) == 1 + +# PC3a: The base argument was UTF-8 encoded to binary +# "hello world" as UTF-8 bytes +ASSERT decode_calls[0].base == utf8_encode("hello world") + +# PC3a: The delta argument is the raw delta payload +ASSERT decode_calls[0].delta == delta + +# The decoded message was delivered to the subscriber +ASSERT received_messages[1].data == "goodbye world" +``` + +--- + +## PC3 - No vcdiff plugin causes FAILED state + +**Spec requirement:** A plugin provided with the PluginType key `vcdiff` should be capable of decoding vcdiff-encoded messages. Without it, vcdiff-encoded messages cannot be decoded. + +Tests that when a vcdiff-encoded message is received but no vcdiff plugin is +registered, the channel transitions to FAILED with error code 40019. + +### Setup +```pseudo +channel_name = "test-PC3-no-plugin-${random_id()}" + +state_changes = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +# No vcdiff plugin registered +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +state_changes = [] + +# Send a delta-encoded message without a vcdiff plugin registered +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + messages: [ + { + id: "msg-1:0", + data: "some-delta-data", + encoding: "vcdiff", + extras: { delta: { from: "msg-0:0", format: "vcdiff" } } + } + ] +)) + +# Channel should transition to FAILED +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +# Channel is FAILED with error code 40019 (no vcdiff plugin) +ASSERT channel.state == ChannelState.failed +ASSERT channel.errorReason.code == 40019 +``` + +--- + +## RTL18 - Decode failure triggers recovery (RTL18a, RTL18b, RTL18c) + +| Spec | Requirement | +|------|-------------| +| RTL18a | Log error with code 40018 | +| RTL18b | Discard the message | +| RTL18c | Send ATTACH with channelSerial set to previous message's channelSerial, transition to ATTACHING, wait for ATTACHED confirmation. ChannelStateChange.reason should have code 40018. | + +Tests that when vcdiff decoding fails, the client discards the message, +transitions to ATTACHING, and sends an ATTACH with the correct channelSerial for +recovery. + +### Setup +```pseudo +channel_name = "test-RTL18-recovery-${random_id()}" + +state_changes = [] +attach_messages = [] +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +# Use a decoder that always fails +failing_decoder = FailingMockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: failing_decoder } +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Establish base with a non-delta message first +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-100", + messages: [ + { + id: "msg-1:0", + data: "base payload", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Clear state tracking from initial attach +state_changes = [] +initial_attach_count = length(attach_messages) + +# Send a delta message — the failing decoder will throw during decode +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + channelSerial: "serial-200", + messages: [ + { + id: "msg-2:0", + data: "fake-delta-payload", + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +# RTL18c: channel transitions to ATTACHING for recovery +AWAIT_STATE channel.state == ChannelState.attaching +``` + +### Assertions +```pseudo +# RTL18b: The failed delta message was NOT delivered to subscribers +ASSERT length(received_messages) == 1 +ASSERT received_messages[0].data == "base payload" + +# RTL18c: A new ATTACH was sent for recovery +ASSERT length(attach_messages) > initial_attach_count +recovery_attach = attach_messages[length(attach_messages) - 1] + +# RTL18c: The ATTACH includes channelSerial from the previous successful message +ASSERT recovery_attach.channelSerial == "serial-100" + +# RTL18c: Channel state went to ATTACHING with error code 40018 +ASSERT state_changes CONTAINS_IN_ORDER [ + ChannelState.attaching +] +attaching_change = FIND state_changes WHERE current == ChannelState.attaching +ASSERT attaching_change.reason.code == 40018 +``` + +--- + +## RTL18c - Recovery completes when server sends ATTACHED + +**Spec requirement:** Send an ATTACH ProtocolMessage and wait for a confirmation ATTACHED, as per RTL4c and RTL4f. + +Tests that after decode failure recovery, the channel returns to ATTACHED state +when the server confirms with an ATTACHED ProtocolMessage, and that new messages +can be received afterwards. + +### Setup +```pseudo +channel_name = "test-RTL18c-complete-${random_id()}" +encoder = MockVCDiffEncoder() + +state_changes = [] +received_messages = [] +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +# Use a decoder that fails on first call, then succeeds +decode_attempt = 0 +conditional_decoder = MockVCDiffDecoder( + onDecode: (delta, base) => { + decode_attempt++ + IF decode_attempt == 1: + THROW "Simulated decode failure" + } +) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: conditional_decoder } +)) +channel = client.channels.get(channel_name) +channel.on((change) => state_changes.append(change)) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Establish base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-1", + messages: [ + { + id: "msg-1:0", + data: "original base", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 1 + +# Send delta that will fail on first decode attempt +# This triggers recovery → ATTACHING → ATTACHED +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + channelSerial: "serial-2", + messages: [ + { + id: "msg-2:0", + data: "bad-delta", + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +# Recovery: ATTACHING → server auto-responds with ATTACHED +AWAIT_STATE channel.state == ChannelState.attached + +state_changes = [] + +# After recovery, server resends from channelSerial with a fresh non-delta +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-3:0", + channelSerial: "serial-3", + messages: [ + { + id: "msg-3:0", + data: "fresh after recovery", + encoding: null + } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# Channel recovered and is now attached +ASSERT channel.state == ChannelState.attached + +# Messages received: the original base and the fresh message after recovery +# (the failed delta msg-2 was discarded per RTL18b) +ASSERT received_messages[0].data == "original base" +ASSERT received_messages[1].data == "fresh after recovery" +``` + +--- + +## RTL18 - Only one recovery in progress at a time + +**Spec requirement:** The client must automatically execute the recovery procedure. (Implied: concurrent decode failures should not trigger multiple simultaneous recovery attempts.) + +Tests that if multiple delta decode failures occur in quick succession, only one +recovery ATTACH is sent (the recovery flag prevents duplicate recovery attempts). + +### Setup +```pseudo +channel_name = "test-RTL18-single-recovery-${random_id()}" + +attach_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_messages.append(msg) + # Do NOT auto-respond with ATTACHED — leave recovery in progress + IF length(attach_messages) == 1: + # Only respond to the initial attach + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +failing_decoder = FailingMockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: failing_decoder } +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +initial_attach_count = length(attach_messages) + +# Establish base +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-1:0", + channelSerial: "serial-1", + messages: [ + { + id: "msg-1:0", + data: "base", + encoding: null + } + ] +)) + +AWAIT Future.delayed(Duration.zero) + +# Send first delta that will fail — triggers recovery +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-2:0", + messages: [ + { + id: "msg-2:0", + data: "bad-delta-1", + encoding: "vcdiff", + extras: { delta: { from: "msg-1:0", format: "vcdiff" } } + } + ] +)) + +AWAIT_STATE channel.state == ChannelState.attaching + +# Send second delta that also fails — recovery already in progress +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg-3:0", + messages: [ + { + id: "msg-3:0", + data: "bad-delta-2", + encoding: "vcdiff", + extras: { delta: { from: "msg-2:0", format: "vcdiff" } } + } + ] +)) + +AWAIT Future.delayed(Duration.zero) +``` + +### Assertions +```pseudo +# Only one recovery ATTACH was sent (not two) +recovery_attaches = length(attach_messages) - initial_attach_count +ASSERT recovery_attaches == 1 +``` diff --git a/uts/realtime/unit/channels/message_field_population.md b/uts/realtime/unit/channels/message_field_population.md new file mode 100644 index 000000000..86255ecbf --- /dev/null +++ b/uts/realtime/unit/channels/message_field_population.md @@ -0,0 +1,540 @@ +# Message Field Population from ProtocolMessage + +Spec points: `TM2a`, `TM2c`, `TM2f` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +When a realtime client receives a ProtocolMessage containing messages, certain +fields on individual messages may be absent. The spec requires the SDK to populate +these from the encapsulating ProtocolMessage before delivering to subscribers: + +| Spec | Field | Fallback | +|------|-------|----------| +| TM2a | `id` | `protocolMsgId:index` (0-based index in messages array) | +| TM2c | `connectionId` | ProtocolMessage `connectionId` | +| TM2f | `timestamp` | ProtocolMessage `timestamp` | + +This is critical for correct operation of features that depend on message IDs +(e.g., vcdiff delta decoding RTL20 uses `id` for continuity checks) and for +providing complete message metadata to subscribers. + +These tests verify that the population happens before messages are delivered to +subscribers via `channel.subscribe()`. + +--- + +## TM2a - Message id populated from ProtocolMessage id and index + +**Spec requirement:** For messages received over Realtime, if the message does not +contain an `id`, it should be set to `protocolMsgId:index`, where `protocolMsgId` +is the id of the `ProtocolMessage` encapsulating it, and `index` is the index of +the message inside the `messages` array of the `ProtocolMessage`. + +Tests that messages without an `id` field receive a computed ID in the format +`protocolMessageId:arrayIndex` before being delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-TM2a-id-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send a ProtocolMessage with 3 messages that have no id field. +# The ProtocolMessage itself has id "connId:serial". +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "abc123:5", + connectionId: "abc123", + timestamp: 1700000000000, + messages: [ + { name: "first", data: "a" }, + { name: "second", data: "b" }, + { name: "third", data: "c" } + ] +)) + +AWAIT length(received_messages) == 3 +``` + +### Assertions +```pseudo +# Each message id is computed as protocolMessageId:index +ASSERT received_messages[0].id == "abc123:5:0" +ASSERT received_messages[1].id == "abc123:5:1" +ASSERT received_messages[2].id == "abc123:5:2" +``` + +--- + +## TM2a - Message with existing id is not overwritten + +**Spec requirement:** The id should only be set if the message does not already +contain one. + +Tests that a message that already has an `id` field retains its original value. + +### Setup +```pseudo +channel_name = "test-TM2a-existing-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message already has its own id — should not be overwritten +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "proto-id:0", + messages: [ + { id: "my-custom-id", name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].id == "my-custom-id" +``` + +--- + +## TM2a - No id when ProtocolMessage has no id + +**Spec requirement:** The id derivation only applies when the ProtocolMessage has +an `id` field. If the ProtocolMessage has no `id`, messages without their own `id` +should remain without one. + +Tests that messages are not assigned a computed id when the ProtocolMessage itself +lacks an `id` field. + +### Setup +```pseudo +channel_name = "test-TM2a-no-proto-id-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# ProtocolMessage has no id field — messages should not get computed ids +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + connectionId: "abc123", + messages: [ + { name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].id IS null +``` + +--- + +## TM2c - Message connectionId populated from ProtocolMessage + +**Spec requirement:** If a message received from Ably does not contain a +`connectionId`, it should be set to the `connectionId` of the encapsulating +`ProtocolMessage`. + +Tests that messages without a `connectionId` field inherit the value from the +ProtocolMessage before being delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-TM2c-connId-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message has no connectionId — should inherit from ProtocolMessage +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + connectionId: "server-conn-xyz", + messages: [ + { name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].connectionId == "server-conn-xyz" +``` + +--- + +## TM2c - Message with existing connectionId is not overwritten + +**Spec requirement:** The connectionId should only be set if the message does not +already contain one. + +Tests that a message that already has a `connectionId` retains its original value. + +### Setup +```pseudo +channel_name = "test-TM2c-existing-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message already has its own connectionId — should not be overwritten +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + connectionId: "proto-conn", + messages: [ + { connectionId: "msg-conn", name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].connectionId == "msg-conn" +``` + +--- + +## TM2f - Message timestamp populated from ProtocolMessage + +**Spec requirement:** If a message received from Ably over a realtime transport does +not contain a `timestamp`, the SDK must set it to the `timestamp` of the +encapsulating `ProtocolMessage`. + +Tests that messages without a `timestamp` field inherit the value from the +ProtocolMessage before being delivered to subscribers. + +### Setup +```pseudo +channel_name = "test-TM2f-timestamp-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message has no timestamp — should inherit from ProtocolMessage +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + timestamp: 1700000000000, + messages: [ + { name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].timestamp == 1700000000000 +``` + +--- + +## TM2f - Message with existing timestamp is not overwritten + +**Spec requirement:** The timestamp should only be set if the message does not +already contain one. + +Tests that a message that already has a `timestamp` retains its original value. + +### Setup +```pseudo +channel_name = "test-TM2f-existing-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Message already has its own timestamp — should not be overwritten +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "msg:0", + timestamp: 1700000000000, + messages: [ + { timestamp: 1600000000000, name: "msg", data: "hello" } + ] +)) + +AWAIT length(received_messages) == 1 +``` + +### Assertions +```pseudo +ASSERT received_messages[0].timestamp == 1600000000000 +``` + +--- + +## TM2a, TM2c, TM2f - All fields populated together + +**Spec requirement:** All three fields (id, connectionId, timestamp) should be +populated from the ProtocolMessage when absent from the message. + +Tests that all three fields are populated in a single ProtocolMessage containing +multiple messages, with correct per-message index for the id field. + +### Setup +```pseudo +channel_name = "test-TM2-all-fields-${random_id()}" + +received_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +channel = client.channels.get(channel_name) +channel.subscribe((msg) => received_messages.append(msg)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# ProtocolMessage with all parent fields set, messages with none +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + id: "connId:7", + connectionId: "connId", + timestamp: 1700000000000, + messages: [ + { name: "first", data: "a" }, + { name: "second", data: "b" } + ] +)) + +AWAIT length(received_messages) == 2 +``` + +### Assertions +```pseudo +# First message +ASSERT received_messages[0].id == "connId:7:0" +ASSERT received_messages[0].connectionId == "connId" +ASSERT received_messages[0].timestamp == 1700000000000 +ASSERT received_messages[0].name == "first" +ASSERT received_messages[0].data == "a" + +# Second message — same connectionId and timestamp, different id index +ASSERT received_messages[1].id == "connId:7:1" +ASSERT received_messages[1].connectionId == "connId" +ASSERT received_messages[1].timestamp == 1700000000000 +ASSERT received_messages[1].name == "second" +ASSERT received_messages[1].data == "b" +``` diff --git a/uts/realtime/unit/helpers/mock_vcdiff.md b/uts/realtime/unit/helpers/mock_vcdiff.md new file mode 100644 index 000000000..a8748a5f0 --- /dev/null +++ b/uts/realtime/unit/helpers/mock_vcdiff.md @@ -0,0 +1,210 @@ +# Mock VCDiff Infrastructure + +This document specifies the mock VCDiff encoder and decoder for unit tests. Tests that need to encode or decode vcdiff deltas should reference this document. + +## Purpose + +The mock VCDiff infrastructure provides a deterministic, predictable encoding and decoding algorithm for testing delta compression functionality without a real vcdiff library. The algorithm is designed so that: + +1. **Encoded deltas are inspectable** — the delta payload contains both the base and the new value in a human-readable format +2. **Decoding validates the base** — the decoder verifies that the base argument matches what was used during encoding, catching base payload storage bugs +3. **Round-trip is exact** — `decode(base, encode(base, value)) == value` + +## Algorithm + +### Encoding + +The encoder takes a base payload and a new value, and produces a delta. + +**String inputs:** +```pseudo +encode(base: String, value: String) -> String: + return encode_uri_component(base) + "/" + encode_uri_component(value) +``` + +**Binary inputs:** +```pseudo +encode(base: byte[], value: byte[]) -> byte[]: + return utf8_encode(base64url_encode(base) + "/" + base64url_encode(value)) +``` + +### Decoding + +The decoder takes a base payload and a delta, validates the base, and returns the original value. + +**String inputs:** +```pseudo +decode(base: String, delta: String) -> String: + parts = delta.split("/") + IF length(parts) != 2: + THROW "Invalid delta format" + encoded_base = parts[0] + encoded_value = parts[1] + decoded_base = decode_uri_component(encoded_base) + IF decoded_base != base: + THROW "Base mismatch: expected base does not match delta" + return decode_uri_component(encoded_value) +``` + +**Binary inputs:** +```pseudo +decode(base: byte[], delta: byte[]) -> byte[]: + delta_string = utf8_decode(delta) + parts = delta_string.split("/") + IF length(parts) != 2: + THROW "Invalid delta format" + encoded_base = parts[0] + encoded_value = parts[1] + decoded_base = base64url_decode(encoded_base) + IF decoded_base != base: + THROW "Base mismatch: expected base does not match delta" + return base64url_decode(encoded_value) +``` + +### Examples + +**String round-trip:** +```pseudo +base = "hello world" +value = "goodbye world" + +delta = encode(base, value) +# delta == "hello%20world/goodbye%20world" + +result = decode(base, delta) +# result == "goodbye world" +``` + +**String with special characters:** +```pseudo +base = "msg/1" +value = "msg/2" + +delta = encode(base, value) +# delta == "msg%2F1/msg%2F2" + +result = decode(base, delta) +# result == "msg/2" +``` + +**Binary round-trip:** +```pseudo +base = [0x48, 0x65, 0x6C, 0x6C, 0x6F] # "Hello" in UTF-8 +value = [0x57, 0x6F, 0x72, 0x6C, 0x64] # "World" in UTF-8 + +delta = encode(base, value) +# delta == utf8_encode("SGVsbG8/V29ybGQ") +# == [0x53, 0x47, 0x56, 0x73, 0x62, 0x47, 0x38, 0x2F, +# 0x56, 0x32, 0x39, 0x79, 0x62, 0x47, 0x51] + +result = decode(base, delta) +# result == [0x57, 0x6F, 0x72, 0x6C, 0x64] # "World" +``` + +**Base mismatch (decode fails):** +```pseudo +base = "hello" +value = "world" +delta = encode(base, value) # "hello/world" + +wrong_base = "wrong" +decode(wrong_base, delta) # THROWS "Base mismatch" +``` + +## Mock Interface + +### MockVCDiffEncoder + +```pseudo +interface MockVCDiffEncoder: + encode(base: String, value: String) -> String + encode(base: byte[], value: byte[]) -> byte[] +``` + +### MockVCDiffDecoder + +The decoder implements the `VCDiffDecoder` interface specified in VD2a. + +```pseudo +interface MockVCDiffDecoder: + decode(delta: byte[], base: byte[]) -> byte[] +``` + +Note: The `VCDiffDecoder` interface (VD2) only specifies a binary API +(`decode(delta, base) -> byte[]`). The string overloads on the encoder and +decoder are a convenience for test setup — they allow tests to construct delta +payloads from string values without manually converting to binary. The SDK's +vcdiff plugin integration point uses the binary-only `VCDiffDecoder` interface. + +### FailingMockVCDiffDecoder + +For testing RTL18 decode failure recovery, a decoder that always throws: + +```pseudo +interface FailingMockVCDiffDecoder: + decode(delta: byte[], base: byte[]) -> byte[]: + THROW "Simulated vcdiff decode failure" +``` + +## Usage in Tests + +### Creating delta payloads for mock server messages + +When the mock WebSocket server needs to send a delta-encoded MESSAGE, use the +encoder to construct the delta payload from known base and value strings: + +```pseudo +encoder = MockVCDiffEncoder() + +# First message (non-delta, establishes base payload) +base_data = "first message" + +# Second message (delta, references first) +new_data = "second message" +delta_payload = encoder.encode(base_data, new_data) + +# Server sends the delta message +mock_ws.send_to_client(ProtocolMessage( + action: MESSAGE, + channel: channel_name, + messages: [ + { + id: "msg-2", + data: delta_payload, + encoding: "vcdiff", + extras: { delta: { from: "msg-1", format: "vcdiff" } } + } + ] +)) +``` + +### Registering the decoder as a plugin + +```pseudo +decoder = MockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: decoder } +)) +``` + +### Testing decode failure recovery (RTL18) + +```pseudo +failing_decoder = FailingMockVCDiffDecoder() + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + plugins: { vcdiff: failing_decoder } +)) +``` + +## Notes on Base64URL + +Base64URL encoding uses the URL-safe alphabet (`A-Z`, `a-z`, `0-9`, `-`, `_`) +with no padding (`=`). This is distinct from standard Base64 which uses `+` and +`/`. The URL-safe alphabet is used here because `/` is the separator character +in the delta format. From 9244325cb01c9d5c529d4001c26034ee135840d9 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 26/32] Add test specs for channel attributes, whenState, timeouts, and auto-connect Add test specs for channel attributes (RTL15), channel whenState helper, realtime client timeout configuration, auto-connect behaviour (RTC1b), and REST channel attributes. --- uts/completion-status.md | 24 +- .../unit/channels/channel_attributes.md | 362 ++++++++++++++++++ .../unit/channels/channel_when_state_test.md | 337 ++++++++++++++++ uts/realtime/unit/client/realtime_timeouts.md | 288 ++++++++++++++ .../unit/connection/auto_connect_test.md | 181 +++++++++ 5 files changed, 1180 insertions(+), 12 deletions(-) create mode 100644 uts/realtime/unit/channels/channel_attributes.md create mode 100644 uts/realtime/unit/channels/channel_when_state_test.md create mode 100644 uts/realtime/unit/client/realtime_timeouts.md create mode 100644 uts/realtime/unit/connection/auto_connect_test.md diff --git a/uts/completion-status.md b/uts/completion-status.md index cb1b7d714..022bea644 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -93,9 +93,9 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSL4 | Message encoding (RSL4a–RSL4d4) | Yes — `rest/unit/encoding/message_encoding.md` | | RSL5 | Message encryption (RSL5a–RSL5c) | | | RSL6 | Message decoding (RSL6a–RSL6b) | Yes — `rest/unit/encoding/message_encoding.md` | -| RSL7 | SetOptions function | | -| RSL8 | Status function (RSL8a) | | -| RSL9 | Name attribute | | +| RSL7 | SetOptions function | Yes — `rest/unit/channel/rest_channel_attributes.md` | +| RSL8 | Status function (RSL8a) | Yes — `rest/unit/channel/rest_channel_attributes.md` | +| RSL9 | Name attribute | Yes — `rest/unit/channel/rest_channel_attributes.md` | | RSL10 | Annotations attribute | | | RSL11 | GetMessage function (RSL11a–RSL11c) | | | RSL14 | GetMessageVersions (RSL14a–RSL14c) | | @@ -152,7 +152,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC4 | Auth object attribute (RTC4a) | Yes — `realtime/unit/client/realtime_client.md` | | RTC5 | Stats function (RTC5a–RTC5b) | Yes — `realtime/unit/client/realtime_stats.md` (proxies to RSC6 tests) | | RTC6 | Time function (RTC6a) | Yes — `realtime/unit/client/realtime_time.md` (proxies to RSC16 tests) | -| RTC7 | Uses configured timeouts | | +| RTC7 | Uses configured timeouts | Yes — `realtime/unit/client/realtime_timeouts.md` | | RTC8 | Authorize function for realtime (RTC8a–RTC8c) | Yes — `realtime/unit/auth/realtime_authorize.md`, `realtime/integration/auth.md` | | RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | | RTC10–RTC11 | Deleted | N/A | @@ -169,7 +169,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati |-----------|-------------|---------------| | RTN1 | Uses websocket connection | Information only | | RTN2 | Default host and query string params (RTN2a–RTN2g) | Partial — `realtime/unit/auth/connection_auth_test.md` covers RTN2e | -| RTN3 | AutoConnect option | | +| RTN3 | AutoConnect option | Yes — `realtime/unit/connection/auto_connect_test.md` | | RTN4 | Connection event emission (RTN4a–RTN4i) | Partial — `realtime/integration/connection_lifecycle_test.md` covers RTN4b, RTN4c; `realtime/unit/connection/update_events_test.md` covers RTN4h | | RTN5 | Concurrency test (50+ clients) | | | RTN6 | Successful connection definition | Information only| @@ -229,9 +229,9 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL20 | Last message ID storage | Yes — `realtime/unit/channels/channel_delta_decoding.md`, `realtime/integration/delta_decoding_test.md` | | RTL21 | Message ordering in arrays | Yes — `realtime/unit/channels/channel_delta_decoding.md` | | RTL22 | Message filtering (RTL22a–RTL22d) | | -| RTL23 | Name attribute | | -| RTL24 | ErrorReason attribute | | -| RTL25 | WhenState function (RTL25a–RTL25b) | | +| RTL23 | Name attribute | Yes — `realtime/unit/channels/channel_attributes.md` | +| RTL24 | ErrorReason attribute | Yes — `realtime/unit/channels/channel_attributes.md` | +| RTL25 | WhenState function (RTL25a–RTL25b) | Yes — `realtime/unit/channels/channel_when_state_test.md` | | RTL26 | Annotations attribute | | | RTL27 | Objects attribute (RTL27a–RTL27b) | | | RTL28 | GetMessage function | | @@ -394,14 +394,14 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **REST client** (RSC) | 18 | 15 | Partial | | **REST auth** (RSA) | 15 | 15 | Full | | **REST channels** (RSN) | 4 | 0 | None | -| **REST channel** (RSL) | 13 | 7 | Partial | +| **REST channel** (RSL) | 13 | 10 | Partial | | **REST presence** (RSP) | 5 | 4 | Mostly | | **REST encryption** (RSE) | 2 | 0 | None | | **REST annotations** (RSAN) | 3 | 0 | None | -| **Realtime client** (RTC) | 14 | 12 | Partial | -| **Connection** (RTN) | 23 | 17 | Partial | +| **Realtime client** (RTC) | 14 | 13 | Partial | +| **Connection** (RTN) | 23 | 18 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | -| **Realtime channel** (RTL) | 24 | 20 | Partial | +| **Realtime channel** (RTL) | 24 | 23 | Partial | | **Realtime presence** (RTP) | 15 | 15 | Full | | **Realtime annotations** (RTAN) | 5 | 0 | None | | **EventEmitter** (RTE) | 6 | 0 | None | diff --git a/uts/realtime/unit/channels/channel_attributes.md b/uts/realtime/unit/channels/channel_attributes.md new file mode 100644 index 000000000..d8cf22ac3 --- /dev/null +++ b/uts/realtime/unit/channels/channel_attributes.md @@ -0,0 +1,362 @@ +# RealtimeChannel Attributes + +Spec points: `RTL23`, `RTL24` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL23 - RealtimeChannel name attribute + +**Spec requirement:** `RealtimeChannel#name` attribute is a string containing the +channel's name. + +Tests that the channel name attribute returns the name used when getting the channel. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +channel = client.channels.get("my-channel") +ASSERT channel.name == "my-channel" + +# Also works with special characters +channel2 = client.channels.get("namespace:channel-name") +ASSERT channel2.name == "namespace:channel-name" +``` + +--- + +## RTL24 - errorReason set on channel error + +**Spec requirement:** `RealtimeChannel#errorReason` attribute is an optional +`ErrorInfo` object which is set by the library when an error occurs on the channel. + +Tests that errorReason is populated when a channel receives an ERROR ProtocolMessage +(RTL14). + +### Setup +```pseudo +channel_name = "test-RTL24-error-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Verify errorReason is initially null +ASSERT channel.errorReason IS null + +# Send an ERROR ProtocolMessage for this channel +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + message: "Channel error occurred", + code: 90001, + statusCode: 500 + ) +)) + +AWAIT_STATE channel.state == ChannelState.failed +``` + +### Assertions +```pseudo +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 90001 +ASSERT channel.errorReason.statusCode == 500 +ASSERT channel.errorReason.message == "Channel error occurred" +``` + +--- + +## RTL24 - errorReason set on attach failure + +**Spec requirement:** `RealtimeChannel#errorReason` is set by the library when an +error occurs on the channel, as described by RTL4g. + +Tests that errorReason is populated when an attach is rejected by the server. + +### Setup +```pseudo +channel_name = "test-RTL24-attach-fail-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Reject attach with DETACHED + error + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo( + message: "Permission denied", + code: 40160, + statusCode: 401 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach should fail +AWAIT channel.attach() FAILS WITH error +``` + +### Assertions +```pseudo +# errorReason is set from the DETACHED response error +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 40160 +ASSERT channel.errorReason.statusCode == 401 +``` + +--- + +## RTL24 - errorReason cleared on successful attach + +**Spec requirement:** The errorReason should be cleared when the channel +successfully attaches or reattaches. + +Tests that errorReason is reset to null after a successful attach following a +previous error. + +### Setup +```pseudo +channel_name = "test-RTL24-clear-attach-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + attach_count++ + IF attach_count == 1: + # First attach: reject + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name, + error: ErrorInfo( + message: "Temporary error", + code: 50000, + statusCode: 500 + ) + )) + ELSE: + # Subsequent attaches: succeed + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# First attach fails — errorReason set +AWAIT channel.attach() FAILS WITH error +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 50000 + +# Second attach succeeds — errorReason cleared +AWAIT channel.attach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +ASSERT channel.errorReason IS null +``` + +--- + +## RTL24 - errorReason cleared on successful detach + +**Spec requirement:** The errorReason should be cleared when the channel +successfully detaches. + +Tests that errorReason is reset to null after a successful detach, even if +the channel previously had an error. + +Note: To reliably set errorReason, we use an ERROR ProtocolMessage (which +transitions the channel to FAILED via RTL14). An ATTACHED-while-already-ATTACHED +message (UPDATE) emits a ChannelStateChange event with the error, but +implementations may not persist it to the errorReason attribute — only state +transitions via RTL14 or RTL4g reliably set errorReason. After the ERROR puts +the channel in FAILED, we reattach (which clears errorReason), then verify +detach also leaves errorReason null. + +### Setup +```pseudo +channel_name = "test-RTL24-clear-detach-${random_id()}" +attach_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Send ERROR — channel transitions to FAILED, errorReason is set (RTL14) +mock_ws.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo( + message: "Channel error", + code: 90002, + statusCode: 500 + ) +)) + +AWAIT_STATE channel.state == ChannelState.failed + +ASSERT channel.errorReason IS NOT null +ASSERT channel.errorReason.code == 90002 + +# Reattach — errorReason cleared on successful attach +AWAIT channel.attach() +ASSERT channel.errorReason IS null + +# Now detach — errorReason stays null +AWAIT channel.detach() +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.detached +ASSERT channel.errorReason IS null +``` diff --git a/uts/realtime/unit/channels/channel_when_state_test.md b/uts/realtime/unit/channels/channel_when_state_test.md new file mode 100644 index 000000000..a4b7ace25 --- /dev/null +++ b/uts/realtime/unit/channels/channel_when_state_test.md @@ -0,0 +1,337 @@ +# RealtimeChannel whenState Tests (RTL25) + +Spec points: `RTL25`, `RTL25a`, `RTL25b` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +`RealtimeChannel#whenState` is a convenience function for waiting on channel state: +- If the channel is already in the given state, the listener is called immediately + with a `null` argument (RTL25a). +- Otherwise, the listener is registered with `#once` for the given state, and + called with the `ChannelStateChange` when the state is reached (RTL25b). + +This mirrors the `Connection#whenState` function (RTN26). + +--- + +## RTL25a - whenState calls listener immediately if already in state + +**Spec requirement:** If the channel is already in the given state, calls the +listener with a `null` argument. + +Tests that whenState invokes the callback immediately when the channel is already +in the target state. + +### Setup +```pseudo +channel_name = "test-RTL25a-immediate-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Channel is now ATTACHED — call whenState for current state +callback_invoked = false +callback_arg = undefined + +channel.whenState(ChannelState.attached, (change) => { + callback_invoked = true + callback_arg = change +}) + +# Callback should be invoked synchronously or very quickly +WAIT(50) +``` + +### Assertions +```pseudo +# Callback was invoked immediately +ASSERT callback_invoked == true + +# Callback was invoked with null argument (not a ChannelStateChange object) +ASSERT callback_arg IS null +``` + +--- + +## RTL25b - whenState waits for state if not already in it + +**Spec requirement:** Else, calls `#once` with the given state and listener. + +Tests that whenState waits for a state transition when the channel is not currently +in the target state. + +### Setup +```pseudo +channel_name = "test-RTL25b-deferred-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Channel is in INITIALIZED state — register whenState for ATTACHED +callback_invoked = false +callback_arg = undefined + +channel.whenState(ChannelState.attached, (change) => { + callback_invoked = true + callback_arg = change +}) + +# Callback should not be invoked yet +ASSERT callback_invoked == false + +# Attach the channel +AWAIT channel.attach() + +# Give callback a moment to execute +WAIT(50) +``` + +### Assertions +```pseudo +# Callback was invoked after state transition +ASSERT callback_invoked == true + +# Callback was invoked with a ChannelStateChange object (not null) +ASSERT callback_arg IS NOT null +ASSERT callback_arg.current == ChannelState.attached +ASSERT callback_arg.previous IN [ChannelState.initialized, ChannelState.attaching] +``` + +--- + +## RTL25b - whenState only fires once + +**Spec requirement:** whenState uses `#once`, meaning it should only fire once, +not on every subsequent occurrence of the state. + +Tests that the whenState callback is invoked only once even if the state is entered +multiple times. + +### Setup +```pseudo +channel_name = "test-RTL25b-once-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage( + action: DETACHED, + channel: channel_name + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Register whenState for ATTACHED +callback_count = 0 + +channel.whenState(ChannelState.attached, (change) => { + callback_count++ +}) + +# First attach +AWAIT channel.attach() +WAIT(50) + +# Verify callback was invoked once +ASSERT callback_count == 1 + +# Detach +AWAIT channel.detach() + +# Second attach +AWAIT channel.attach() +WAIT(50) +``` + +### Assertions +```pseudo +# Callback was still only invoked once (not again on second attach) +ASSERT callback_count == 1 +``` + +--- + +## RTL25a - whenState for past state does not fire + +**Spec requirement:** whenState checks the current state. If the channel has +already passed through a state but is no longer in it, whenState should NOT +invoke the callback immediately. + +Tests that whenState for a state that was previously visited but is no longer +current does not fire. + +### Setup +```pseudo +channel_name = "test-RTL25a-past-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach — channel passes through ATTACHING to reach ATTACHED +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Now call whenState for ATTACHING — a past state, not the current one +callback_invoked = false + +channel.whenState(ChannelState.attaching, (change) => { + callback_invoked = true +}) + +# Wait to see if callback is invoked +WAIT(200) +``` + +### Assertions +```pseudo +# Callback should NOT be invoked (we're not in ATTACHING state anymore) +ASSERT callback_invoked == false +``` diff --git a/uts/realtime/unit/client/realtime_timeouts.md b/uts/realtime/unit/client/realtime_timeouts.md new file mode 100644 index 000000000..a406bb87c --- /dev/null +++ b/uts/realtime/unit/client/realtime_timeouts.md @@ -0,0 +1,288 @@ +# Realtime Client Configured Timeouts + +Spec points: `RTC7` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +The realtime client must use the configured timeouts specified in `ClientOptions`, +falling back to client library defaults. This file tests that custom timeout values +are correctly applied to realtime operations. + +Default timeout values (from spec): +- `realtimeRequestTimeout`: 10,000 ms (TO3l11) — used for CONNECT, ATTACH, DETACH, HEARTBEAT +- `disconnectedRetryTimeout`: 15,000 ms (TO3l1) — delay before reconnecting from DISCONNECTED +- `suspendedRetryTimeout`: 30,000 ms (TO3l2) — delay before reconnecting from SUSPENDED + +--- + +## RTC7 - realtimeRequestTimeout applied to channel attach + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions. + +Tests that a custom `realtimeRequestTimeout` is applied to channel attach operations. +When the server does not respond to ATTACH within the timeout, the operation should fail. + +### Setup +```pseudo +channel_name = "test-RTC7-attach-${random_id()}" + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Do NOT respond — simulate timeout + PASS + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 500 +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Start attach — will not get a response +attach_future = channel.attach() + +# Advance past the custom timeout +ADVANCE_TIME(600) + +# Attach should fail +AWAIT attach_future FAILS WITH error +``` + +### Assertions +```pseudo +# The timeout used the custom value (500ms), not the default (10000ms) +ASSERT error IS NOT null +# Channel should be in SUSPENDED state (RTL4f: attach timeout → SUSPENDED) +ASSERT channel.state == ChannelState.suspended +``` + +--- + +## RTC7 - realtimeRequestTimeout applied to channel detach + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions. + +Tests that a custom `realtimeRequestTimeout` is applied to channel detach operations. + +### Setup +```pseudo +channel_name = "test-RTC7-detach-${random_id()}" +ignore_detach = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: channel_name, + flags: 0 + )) + IF msg.action == DETACH AND ignore_detach: + # Do NOT respond — simulate timeout + PASS + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + realtimeRequestTimeout: 500 +)) + +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT channel.attach() + +# Now ignore DETACH messages +ignore_detach = true + +# Start detach — will not get a response +detach_future = channel.detach() + +# Advance past the custom timeout +ADVANCE_TIME(600) + +# Detach should fail +AWAIT detach_future FAILS WITH error +``` + +### Assertions +```pseudo +# The timeout used the custom value (500ms), not the default (10000ms) +ASSERT error IS NOT null +# Channel should still be in ATTACHED state (RTL5f: detach timeout → back to ATTACHED) +ASSERT channel.state == ChannelState.attached +``` + +--- + +## RTC7 - disconnectedRetryTimeout controls reconnection delay + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions. + +Tests that a custom `disconnectedRetryTimeout` controls the delay before reconnection +after the connection is lost. + +Note: Per RTN15a, when a previously-CONNECTED client disconnects, the first +reconnection attempt is immediate (no delay). This immediate retry must be +accounted for. We make all retries after the initial connection fail, and +disable fallback hosts so SocketException errors don't trigger fallback host +iteration. A mock HTTP client is used to avoid real network requests from +the connectivity checker (RTN17j). + +### Setup +```pseudo +connection_attempt_count = 0 + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempt_count++ + IF connection_attempt_count == 1: + # Initial connection succeeds + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 0, + connectionStateTtl: 120000 + ) + )) + ELSE: + # All subsequent attempts fail + conn.respond_with_refused() + } +) +install_mock(mock_ws) + +mock_http = MockHttpClient( + onRequest: (req) => req.respond_with(200, "yes") +) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + disconnectedRetryTimeout: 2000, + fallbackHosts: [] +), httpClient: mock_http) +``` + +### Test Steps +```pseudo +enable_fake_timers() + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected +ASSERT connection_attempt_count == 1 + +# Force disconnection — triggers RTN15a immediate retry (which fails), +# then schedules timer-based retry using disconnectedRetryTimeout +mock_ws.active_connection.close() + +# Wait for the immediate retry to fail and state to return to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Record attempts after the immediate retry cycle +count_after_immediate = connection_attempt_count + +# Advance time by less than the custom timeout — no new retry yet +ADVANCE_TIME(1500) +ASSERT connection_attempt_count == count_after_immediate + +# Advance past the custom timeout (2000ms + jitter margin) +ADVANCE_TIME(1500) +``` + +### Assertions +```pseudo +# A new reconnection attempt was made after the custom delay +ASSERT connection_attempt_count > count_after_immediate +``` + +--- + +## RTC7 - default timeouts applied when not configured + +**Spec requirement:** The client library must use the configured timeouts specified +in the ClientOptions, falling back to the client library defaults. + +Tests that default timeout values are used when no custom values are specified. + +### Setup +```pseudo +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +# Default values per spec (TO3l*) +ASSERT client.options.realtimeRequestTimeout == 10000 +ASSERT client.options.disconnectedRetryTimeout == 15000 +ASSERT client.options.suspendedRetryTimeout == 30000 +ASSERT client.options.httpOpenTimeout == 4000 +ASSERT client.options.httpRequestTimeout == 10000 +``` diff --git a/uts/realtime/unit/connection/auto_connect_test.md b/uts/realtime/unit/connection/auto_connect_test.md new file mode 100644 index 000000000..681b9f783 --- /dev/null +++ b/uts/realtime/unit/connection/auto_connect_test.md @@ -0,0 +1,181 @@ +# Connection Auto Connect Tests (RTN3) + +Spec points: `RTN3` + +## Test Type +Unit test with mocked WebSocket client + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## Purpose + +When the `autoConnect` option is true (the default), a connection should be +initiated immediately when the Realtime client is created. When false, no +connection should be made until `connect()` is explicitly called. + +--- + +## RTN3 - autoConnect true initiates connection immediately + +**Spec requirement:** If connection option `autoConnect` is true, a connection is +initiated immediately. + +Tests that creating a Realtime client with `autoConnect: true` (or default) +initiates a WebSocket connection without requiring an explicit `connect()` call. + +### Setup +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +# Create client with default autoConnect (true) — do NOT call connect() +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret" +)) + +# Wait for connection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# Connection was established automatically +ASSERT client.connection.state == ConnectionState.connected +ASSERT client.connection.id == "connection-id" +``` + +--- + +## RTN3 - autoConnect false does not initiate connection + +**Spec requirement:** Otherwise a connection is only initiated following an explicit +call to `connect()`. + +Tests that creating a Realtime client with `autoConnect: false` does not initiate +a WebSocket connection. + +### Setup +```pseudo +connection_attempted = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +# Create client with autoConnect: false +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Wait briefly to confirm no connection attempt is made +WAIT(500) +``` + +### Assertions +```pseudo +# No connection was attempted +ASSERT connection_attempted == false + +# State remains INITIALIZED +ASSERT client.connection.state == ConnectionState.initialized +``` + +--- + +## RTN3 - explicit connect after autoConnect false + +**Spec requirement:** A connection is only initiated following an explicit call to +`connect()`. + +Tests that after creating a client with `autoConnect: false`, calling `connect()` +initiates the connection. + +### Setup +```pseudo +connection_attempted = false + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + connection_attempted = true + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) +``` + +### Test Steps +```pseudo +# Create client with autoConnect: false +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +# Verify no connection yet +ASSERT client.connection.state == ConnectionState.initialized +ASSERT connection_attempted == false + +# Explicitly connect +client.connect() + +# Wait for connection to complete +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# Connection was established after explicit connect() +ASSERT connection_attempted == true +ASSERT client.connection.state == ConnectionState.connected +``` From ec858b32dcd7c28df8a9fa978308e465707212e0 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 27/32] Add test specs for mutable messages (RTL22/RTL23) Add specs covering the mutable messages feature including message update and delete operations, action fields, and event handling. --- uts/.claude/skills/write-test-spec.md | 36 + uts/completion-status.md | 38 +- .../integration/mutable_messages_test.md | 839 +++++++++++++++ .../unit/channels/channel_annotations.md | 971 ++++++++++++++++++ .../unit/channels/channel_get_message.md | 14 + .../unit/channels/channel_message_versions.md | 14 + .../channels/channel_update_delete_message.md | 580 +++++++++++ uts/rest/integration/mutable_messages.md | 399 +++++++ 8 files changed, 2872 insertions(+), 19 deletions(-) create mode 100644 uts/realtime/integration/mutable_messages_test.md create mode 100644 uts/realtime/unit/channels/channel_annotations.md create mode 100644 uts/realtime/unit/channels/channel_get_message.md create mode 100644 uts/realtime/unit/channels/channel_message_versions.md create mode 100644 uts/realtime/unit/channels/channel_update_delete_message.md create mode 100644 uts/rest/integration/mutable_messages.md diff --git a/uts/.claude/skills/write-test-spec.md b/uts/.claude/skills/write-test-spec.md index 5bf5c6e3b..0dac235e2 100644 --- a/uts/.claude/skills/write-test-spec.md +++ b/uts/.claude/skills/write-test-spec.md @@ -271,6 +271,39 @@ ASSERT request.url.path == "/channels/" + encode_uri_component(channel_name) + " ASSERT request.url.path CONTAINS "/channels/" ``` +### Serialization and Deserialization + +Use `toJson()` and `fromJson()` as the portable pseudocode names for serializing to and deserializing from wire format. These are language-agnostic — implementations will map them to the appropriate mechanism (e.g., `toMap()`/`fromMap()` in Dart, `toJSON()`/`fromJSON()` in JavaScript, `to_dict()`/`from_dict()` in Python). + +```pseudo +# Serializing to wire format +json_data = message.toJson() +ASSERT json_data["action"] == 1 +ASSERT json_data["serial"] == "s1" + +# Deserializing from wire format +msg = Message.fromJson({ + "serial": "msg-serial-1", + "name": "test", + "data": "hello" +}) +ASSERT msg.serial == "msg-serial-1" +``` + +**Do NOT use language-specific names:** +```pseudo +# BAD - Dart-specific +map = message.toMap() +msg = Message.fromMap({...}) + +# BAD - Python-specific +d = message.to_dict() + +# GOOD - portable +json_data = message.toJson() +msg = Message.fromJson({...}) +``` + ### Type Assertions Type assertions verify object types/interfaces. Implementation varies by language: @@ -888,6 +921,9 @@ ASSERT captured_requests[0].headers["Authorization"] IS NOT null 18. ❌ Mock echo missing fields that the test later asserts on (e.g. omitting `data` from a PRESENCE echo, then asserting `member.data`) ✅ Include all fields in the mock echo that the test assertions depend on +19. ❌ Using language-specific serialization names: `toMap()`, `fromMap()`, `to_dict()` + ✅ Use portable `toJson()` / `fromJson()` for wire format serialization + ### Keeping UTS and Dart Tests in Sync When a Dart test reveals a bug or gap in a UTS spec (or vice versa), **always update both**. Common cases: diff --git a/uts/completion-status.md b/uts/completion-status.md index 022bea644..58163e0e2 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -86,7 +86,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSL1 | Publish function (RSL1a–RSL1n1) | Yes — `rest/unit/channel/publish.md`, `rest/integration/publish.md` | +| RSL1 | Publish function (RSL1a–RSL1n1) | Yes — `rest/unit/channel/publish.md`, `rest/unit/channel/publish_result.md`, `rest/integration/publish.md`, `rest/integration/mutable_messages.md` | | RSL1k | Idempotent publishing (RSL1k1–RSL1k5) | Yes — `rest/unit/channel/idempotency.md` | | RSL2 | History function (RSL2a–RSL2b3) | Yes — `rest/unit/channel/history.md`, `rest/integration/history.md` | | RSL3 | Presence attribute | Yes — `rest/unit/presence/rest_presence.md` (with RSP1a) | @@ -96,10 +96,10 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSL7 | SetOptions function | Yes — `rest/unit/channel/rest_channel_attributes.md` | | RSL8 | Status function (RSL8a) | Yes — `rest/unit/channel/rest_channel_attributes.md` | | RSL9 | Name attribute | Yes — `rest/unit/channel/rest_channel_attributes.md` | -| RSL10 | Annotations attribute | | -| RSL11 | GetMessage function (RSL11a–RSL11c) | | -| RSL14 | GetMessageVersions (RSL14a–RSL14c) | | -| RSL15 | UpdateMessage/DeleteMessage/AppendMessage (RSL15a–RSL15f) | | +| RSL10 | Annotations attribute | Yes — `rest/unit/channel/annotations.md` | +| RSL11 | GetMessage function (RSL11a–RSL11c) | Yes — `rest/unit/channel/get_message.md`, `rest/integration/mutable_messages.md` | +| RSL14 | GetMessageVersions (RSL14a–RSL14c) | Yes — `rest/unit/channel/message_versions.md`, `rest/integration/mutable_messages.md` | +| RSL15 | UpdateMessage/DeleteMessage/AppendMessage (RSL15a–RSL15f) | Yes — `rest/unit/channel/update_delete_message.md`, `rest/integration/mutable_messages.md` | ### Plugins @@ -130,7 +130,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSAN1–RSAN3 | Annotations publish/delete/get | | +| RSAN1–RSAN3 | Annotations publish/delete/get | Yes — `rest/unit/channel/annotations.md`, `rest/integration/mutable_messages.md` | ### Forwards Compatibility (REST) @@ -232,11 +232,11 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTL23 | Name attribute | Yes — `realtime/unit/channels/channel_attributes.md` | | RTL24 | ErrorReason attribute | Yes — `realtime/unit/channels/channel_attributes.md` | | RTL25 | WhenState function (RTL25a–RTL25b) | Yes — `realtime/unit/channels/channel_when_state_test.md` | -| RTL26 | Annotations attribute | | +| RTL26 | Annotations attribute | Yes — `realtime/unit/channels/channel_annotations.md` | | RTL27 | Objects attribute (RTL27a–RTL27b) | | -| RTL28 | GetMessage function | | -| RTL31 | GetMessageVersions function | | -| RTL32 | UpdateMessage/DeleteMessage/AppendMessage (RTL32a–RTL32e) | | +| RTL28 | GetMessage function | Yes — `realtime/unit/channels/channel_get_message.md` (proxies to RSL11 tests), `realtime/integration/mutable_messages_test.md` | +| RTL31 | GetMessageVersions function | Yes — `realtime/unit/channels/channel_message_versions.md` (proxies to RSL14 tests), `realtime/integration/mutable_messages_test.md` | +| RTL32 | UpdateMessage/DeleteMessage/AppendMessage (RTL32a–RTL32e) | Yes — `realtime/unit/channels/channel_update_delete_message.md`, `realtime/integration/mutable_messages_test.md` | ### RealtimePresence @@ -265,7 +265,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RTAN1–RTAN5 | Annotations publish/delete/get/subscribe/unsubscribe | | +| RTAN1–RTAN5 | Annotations publish/delete/get/subscribe/unsubscribe | Yes — `realtime/unit/channels/channel_annotations.md`, `realtime/integration/mutable_messages_test.md` | ### EventEmitter @@ -313,7 +313,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5; `realtime/unit/channels/message_field_population.md` covers TM2a, TM2c, TM2f (realtime field population) | +| TM1–TM8 | Message (TM1–TM8a1) | Partial — `rest/unit/types/message_types.md` covers TM1–TM5; `rest/unit/types/mutable_message_types.md` covers TM2j, TM2r, TM2s, TM2u, TM5, TM8; `realtime/unit/channels/message_field_population.md` covers TM2a, TM2c, TM2f (realtime field population) | | DE1–DE2 | DeltaExtras | | | TP1–TP5 | PresenceMessage | Yes — `rest/unit/types/presence_message_types.md` | | OM1–OM5 | ObjectMessage | | @@ -325,7 +325,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | OCN1–OCN3 | ObjectsCounter | | | OME1–OME3 | ObjectsMapEntry | | | OD1–OD5 | ObjectData | | -| TAN1–TAN3 | Annotation | | +| TAN1–TAN3 | Annotation | Yes — `rest/unit/types/mutable_message_types.md` | | TR1–TR4 | ProtocolMessage | | | TG1–TG7 | PaginatedResult | Yes — `rest/unit/types/paginated_result.md`, `rest/integration/pagination.md` | | HP1–HP8 | HttpPaginatedResponse | Yes — `rest/unit/request.md` | @@ -346,7 +346,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | BPR1–BPR2, BPF1–BPF2 | BatchPublish result types | | | BGR1–BGR2, BGF1–BGF2 | BatchPresence result types | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | | PBR1–PBR2 | PublishResult | Yes — `realtime/unit/channels/channel_publish.md` | -| UDR1–UDR2 | UpdateDeleteResult | | +| UDR1–UDR2 | UpdateDeleteResult | Yes — `rest/unit/types/mutable_message_types.md` | | TRT1–TRT2, TRS1–TRS2, TRF1–TRF2 | TokenRevocation types | Yes — `rest/unit/auth/revoke_tokens.md` | | MFI1–MFI2 | MessageFilter | | | REX1–REX2 | ReferenceExtras | | @@ -394,22 +394,22 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **REST client** (RSC) | 18 | 15 | Partial | | **REST auth** (RSA) | 15 | 15 | Full | | **REST channels** (RSN) | 4 | 0 | None | -| **REST channel** (RSL) | 13 | 10 | Partial | +| **REST channel** (RSL) | 13 | 13 | Full | | **REST presence** (RSP) | 5 | 4 | Mostly | | **REST encryption** (RSE) | 2 | 0 | None | -| **REST annotations** (RSAN) | 3 | 0 | None | +| **REST annotations** (RSAN) | 3 | 3 | Full | | **Realtime client** (RTC) | 14 | 13 | Partial | | **Connection** (RTN) | 23 | 18 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | -| **Realtime channel** (RTL) | 24 | 23 | Partial | +| **Realtime channel** (RTL) | 28 | 26 | Partial | | **Realtime presence** (RTP) | 15 | 15 | Full | -| **Realtime annotations** (RTAN) | 5 | 0 | None | +| **Realtime annotations** (RTAN) | 5 | 5 | Full | | **EventEmitter** (RTE) | 6 | 0 | None | | **Backoff/jitter** (RTB) | 1 | 0 | None | | **Wrapper SDK** (WP) | 7 | 0 | None | | **Push notifications** (RSH) | 8 | 0 | None | | **Plugins** (PC/PT/VD) | 3 | 2 | Partial | -| **Data types** | 30 | 9 | Partial | +| **Data types** | 30 | 12 | Partial | | **Option types** | 8 | 5 | Partial | | **Push types** | 3 | 0 | None | | **Introspection** (CR) | 1 | 0 | None | diff --git a/uts/realtime/integration/mutable_messages_test.md b/uts/realtime/integration/mutable_messages_test.md new file mode 100644 index 000000000..1298c9a87 --- /dev/null +++ b/uts/realtime/integration/mutable_messages_test.md @@ -0,0 +1,839 @@ +# Realtime Mutable Messages & Annotations Integration Tests + +Spec points: `RTL28`, `RTL31`, `RTL32`, `RTAN1`, `RTAN2`, `RTAN4` + +## Test Type +Integration test against Ably sandbox + +## Purpose + +End-to-end verification of mutable messages and annotations over realtime +(WebSocket) connections against the Ably sandbox. These tests complement the REST +integration tests (`rest/integration/mutable_messages.md`) by verifying: + +- Update/delete/append via MESSAGE ProtocolMessage (RTL32) rather than HTTP PATCH (RSL15) +- Real-time delivery of mutation events to subscribers +- Annotation publish/delete via ANNOTATION ProtocolMessage (RTAN1/RTAN2) rather than HTTP POST (RSAN1/RSAN2) +- Real-time delivery of annotations to subscribers (RTAN4) +- getMessage and getMessageVersions work from a RealtimeChannel instance (RTL28/RTL31) + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +**Note:** `useBinaryProtocol: false` is required if the SDK does not implement msgpack. + +### App Provisioning + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + api_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {api_key} +``` + +### Notes +- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) +- All clients use `endpoint: "sandbox"` +- All channel names use the `mutable:` namespace prefix — the test app setup configures + the `mutable` namespace with `mutableMessages: true` + +--- + +## RTL32 — Update a message via realtime and observe on subscriber + +**Spec requirement:** RTL32b1 — `updateMessage()` sends a MESSAGE ProtocolMessage +with `MESSAGE_UPDATE` action. RTL32d — returns `UpdateDeleteResult` from ACK. + +Tests that a message published via realtime can be updated via a realtime channel, +and the update event is delivered in real-time to a subscriber on a separate connection. + +### Setup +```pseudo +channel_name = "mutable:rt-update-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +# Collect all messages on client B +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish original message via realtime +AWAIT channel_a.publish(name: "original", data: "v1") + +# Wait for client B to receive the original +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +# Get the serial from the received message +serial = received_messages[0].serial + +# Update via realtime +update_result = AWAIT channel_a.updateMessage( + Message(serial: serial, name: "updated", data: "v2"), + operation: MessageOperation(description: "edited") +) + +# Wait for client B to receive the update event +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +# Update returned a result +ASSERT update_result IS UpdateDeleteResult +ASSERT update_result.versionSerial IS String +ASSERT update_result.versionSerial.length > 0 + +# Client B received the original +ASSERT received_messages[0].action == MessageAction.MESSAGE_CREATE +ASSERT received_messages[0].name == "original" +ASSERT received_messages[0].data == "v1" +ASSERT received_messages[0].serial IS String +ASSERT received_messages[0].serial.length > 0 + +# Client B received the update in real-time +update_msg = received_messages[1] +ASSERT update_msg.action == MessageAction.MESSAGE_UPDATE +ASSERT update_msg.name == "updated" +ASSERT update_msg.data == "v2" +ASSERT update_msg.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL32 — Delete a message via realtime and observe on subscriber + +**Spec requirement:** RTL32b1 — `deleteMessage()` sends a MESSAGE ProtocolMessage +with `MESSAGE_DELETE` action. + +Tests that a published message can be deleted via a realtime channel and the delete +event is delivered in real-time to a subscriber. + +### Setup +```pseudo +channel_name = "mutable:rt-delete-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish original +AWAIT channel_a.publish(name: "to-delete", data: "ephemeral") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Delete via realtime +delete_result = AWAIT channel_a.deleteMessage(Message(serial: serial)) + +# Wait for delete event +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT delete_result IS UpdateDeleteResult +ASSERT delete_result.versionSerial IS String +ASSERT delete_result.versionSerial.length > 0 + +# Client B received the delete event +delete_msg = received_messages[1] +ASSERT delete_msg.action == MessageAction.MESSAGE_DELETE +ASSERT delete_msg.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL32 — Append to a message via realtime and observe on subscriber + +**Spec requirement:** RTL32b1 — `appendMessage()` sends a MESSAGE ProtocolMessage +with `MESSAGE_APPEND` action. + +### Setup +```pseudo +channel_name = "mutable:rt-append-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish original +AWAIT channel_a.publish(name: "appendable", data: "original") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Append via realtime +append_result = AWAIT channel_a.appendMessage( + Message(serial: serial, data: "appended-data"), + operation: MessageOperation(description: "thread reply") +) + +# Wait for append event +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT append_result IS UpdateDeleteResult +ASSERT append_result.versionSerial IS String +ASSERT append_result.versionSerial.length > 0 + +# Client B received the append event +append_msg = received_messages[1] +ASSERT append_msg.action == MessageAction.MESSAGE_APPEND +ASSERT append_msg.data == "appended-data" +ASSERT append_msg.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL32 — Full mutation lifecycle: update, append, delete observed in sequence + +**Spec requirement:** RTL32b1, RTL32d — all three mutation types delivered in order. + +Tests that a subscriber receives the complete sequence of mutation events +(create → update → append → delete) in the correct order with correct actions. + +### Setup +```pseudo +channel_name = "mutable:rt-lifecycle-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name) +channel_b = client_b.channels.get(channel_name) + +AWAIT channel_b.attach() + +received_messages = [] +channel_b.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# 1. Publish original +AWAIT channel_a.publish(name: "lifecycle", data: "v1") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# 2. Update +AWAIT channel_a.updateMessage( + Message(serial: serial, name: "lifecycle", data: "v2"), + operation: MessageOperation(description: "edit 1") +) + +poll_until( + condition: FUNCTION() => received_messages.length >= 2, + interval: 200ms, + timeout: 10s +) + +# 3. Append +AWAIT channel_a.appendMessage( + Message(serial: serial, data: "reply-data"), + operation: MessageOperation(description: "thread reply") +) + +poll_until( + condition: FUNCTION() => received_messages.length >= 3, + interval: 200ms, + timeout: 10s +) + +# 4. Delete +AWAIT channel_a.deleteMessage(Message(serial: serial)) + +poll_until( + condition: FUNCTION() => received_messages.length >= 4, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received_messages.length == 4 + +# Create +ASSERT received_messages[0].action == MessageAction.MESSAGE_CREATE +ASSERT received_messages[0].name == "lifecycle" +ASSERT received_messages[0].data == "v1" +ASSERT received_messages[0].serial == serial + +# Update +ASSERT received_messages[1].action == MessageAction.MESSAGE_UPDATE +ASSERT received_messages[1].name == "lifecycle" +ASSERT received_messages[1].data == "v2" +ASSERT received_messages[1].serial == serial + +# Append +ASSERT received_messages[2].action == MessageAction.MESSAGE_APPEND +ASSERT received_messages[2].data == "reply-data" +ASSERT received_messages[2].serial == serial + +# Delete +ASSERT received_messages[3].action == MessageAction.MESSAGE_DELETE +ASSERT received_messages[3].serial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTL28, RTL31 — getMessage and getMessageVersions from realtime channel + +**Spec requirement:** RTL28 — `RealtimeChannel#getMessage` same as `RestChannel#getMessage`. +RTL31 — `RealtimeChannel#getMessageVersions` same as `RestChannel#getMessageVersions`. + +Tests that getMessage and getMessageVersions work when called on a RealtimeChannel +after publishing and updating a message via realtime. + +### Setup +```pseudo +channel_name = "mutable:rt-get-versions-" + random_id() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel = client.channels.get(channel_name) +AWAIT channel.attach() + +# Use subscribe to capture the serial from the published message +received_messages = [] +channel.subscribe((msg) => { + received_messages.append(msg) +}) +``` + +### Test Steps +```pseudo +# Publish original +AWAIT channel.publish(name: "versioned", data: "v1") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Update twice +AWAIT channel.updateMessage( + Message(serial: serial, data: "v2"), + operation: MessageOperation(description: "first edit") +) +AWAIT channel.updateMessage( + Message(serial: serial, data: "v3"), + operation: MessageOperation(description: "second edit") +) + +# Wait for propagation before HTTP-based reads +wait_for_propagation(2 seconds) + +# getMessage — should return latest version +msg = AWAIT channel.getMessage(serial) + +# getMessageVersions — should return version history +versions = AWAIT channel.getMessageVersions(serial) +``` + +### Assertions +```pseudo +# getMessage returns the latest state +ASSERT msg IS Message +ASSERT msg.serial == serial +ASSERT msg.data == "v3" +ASSERT msg.action == MessageAction.MESSAGE_UPDATE + +# getMessageVersions returns history +ASSERT versions IS PaginatedResult +ASSERT versions.items.length >= 3 # original + 2 updates + +FOR item IN versions.items: + ASSERT item IS Message + ASSERT item.serial == serial +``` + +### Cleanup +```pseudo +AWAIT client.close() +``` + +--- + +## RTAN1, RTAN2, RTAN4 — Annotation publish, subscribe, and delete via realtime + +**Spec requirement:** RTAN1c — publish sends ANNOTATION ProtocolMessage. +RTAN2a — delete sends ANNOTATION_DELETE. RTAN4b — annotations delivered to subscribers. + +Tests that annotations published via a realtime channel are delivered in real-time +to a subscriber on a separate connection, and that annotation delete events are +also delivered. + +### Setup +```pseudo +channel_name = "mutable:rt-annotations-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name, + options: ChannelOptions(modes: [ANNOTATION_PUBLISH, ANNOTATION_SUBSCRIBE]) +) +channel_b = client_b.channels.get(channel_name, + options: ChannelOptions(modes: [ANNOTATION_SUBSCRIBE]) +) + +AWAIT channel_b.attach() + +# Subscribe to annotations on client B +received_annotations = [] +channel_b.annotations.subscribe((ann) => { + received_annotations.append(ann) +}) + +# Also subscribe to messages to capture the serial +received_messages = [] +channel_a.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish a message to annotate +AWAIT channel_a.publish(name: "annotatable", data: "content") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Publish an annotation via realtime +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) + +# Wait for annotation to arrive on client B +poll_until( + condition: FUNCTION() => received_annotations.length >= 1, + interval: 200ms, + timeout: 10s +) + +# Delete the annotation via realtime +AWAIT channel_a.annotations.delete(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) + +# Wait for delete event on client B +poll_until( + condition: FUNCTION() => received_annotations.length >= 2, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +ASSERT received_annotations.length == 2 + +# Create event +create_ann = received_annotations[0] +ASSERT create_ann.action == AnnotationAction.ANNOTATION_CREATE +ASSERT create_ann.type == "com.ably.reactions" +ASSERT create_ann.name == "like" +ASSERT create_ann.messageSerial == serial + +# Delete event +delete_ann = received_annotations[1] +ASSERT delete_ann.action == AnnotationAction.ANNOTATION_DELETE +ASSERT delete_ann.type == "com.ably.reactions" +ASSERT delete_ann.name == "like" +ASSERT delete_ann.messageSerial == serial +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTAN4c — Annotation subscribe with type filtering + +**Spec requirement:** RTAN4c — subscribe with a `type` filter delivers only +annotations whose type matches. + +Tests that a subscriber filtering by annotation type only receives matching +annotations when multiple types are published. + +### Setup +```pseudo +channel_name = "mutable:rt-ann-filter-" + random_id() + +client_a = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_b = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client_a.connect() +client_b.connect() + +AWAIT_STATE client_a.connection.state == ConnectionState.connected + WITH timeout: 10 seconds +AWAIT_STATE client_b.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel_a = client_a.channels.get(channel_name, + options: ChannelOptions(modes: [ANNOTATION_PUBLISH, ANNOTATION_SUBSCRIBE]) +) +channel_b = client_b.channels.get(channel_name, + options: ChannelOptions(modes: [ANNOTATION_SUBSCRIBE]) +) + +AWAIT channel_b.attach() + +# Subscribe only to "com.ably.reactions" type +filtered_annotations = [] +channel_b.annotations.subscribe(type: "com.ably.reactions", (ann) => { + filtered_annotations.append(ann) +}) + +# Also subscribe to all annotations to know when all have been delivered +all_annotations = [] +channel_b.annotations.subscribe((ann) => { + all_annotations.append(ann) +}) + +received_messages = [] +channel_a.subscribe((msg) => { + received_messages.append(msg) +}) + +AWAIT channel_a.attach() +``` + +### Test Steps +```pseudo +# Publish a message +AWAIT channel_a.publish(name: "multi-type", data: "content") + +poll_until( + condition: FUNCTION() => received_messages.length >= 1, + interval: 200ms, + timeout: 10s +) + +serial = received_messages[0].serial + +# Publish annotations of different types +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.example.comments", + name: "comment" +)) +AWAIT channel_a.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "heart" +)) + +# Wait for all 3 annotations to arrive on client B (unfiltered listener) +poll_until( + condition: FUNCTION() => all_annotations.length >= 3, + interval: 200ms, + timeout: 10s +) +``` + +### Assertions +```pseudo +# Unfiltered listener got all 3 +ASSERT all_annotations.length == 3 + +# Filtered listener got only the 2 "com.ably.reactions" annotations +ASSERT filtered_annotations.length == 2 +ASSERT filtered_annotations[0].type == "com.ably.reactions" +ASSERT filtered_annotations[0].name == "like" +ASSERT filtered_annotations[1].type == "com.ably.reactions" +ASSERT filtered_annotations[1].name == "heart" +``` + +### Cleanup +```pseudo +AWAIT client_a.close() +AWAIT client_b.close() +``` + +--- + +## RTAN4d — Annotation subscribe implicitly attaches channel + +**Spec requirement:** RTAN4d — subscribe has the same connection and channel state +preconditions as `RealtimeChannel#subscribe`, including implicit attach. + +Tests that calling `annotations.subscribe()` on a channel that is not attached +causes it to implicitly attach. + +### Setup +```pseudo +channel_name = "mutable:rt-ann-implicit-attach-" + random_id() + +client = Realtime(options: ClientOptions( + key: api_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + WITH timeout: 10 seconds + +channel = client.channels.get(channel_name, + options: ChannelOptions(modes: [ANNOTATION_SUBSCRIBE]) +) +``` + +### Test Steps +```pseudo +# Channel should be initialized (not attached) +ASSERT channel.state == ChannelState.initialized + +# Subscribe to annotations — should trigger implicit attach +channel.annotations.subscribe((ann) => { + # no-op +}) + +# Wait for channel to become attached +AWAIT_STATE channel.state == ChannelState.attached + WITH timeout: 10 seconds +``` + +### Assertions +```pseudo +ASSERT channel.state == ChannelState.attached +``` + +### Cleanup +```pseudo +AWAIT client.close() +``` diff --git a/uts/realtime/unit/channels/channel_annotations.md b/uts/realtime/unit/channels/channel_annotations.md new file mode 100644 index 000000000..d8836fcb0 --- /dev/null +++ b/uts/realtime/unit/channels/channel_annotations.md @@ -0,0 +1,971 @@ +# RealtimeChannel Annotations Tests + +Spec points: `RTL26`, `RTAN1`, `RTAN1a`, `RTAN1b`, `RTAN1c`, `RTAN1d`, `RTAN2`, `RTAN2a`, `RTAN3`, `RTAN3a`, `RTAN4`, `RTAN4a`, `RTAN4b`, `RTAN4c`, `RTAN4d`, `RTAN4e`, `RTAN4e1`, `RTAN5`, `RTAN5a` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL26 — channel.annotations returns RealtimeAnnotations + +**Spec requirement:** RTL26 — `RealtimeChannel#annotations` attribute contains the `RealtimeAnnotations` object for this channel. + +Tests that the channel exposes an `annotations` attribute of type `RealtimeAnnotations`. + +### Setup +```pseudo +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get("test-RTL26") +``` + +### Assertions +```pseudo +ASSERT channel.annotations IS RealtimeAnnotations +``` + +--- + +## RTAN1a, RTAN1c — publish sends ANNOTATION ProtocolMessage with ANNOTATION_CREATE + +| Spec | Requirement | +|------|-------------| +| RTAN1a | Accepts same arguments and performs same validation, field setting, and data encoding as RSAN1 | +| RTAN1c | Must put annotation into array in `annotations` field of a `ProtocolMessage` with action `ANNOTATION`, channel set to channel name | + +Tests that `annotations.publish()` sends a correctly formatted ANNOTATION ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTAN1-publish-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +annotation_pm = null +FOR pm IN captured_messages: + IF pm.action == ANNOTATION: + annotation_pm = pm +ASSERT annotation_pm IS NOT null + +ASSERT annotation_pm.channel == channel_name +ASSERT annotation_pm.annotations.length == 1 + +ann = annotation_pm.annotations[0] +ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE # numeric: 0 +ASSERT ann.messageSerial == "msg-serial-1" +ASSERT ann.type == "com.example.reaction" +ASSERT ann.name == "like" +``` + +--- + +## RTAN1a — publish validates type is required + +**Spec requirement:** RTAN1a — Performs the same validation as RSAN1. Per RSAN1a3, the `type` field is required. + +Tests that publishing an annotation without a `type` field throws an error. + +### Setup +```pseudo +channel_name = "test-RTAN1a-validate-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + name: "like" +)) FAILS WITH error +ASSERT error.code == 40003 +``` + +--- + +## RTAN1a — publish encodes data per RSL4 + +**Spec requirement:** RTAN1a — Performs the same data encoding as RSAN1. Per RSAN1c3, data must be encoded per RSL4. + +Tests that JSON data in an annotation is encoded following message encoding rules. + +### Setup +```pseudo +channel_name = "test-RTAN1a-encode-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.data", + data: { "key": "value", "nested": { "a": 1 } } +)) +``` + +### Assertions +```pseudo +annotation_pm = null +FOR pm IN captured_messages: + IF pm.action == ANNOTATION: + annotation_pm = pm +ASSERT annotation_pm IS NOT null + +ann = annotation_pm.annotations[0] +ASSERT ann.data IS String +ASSERT ann.encoding == "json" +ASSERT parse_json(ann.data) == { "key": "value", "nested": { "a": 1 } } +``` + +--- + +## RTAN1b — publish has same connection and channel state conditions as message publishing + +**Spec requirement:** RTAN1b — Has the same connection and channel state conditions as message publishing, see RTL6c. + +Tests that annotation publish fails in FAILED and SUSPENDED channel states, matching the behaviour tested in `uts/test/realtime/unit/channels/channel_publish.md` (RTL6c4). The same connection and channel state preconditions apply. + +### Setup +```pseudo +channel_name = "test-RTAN1b-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Send ERROR to put channel in FAILED state + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ERROR, + channel: channel_name, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +# Attempt attach — will fail, putting channel in FAILED +TRY: + AWAIT channel.attach() +CATCH: + # Expected — channel is now FAILED + +ASSERT channel.state == FAILED + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) FAILS WITH error +ASSERT error IS NOT null +``` + +--- + +## RTAN1d — publish indicates success/failure via ACK/NACK + +**Spec requirement:** RTAN1d — Must indicate success or failure of the publish (once ACKed or NACKed) in the same way as `RealtimeChannel#publish`. + +Tests that the publish resolves on ACK and rejects on NACK. + +### Setup (ACK case) +```pseudo +channel_name = "test-RTAN1d-ack-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps (ACK) +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +# Should resolve without error +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +# If we get here, publish succeeded (no assertion needed beyond no throw) +``` + +### Setup (NACK case) +```pseudo +channel_name = "test-RTAN1d-nack-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(NACK( + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions (NACK) +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.publish("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) FAILS WITH error +ASSERT error.code == 40160 +``` + +--- + +## RTAN2a — delete sends ANNOTATION ProtocolMessage with ANNOTATION_DELETE + +**Spec requirement:** RTAN2a — Must be identical to RTAN1 `publish()` except that the `Annotation.action` is set to `ANNOTATION_DELETE`, not `ANNOTATION_CREATE`. + +Tests that `annotations.delete()` sends ANNOTATION_DELETE. + +### Setup +```pseudo +channel_name = "test-RTAN2-delete-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH + )) + ELSE IF msg.action == ANNOTATION: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1 + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.annotations.delete("msg-serial-1", Annotation( + type: "com.example.reaction", + name: "like" +)) +``` + +### Assertions +```pseudo +annotation_pm = null +FOR pm IN captured_messages: + IF pm.action == ANNOTATION: + annotation_pm = pm +ASSERT annotation_pm IS NOT null + +ann = annotation_pm.annotations[0] +ASSERT ann.action == AnnotationAction.ANNOTATION_DELETE # numeric: 1 +ASSERT ann.messageSerial == "msg-serial-1" +ASSERT ann.type == "com.example.reaction" +ASSERT ann.name == "like" +``` + +--- + +## RTAN3a — get is identical to RestAnnotations#get + +**Spec requirement:** RTAN3a — Is identical to `RestAnnotations#get`. + +`RealtimeAnnotations#get` uses the same underlying REST endpoint as `RestAnnotations#get`. The tests in `uts/test/rest/unit/channel/annotations.md` (covering RSAN3) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. + +--- + +## RTAN4a, RTAN4b — subscribe delivers annotations from ANNOTATION ProtocolMessage + +| Spec | Requirement | +|------|-------------| +| RTAN4a | Should support the same set of type signatures as `RealtimeChannel#subscribe` (RTL7), except `name` is called `type` | +| RTAN4b | When the library receives a `ProtocolMessage` with action `ANNOTATION`, every member of the `annotations` array should be delivered to registered listeners | + +Tests that subscribing to annotations delivers decoded Annotation objects when an ANNOTATION ProtocolMessage is received. + +### Setup +```pseudo +channel_name = "test-RTAN4-subscribe-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +received_annotations = [] +channel.annotations.subscribe((annotation) => { + received_annotations.append(annotation) +}) + +# Server sends ANNOTATION ProtocolMessage with two annotations +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "id": "ann-1", + "action": 0, + "type": "com.example.reaction", + "name": "like", + "clientId": "user-1", + "serial": "ann-serial-1", + "messageSerial": "msg-serial-1", + "timestamp": 1700000000000 + }, + { + "id": "ann-2", + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "clientId": "user-2", + "serial": "ann-serial-2", + "messageSerial": "msg-serial-1", + "timestamp": 1700000001000 + } + ] +)) +``` + +### Assertions +```pseudo +ASSERT received_annotations.length == 2 + +ann1 = received_annotations[0] +ASSERT ann1 IS Annotation +ASSERT ann1.id == "ann-1" +ASSERT ann1.action == AnnotationAction.ANNOTATION_CREATE +ASSERT ann1.type == "com.example.reaction" +ASSERT ann1.name == "like" +ASSERT ann1.clientId == "user-1" +ASSERT ann1.serial == "ann-serial-1" +ASSERT ann1.messageSerial == "msg-serial-1" +ASSERT ann1.timestamp == 1700000000000 + +ann2 = received_annotations[1] +ASSERT ann2.name == "heart" +ASSERT ann2.clientId == "user-2" +``` + +--- + +## RTAN4c — subscribe with type filter delivers only matching annotations + +**Spec requirement:** RTAN4c — If the user subscribes with a `type` (or array of types), the SDK must deliver only annotations whose `type` field exactly equals the requested type. + +Tests that type-filtered subscription only delivers matching annotations. + +### Setup +```pseudo +channel_name = "test-RTAN4c-filter-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +reaction_annotations = [] +channel.annotations.subscribe( + type: "com.example.reaction", + listener: (annotation) => { + reaction_annotations.append(annotation) + } +) + +# Server sends mixed annotation types +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "like", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-1", + "timestamp": 1700000000000 + }, + { + "action": 0, + "type": "com.example.comment", + "name": "text", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-2", + "timestamp": 1700000001000 + }, + { + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-3", + "timestamp": 1700000002000 + } + ] +)) +``` + +### Assertions +```pseudo +# Only reaction annotations delivered +ASSERT reaction_annotations.length == 2 +ASSERT reaction_annotations[0].name == "like" +ASSERT reaction_annotations[1].name == "heart" +``` + +--- + +## RTAN4d — subscribe implicitly attaches channel + +**Spec requirement:** RTAN4d — Has the same connection and channel state preconditions and return value as `RealtimeChannel#subscribe`, including implicitly attaching unless the user requests otherwise per RTL7g/RTL7h. + +Tests that subscribing to annotations triggers an implicit attach from INITIALIZED state when `attachOnSubscribe` is true (the default). + +### Setup +```pseudo +channel_name = "test-RTAN4d-attach-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +# Default attachOnSubscribe is true +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +ASSERT channel.state == INITIALIZED + +channel.annotations.subscribe((annotation) => {}) + +# Wait for implicit attach to complete +AWAIT_STATE channel.state == ATTACHED +``` + +### Assertions +```pseudo +ASSERT channel.state == ATTACHED +``` + +--- + +## RTAN4e — subscribe warns when ANNOTATION_SUBSCRIBE mode not granted + +**Spec requirement:** RTAN4e — Once the channel is in the attached state, the channel modes are checked for the presence of the `ANNOTATION_SUBSCRIBE` mode. If missing, the library should log a warning. + +Tests that a warning is logged when the channel is attached without ANNOTATION_SUBSCRIBE mode. + +### Setup +```pseudo +channel_name = "test-RTAN4e-warn-${random_id()}" +log_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + # Respond with ATTACHED but WITHOUT ANNOTATION_SUBSCRIBE flag + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + } +) + +client = Realtime( + options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + logHandler: (level, message) => { + IF level == WARN: + log_messages.append(message) + } + ), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +channel.annotations.subscribe((annotation) => {}) +``` + +### Assertions +```pseudo +# A warning should have been logged about ANNOTATION_SUBSCRIBE mode +ASSERT log_messages.length >= 1 +found_warning = false +FOR msg IN log_messages: + IF msg CONTAINS "ANNOTATION_SUBSCRIBE": + found_warning = true +ASSERT found_warning == true +``` + +--- + +## RTAN4e1 — subscribe does not warn when not attached and attachOnSubscribe is false + +**Spec requirement:** RTAN4e1 — This check does not apply if `attachOnSubscribe` has been set to `false` and the channel is not attached. + +Tests that no ANNOTATION_SUBSCRIBE warning is emitted when the channel is not attached and attachOnSubscribe is false. + +### Setup +```pseudo +channel_name = "test-RTAN4e1-${random_id()}" +log_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + } +) + +client = Realtime( + options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + logHandler: (level, message) => { + IF level == WARN: + log_messages.append(message) + } + ), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED + +# Channel is INITIALIZED, not attached +ASSERT channel.state == INITIALIZED + +channel.annotations.subscribe((annotation) => {}) +``` + +### Assertions +```pseudo +# No warning about ANNOTATION_SUBSCRIBE should be logged +found_warning = false +FOR msg IN log_messages: + IF msg CONTAINS "ANNOTATION_SUBSCRIBE": + found_warning = true +ASSERT found_warning == false +``` + +--- + +## RTAN5a — unsubscribe removes listeners + +**Spec requirement:** RTAN5a — Should support the same set of type signatures as `RealtimeChannel#unsubscribe` (RTL8), except that the `name` argument is called `type`. + +Tests that unsubscribing removes annotation listeners. + +### Setup +```pseudo +channel_name = "test-RTAN5-unsub-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +received_annotations = [] +listener = (annotation) => { + received_annotations.append(annotation) +} +channel.annotations.subscribe(listener) + +# Send first annotation — should be received +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "like", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-1", + "timestamp": 1700000000000 + } + ] +)) + +ASSERT received_annotations.length == 1 + +# Unsubscribe +channel.annotations.unsubscribe(listener) + +# Send second annotation — should NOT be received +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "heart", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-2", + "timestamp": 1700000001000 + } + ] +)) +``` + +### Assertions +```pseudo +# Only the first annotation was received +ASSERT received_annotations.length == 1 +ASSERT received_annotations[0].name == "like" +``` + +--- + +## RTAN5a — unsubscribe with type removes only type-filtered listener + +Tests that unsubscribing with a type filter only removes that specific type's listener. + +### Setup +```pseudo +channel_name = "test-RTAN5a-typed-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH | ANNOTATION_PUBLISH | ANNOTATION_SUBSCRIBE + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name, RealtimeChannelOptions( + attachOnSubscribe: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +reaction_received = [] +comment_received = [] + +reaction_listener = (ann) => { reaction_received.append(ann) } +comment_listener = (ann) => { comment_received.append(ann) } + +channel.annotations.subscribe(type: "com.example.reaction", listener: reaction_listener) +channel.annotations.subscribe(type: "com.example.comment", listener: comment_listener) + +# Unsubscribe only reactions +channel.annotations.unsubscribe(type: "com.example.reaction", listener: reaction_listener) + +# Send both types +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ANNOTATION, + channel: channel_name, + annotations: [ + { + "action": 0, + "type": "com.example.reaction", + "name": "like", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-1", + "timestamp": 1700000000000 + }, + { + "action": 0, + "type": "com.example.comment", + "name": "text", + "messageSerial": "msg-serial-1", + "serial": "ann-serial-2", + "timestamp": 1700000001000 + } + ] +)) +``` + +### Assertions +```pseudo +# Reactions unsubscribed, comments still active +ASSERT reaction_received.length == 0 +ASSERT comment_received.length == 1 +ASSERT comment_received[0].type == "com.example.comment" +``` diff --git a/uts/realtime/unit/channels/channel_get_message.md b/uts/realtime/unit/channels/channel_get_message.md new file mode 100644 index 000000000..98471eef0 --- /dev/null +++ b/uts/realtime/unit/channels/channel_get_message.md @@ -0,0 +1,14 @@ +# RealtimeChannel GetMessage Tests + +Spec points: `RTL28` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTL28 - RealtimeChannel#getMessage is identical to RestChannel#getMessage + +**Spec requirement:** `RealtimeChannel#getMessage` function: same as `RestChannel#getMessage`. + +`RealtimeChannel#getMessage` uses the same underlying REST endpoint as `RestChannel#getMessage`. The tests in `uts/test/rest/unit/channel/get_message.md` (covering RSL11) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. diff --git a/uts/realtime/unit/channels/channel_message_versions.md b/uts/realtime/unit/channels/channel_message_versions.md new file mode 100644 index 000000000..8d47d3706 --- /dev/null +++ b/uts/realtime/unit/channels/channel_message_versions.md @@ -0,0 +1,14 @@ +# RealtimeChannel GetMessageVersions Tests + +Spec points: `RTL31` + +## Test Type +Unit test with mocked HTTP client + +--- + +## RTL31 - RealtimeChannel#getMessageVersions is identical to RestChannel#getMessageVersions + +**Spec requirement:** `RealtimeChannel#getMessageVersions` function: same as `RestChannel#getMessageVersions`. + +`RealtimeChannel#getMessageVersions` uses the same underlying REST endpoint as `RestChannel#getMessageVersions`. The tests in `uts/test/rest/unit/channel/message_versions.md` (covering RSL14) should be used to verify that all the same behaviour, parameters, and return types apply when called on a `RealtimeChannel` instance. diff --git a/uts/realtime/unit/channels/channel_update_delete_message.md b/uts/realtime/unit/channels/channel_update_delete_message.md new file mode 100644 index 000000000..bc8ab61b8 --- /dev/null +++ b/uts/realtime/unit/channels/channel_update_delete_message.md @@ -0,0 +1,580 @@ +# RealtimeChannel UpdateMessage/DeleteMessage/AppendMessage Tests + +Spec points: `RTL32`, `RTL32a`, `RTL32b`, `RTL32b1`, `RTL32b2`, `RTL32c`, `RTL32d`, `RTL32e` + +## Test Type +Unit test with mocked WebSocket + +## Mock WebSocket Infrastructure + +See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSocket Infrastructure specification. + +--- + +## RTL32b, RTL32b1 — updateMessage sends MESSAGE ProtocolMessage with action MESSAGE_UPDATE + +| Spec | Requirement | +|------|-------------| +| RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | +| RTL32b1 | `action` set to `MESSAGE_UPDATE` for `updateMessage()` | + +Tests that `updateMessage()` sends a MESSAGE ProtocolMessage with the message action set to MESSAGE_UPDATE. + +### Setup +```pseudo +channel_name = "test-RTL32-update-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", name: "updated", data: "new-data"), +) +``` + +### Assertions +```pseudo +# Find the MESSAGE ProtocolMessage (not the ATTACH) +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +ASSERT message_pm.channel == channel_name +ASSERT message_pm.messages.length == 1 + +msg = message_pm.messages[0] +ASSERT msg.action == MessageAction.MESSAGE_UPDATE # numeric: 1 +ASSERT msg.serial == "msg-serial-1" +ASSERT msg.name == "updated" +ASSERT msg.data == "new-data" +``` + +--- + +## RTL32b, RTL32b1 — deleteMessage sends MESSAGE ProtocolMessage with action MESSAGE_DELETE + +| Spec | Requirement | +|------|-------------| +| RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | +| RTL32b1 | `action` set to `MESSAGE_DELETE` for `deleteMessage()` | + +Tests that `deleteMessage()` sends MESSAGE_DELETE. + +### Setup +```pseudo +channel_name = "test-RTL32-delete-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.deleteMessage( + Message(serial: "msg-serial-1"), +) +``` + +### Assertions +```pseudo +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +msg = message_pm.messages[0] +ASSERT msg.action == MessageAction.MESSAGE_DELETE # numeric: 2 +ASSERT msg.serial == "msg-serial-1" +``` + +--- + +## RTL32b, RTL32b1 — appendMessage sends MESSAGE ProtocolMessage with action MESSAGE_APPEND + +| Spec | Requirement | +|------|-------------| +| RTL32b | Send a `MESSAGE` `ProtocolMessage` containing a single `Message` | +| RTL32b1 | `action` set to `MESSAGE_APPEND` for `appendMessage()` | + +Tests that `appendMessage()` sends MESSAGE_APPEND. + +### Setup +```pseudo +channel_name = "test-RTL32-append-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.appendMessage( + Message(serial: "msg-serial-1", data: "appended-data"), + operation: MessageOperation(description: "appended content") +) +``` + +### Assertions +```pseudo +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +msg = message_pm.messages[0] +ASSERT msg.action == MessageAction.MESSAGE_APPEND # numeric: 5 +ASSERT msg.serial == "msg-serial-1" +ASSERT msg.data == "appended-data" +``` + +--- + +## RTL32b2 — version field set from MessageOperation + +**Spec requirement:** RTL32b2 — `version` set to the `MessageOperation` object if provided. + +Tests that the `version` field on the wire message is set to the MessageOperation when provided, and absent when not provided. + +### Setup +```pseudo +channel_name = "test-RTL32b2-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +# With operation +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "v2"), + operation: MessageOperation( + description: "edited content", + metadata: { "reason": "typo" } + ) +) + +# Without operation +AWAIT channel.updateMessage( + Message(serial: "msg-serial-2", data: "v2") +) +``` + +### Assertions +```pseudo +message_pms = [] +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pms.append(pm) +ASSERT message_pms.length == 2 + +# With operation: version field present +msg_with_op = message_pms[0].messages[0] +ASSERT msg_with_op.version IS NOT null +ASSERT msg_with_op.version.description == "edited content" +ASSERT msg_with_op.version.metadata["reason"] == "typo" + +# Without operation: version field absent +msg_without_op = message_pms[1].messages[0] +ASSERT msg_without_op.version IS null +``` + +--- + +## RTL32c — does not mutate user-supplied Message + +**Spec requirement:** RTL32c — The SDK must not mutate the user-supplied `Message` object. + +Tests that the original Message object is unchanged after calling updateMessage. + +### Setup +```pseudo +channel_name = "test-RTL32c-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +original_message = Message(serial: "msg-serial-1", name: "original", data: "original-data") +AWAIT channel.updateMessage(original_message) +``` + +### Assertions +```pseudo +# Original message unchanged +ASSERT original_message.name == "original" +ASSERT original_message.data == "original-data" +ASSERT original_message.serial == "msg-serial-1" +ASSERT original_message.action IS null +``` + +--- + +## RTL32d — returns UpdateDeleteResult from ACK + +**Spec requirement:** RTL32d — On success, returns an `UpdateDeleteResult` object containing the version serial of the published update, obtained from the first element of the `serials` array of the `res` field of the `ACK`. + +Tests that the result is parsed from the ACK ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTL32d-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["01770000000000-000@abcdef:000"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +result = AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "updated") +) +``` + +### Assertions +```pseudo +ASSERT result IS UpdateDeleteResult +ASSERT result.versionSerial == "01770000000000-000@abcdef:000" +``` + +--- + +## RTL32d — NACK returns error + +**Spec requirement:** RTL32d — Indicates an error if the operation was not successful. + +Tests that a NACK results in an error. + +### Setup +```pseudo +channel_name = "test-RTL32d-nack-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(NACK( + msgSerial: msg.msgSerial, + count: 1, + error: ErrorInfo(code: 40160, message: "Not permitted") + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "updated") +) FAILS WITH error +``` + +### Assertions +```pseudo +ASSERT error.code == 40160 +``` + +--- + +## RTL32e — params sent in ProtocolMessage.params + +**Spec requirement:** RTL32e — Any params provided in the third argument must be sent in the `TR4q` `ProtocolMessage.params` field. + +Tests that optional params are forwarded in the ProtocolMessage. + +### Setup +```pseudo +channel_name = "test-RTL32e-${random_id()}" +captured_messages = [] + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + captured_messages.append(msg) + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + ELSE IF msg.action == MESSAGE: + mock_ws.active_connection.send_to_client(ACK( + msgSerial: msg.msgSerial, + count: 1, + res: { "serials": ["version-serial-1"] } + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +AWAIT channel.updateMessage( + Message(serial: "msg-serial-1", data: "v2"), + params: { "key1": "value1", "key2": "value2" } +) +``` + +### Assertions +```pseudo +message_pm = null +FOR pm IN captured_messages: + IF pm.action == MESSAGE: + message_pm = pm +ASSERT message_pm IS NOT null + +ASSERT message_pm.params["key1"] == "value1" +ASSERT message_pm.params["key2"] == "value2" +``` + +--- + +## RTL32a — serial validation + +**Spec requirement:** RTL32a — Takes a first argument of a `Message` object (which must contain a populated `serial` field). + +Tests that calling updateMessage/deleteMessage/appendMessage with a missing serial throws an error. Follows the same validation as RSL15a. + +### Setup +```pseudo +channel_name = "test-RTL32a-${random_id()}" + +mock_ws = MockWebSocketClient( + onConnectionAttempt: (conn) => { + conn.respond_with_success(CONNECTED()) + }, + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ATTACHED( + channel: channel_name, + flags: PUBLISH + )) + } +) + +client = Realtime( + options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false), + webSocketClient: mock_ws +) +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +client.connect() +AWAIT_STATE connection == CONNECTED +AWAIT channel.attach() + +# Empty serial +AWAIT channel.updateMessage( + Message(serial: "", data: "v2") +) FAILS WITH error +ASSERT error.code == 40003 + +# Null serial (if applicable in language) +AWAIT channel.deleteMessage( + Message(data: "v2") +) FAILS WITH error +ASSERT error.code == 40003 +``` diff --git a/uts/rest/integration/mutable_messages.md b/uts/rest/integration/mutable_messages.md new file mode 100644 index 000000000..81f562415 --- /dev/null +++ b/uts/rest/integration/mutable_messages.md @@ -0,0 +1,399 @@ +# REST Mutable Messages Integration Tests + +Spec points: `RSL1n`, `RSL11`, `RSL14`, `RSL15`, `RSAN1`, `RSAN2`, `RSAN3` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +### Notes +- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) +- All clients use `endpoint: "sandbox"` +- All channel names use the `mutable:` namespace prefix — the test app setup configures the `mutable` namespace with `mutableMessages: true`, which is required for getMessage, updateMessage, deleteMessage, appendMessage, and annotations + +--- + +## RSL1n — publish returns serials from sandbox + +**Spec requirement:** RSL1n — On success, returns a `PublishResult` containing message serials. + +Tests that publish returns real serials from the Ably sandbox. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSL1n-serials-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps and Assertions +```pseudo +# Single message +result1 = AWAIT channel.publish(name: "event1", data: "data1") +ASSERT result1 IS PublishResult +ASSERT result1.serials IS List +ASSERT result1.serials.length == 1 +ASSERT result1.serials[0] IS String +ASSERT result1.serials[0].length > 0 + +# Multiple messages +result2 = AWAIT channel.publish(messages: [ + Message(name: "event2", data: "data2"), + Message(name: "event3", data: "data3"), + Message(name: "event4", data: "data4") +]) +ASSERT result2.serials.length == 3 +ASSERT ALL serial IN result2.serials: serial IS String AND serial.length > 0 + +# Serials should be unique +ASSERT result2.serials[0] != result2.serials[1] +ASSERT result2.serials[1] != result2.serials[2] +``` + +--- + +## RSL11 — getMessage retrieves published message + +**Spec requirement:** RSL11 — `getMessage()` retrieves a message by serial. + +Tests that a published message can be retrieved by its serial. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSL11-getMessage-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish a message and get its serial +publish_result = AWAIT channel.publish(name: "test-event", data: "hello world") +serial = publish_result.serials[0] + +# Retrieve the message by serial +msg = AWAIT channel.getMessage(serial) +``` + +### Assertions +```pseudo +ASSERT msg IS Message +ASSERT msg.name == "test-event" +ASSERT msg.data == "hello world" +ASSERT msg.serial == serial +ASSERT msg.action == MessageAction.MESSAGE_CREATE +ASSERT msg.timestamp IS NOT null +``` + +--- + +## RSL15 — updateMessage updates a published message + +**Spec requirement:** RSL15 — `updateMessage()` sends a PATCH that updates a message. + +Tests that a published message can be updated and the update is visible via `getMessage()`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSL15-update-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original message +publish_result = AWAIT channel.publish(name: "original", data: "original-data") +serial = publish_result.serials[0] + +# Update the message +update_result = AWAIT channel.updateMessage( + Message(serial: serial, name: "updated", data: "updated-data"), + operation: MessageOperation(description: "edited content") +) +``` + +### Assertions +```pseudo +# Update returns a version serial +ASSERT update_result IS UpdateDeleteResult +ASSERT update_result.versionSerial IS String +ASSERT update_result.versionSerial.length > 0 + +# Verify via getMessage +updated_msg = AWAIT channel.getMessage(serial) +ASSERT updated_msg.name == "updated" +ASSERT updated_msg.data == "updated-data" +ASSERT updated_msg.action == MessageAction.MESSAGE_UPDATE +ASSERT updated_msg.version.description == "edited content" +``` + +--- + +## RSL15 — deleteMessage deletes a published message + +**Spec requirement:** RSL15 — `deleteMessage()` sends a PATCH that marks a message as deleted. + +Tests that a published message can be deleted. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSL15-delete-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original message +publish_result = AWAIT channel.publish(name: "to-delete", data: "delete-me") +serial = publish_result.serials[0] + +# Delete the message +delete_result = AWAIT channel.deleteMessage( + Message(serial: serial) +) +``` + +### Assertions +```pseudo +ASSERT delete_result IS UpdateDeleteResult +ASSERT delete_result.versionSerial IS String +ASSERT delete_result.versionSerial.length > 0 + +# Verify via getMessage — action should be MESSAGE_DELETE +deleted_msg = AWAIT channel.getMessage(serial) +ASSERT deleted_msg.action == MessageAction.MESSAGE_DELETE +``` + +--- + +## RSL14 — getMessageVersions returns version history + +**Spec requirement:** RSL14 — `getMessageVersions()` retrieves all versions of a message. + +Tests that version history contains the original and all updates. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSL14-versions-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original +publish_result = AWAIT channel.publish(name: "versioned", data: "v1") +serial = publish_result.serials[0] + +# Update twice +AWAIT channel.updateMessage( + Message(serial: serial, data: "v2"), + operation: MessageOperation(description: "first edit") +) +AWAIT channel.updateMessage( + Message(serial: serial, data: "v3"), + operation: MessageOperation(description: "second edit") +) + +# Get version history +versions = AWAIT channel.getMessageVersions(serial) +``` + +### Assertions +```pseudo +ASSERT versions IS PaginatedResult +ASSERT versions.items.length >= 3 # Original + 2 updates + +# All items should be Messages with the same serial +FOR item IN versions.items: + ASSERT item IS Message + ASSERT item.serial == serial +``` + +--- + +## RSL15 — appendMessage appends to a published message + +**Spec requirement:** RSL15 — `appendMessage()` sends a PATCH with `MESSAGE_APPEND` action. + +Tests that a message can be appended to. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSL15-append-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish original +publish_result = AWAIT channel.publish(name: "appendable", data: "original") +serial = publish_result.serials[0] + +# Append to the message +append_result = AWAIT channel.appendMessage( + Message(serial: serial, data: "appended-data"), + operation: MessageOperation(description: "appended content") +) +``` + +### Assertions +```pseudo +ASSERT append_result IS UpdateDeleteResult +ASSERT append_result.versionSerial IS String +ASSERT append_result.versionSerial.length > 0 +``` + +--- + +## RSAN1, RSAN2 — publish and delete annotations on a message + +| Spec | Requirement | +|------|-------------| +| RSAN1 | `RestAnnotations#publish` creates an annotation on a message | +| RSAN2 | `RestAnnotations#delete` deletes an annotation from a message | +| RSAN3 | `RestAnnotations#get` retrieves annotations for a message | + +Tests the full annotation lifecycle: create, verify, delete. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSAN-lifecycle-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish a message to annotate +publish_result = AWAIT channel.publish(name: "annotatable", data: "content") +serial = publish_result.serials[0] + +# Create an annotation +AWAIT channel.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) + +# Verify annotation exists +annotations = AWAIT channel.annotations.get(serial) +ASSERT annotations.items.length >= 1 + +found = false +FOR ann IN annotations.items: + IF ann.type == "com.ably.reactions" AND ann.name == "like": + found = true + ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE + ASSERT ann.messageSerial == serial +ASSERT found == true + +# Delete the annotation +AWAIT channel.annotations.delete(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) +``` + +--- + +## RSAN3 — get annotations returns PaginatedResult + +**Spec requirement:** RSAN3c — Returns a `PaginatedResult` containing decoded annotations. + +Tests that multiple annotations can be retrieved as a paginated result. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +channel_name = "mutable:test-RSAN3-paginated-" + random_id() +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +# Publish a message +publish_result = AWAIT channel.publish(name: "multi-annotated", data: "content") +serial = publish_result.serials[0] + +# Publish multiple annotations +AWAIT channel.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "like" +)) +AWAIT channel.annotations.publish(serial, Annotation( + type: "com.ably.reactions", + name: "heart" +)) + +# Retrieve annotations +result = AWAIT channel.annotations.get(serial) +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length >= 2 + +FOR ann IN result.items: + ASSERT ann IS Annotation + ASSERT ann.messageSerial == serial + ASSERT ann.type == "com.ably.reactions" + ASSERT ann.timestamp IS NOT null +``` From 66b1b7298b0f30cb03c7d0081809562b0058bd41 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 08:57:41 +0100 Subject: [PATCH 28/32] Add test specs for push admin (RSH1/RSH7) Add specs covering push notification administration including device registration management and push channel subscription management. --- uts/completion-status.md | 12 +- uts/realtime/unit/client/realtime_client.md | 28 +- uts/rest/integration/push_admin.md | 684 ++++++++++++++++++++ 3 files changed, 717 insertions(+), 7 deletions(-) create mode 100644 uts/rest/integration/push_admin.md diff --git a/uts/completion-status.md b/uts/completion-status.md index 58163e0e2..9909eb07b 100644 --- a/uts/completion-status.md +++ b/uts/completion-status.md @@ -48,7 +48,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RSC18 | TLS configuration | Yes — `rest/unit/rest_client.md`, `rest/unit/time.md` | | RSC19 | Request function (RSC19a–RSC19f1) | Yes — `rest/unit/request.md` | | RSC20 | Deprecated exception reporting (RSC20a–RSC20f) |N/A | -| RSC21 | Push object attribute | | +| RSC21 | Push object attribute | Yes — `rest/unit/push/push_admin_publish.md` (RSH1 type assertions) | | RSC22 | BatchPublish (RSC22a–RSC22d) | Yes — `rest/unit/batch_publish.md` | | RSC23 | Deleted | N/A | | RSC24 | BatchPresence | Yes — `rest/unit/batch_presence.md`, `rest/integration/batch_presence.md` | @@ -157,7 +157,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | RTC9 | Request function | Yes — `realtime/unit/client/realtime_request.md` (proxies to RSC19 tests) | | RTC10–RTC11 | Deleted | N/A | | RTC12 | Same constructors as RestClient | Yes — `realtime/unit/client/realtime_client.md` | -| RTC13 | Push object attribute | | +| RTC13 | Push object attribute | Yes — `realtime/unit/client/realtime_client.md` | | RTC14 | CreateWrapperSDKProxy (RTC14a–RTC14c) | | | RTC15 | Connect function (RTC15a) | Yes — `realtime/unit/client/realtime_client.md` | | RTC16 | Close function (RTC16a) | Yes — `realtime/unit/client/realtime_client.md` | @@ -297,7 +297,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Spec item | Description | UTS test spec | |-----------|-------------|---------------| -| RSH1 | Push#admin object (RSH1a–RSH1c5) | | +| RSH1 | Push#admin object (RSH1a–RSH1c5) | Yes — `rest/unit/push/push_admin_publish.md` (RSH1, RSH1a), `rest/unit/push/push_device_registrations.md` (RSH1b1–RSH1b5), `rest/unit/push/push_channel_subscriptions.md` (RSH1c1–RSH1c5), `rest/integration/push_admin.md` (RSH1a–RSH1c5) | | RSH2 | Platform-specific push operations (RSH2a–RSH2e) | | | RSH3 | Activation state machine (RSH3a–RSH3g3) | | | RSH4–RSH5 | Event queueing and sequential handling | | @@ -391,14 +391,14 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | Area | Spec groups | With UTS spec | Coverage | |------|-------------|---------------|----------| | **Endpoint config** (REC) | 3 | 3 | Full | -| **REST client** (RSC) | 18 | 15 | Partial | +| **REST client** (RSC) | 18 | 16 | Partial | | **REST auth** (RSA) | 15 | 15 | Full | | **REST channels** (RSN) | 4 | 0 | None | | **REST channel** (RSL) | 13 | 13 | Full | | **REST presence** (RSP) | 5 | 4 | Mostly | | **REST encryption** (RSE) | 2 | 0 | None | | **REST annotations** (RSAN) | 3 | 3 | Full | -| **Realtime client** (RTC) | 14 | 13 | Partial | +| **Realtime client** (RTC) | 14 | 14 | Full | | **Connection** (RTN) | 23 | 18 | Partial | | **Realtime channels** (RTS) | 5 | 5 | Full | | **Realtime channel** (RTL) | 28 | 26 | Partial | @@ -407,7 +407,7 @@ This matrix lists all spec items from the [Ably features spec](../../specificati | **EventEmitter** (RTE) | 6 | 0 | None | | **Backoff/jitter** (RTB) | 1 | 0 | None | | **Wrapper SDK** (WP) | 7 | 0 | None | -| **Push notifications** (RSH) | 8 | 0 | None | +| **Push notifications** (RSH) | 8 | 1 | Partial | | **Plugins** (PC/PT/VD) | 3 | 2 | Partial | | **Data types** | 30 | 12 | Partial | | **Option types** | 8 | 5 | Partial | diff --git a/uts/realtime/unit/client/realtime_client.md b/uts/realtime/unit/client/realtime_client.md index 8843a9dc8..daaf83dec 100644 --- a/uts/realtime/unit/client/realtime_client.md +++ b/uts/realtime/unit/client/realtime_client.md @@ -1,6 +1,6 @@ # Realtime Client Tests -Spec points: `RTC1`, `RTC1a`, `RTC1b`, `RTC1c`, `RTC1f`, `RTC2`, `RTC3`, `RTC4`, `RTC12`, `RTC15`, `RTC16`, `RTC17` +Spec points: `RTC1`, `RTC1a`, `RTC1b`, `RTC1c`, `RTC1f`, `RTC2`, `RTC3`, `RTC4`, `RTC12`, `RTC13`, `RTC15`, `RTC16`, `RTC17` ## Test Type Unit test with mocked WebSocket connection @@ -158,6 +158,32 @@ ASSERT client.auth IS Auth --- +## RTC13 - Push Attribute + +**Spec requirement:** RTC13 — `RealtimeClient#push` attribute provides access to the `Push` object. + +Tests that `RealtimeClient#push` provides access to the Push object. + +### Setup +```pseudo +mock_ws = create_mock_websocket() +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) +``` + +### Assertions +```pseudo +ASSERT client.push IS NOT null +ASSERT client.push IS Push +ASSERT client.push.admin IS PushAdmin +``` + +--- + ## RTC17 - ClientId Attribute **Spec requirement:** The Realtime client must expose a `clientId` property that returns the clientId from the auth object. diff --git a/uts/rest/integration/push_admin.md b/uts/rest/integration/push_admin.md new file mode 100644 index 000000000..de5cf80aa --- /dev/null +++ b/uts/rest/integration/push_admin.md @@ -0,0 +1,684 @@ +# Push Admin Integration Tests + +Spec points: `RSH1`, `RSH1a`, `RSH1b1`, `RSH1b2`, `RSH1b3`, `RSH1b4`, `RSH1b5`, `RSH1c1`, `RSH1c2`, `RSH1c3`, `RSH1c4`, `RSH1c5` + +## Test Type +Integration test against Ably sandbox + +## Sandbox Setup + +Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. + +### App Provisioning + +Uses `ably-common/test-resources/test-app-setup.json` which provides: +- `keys[0]` — full access (default capability `{"*":["*"]}`) +- `keys[1]` — includes `pushenabled:admin:*` with `push-admin` capability + +```pseudo +BEFORE ALL TESTS: + response = POST https://sandbox-rest.ably.io/apps + WITH body from ably-common/test-resources/test-app-setup.json + + app_config = parse_json(response.body) + full_access_key = app_config.keys[0].key_str + push_admin_key = app_config.keys[1].key_str + app_id = app_config.app_id + +AFTER ALL TESTS: + DELETE https://sandbox-rest.ably.io/apps/{app_id} + WITH Authorization: Basic {full_access_key} +``` + +### Notes +- All clients use `useBinaryProtocol: false` (SDK does not implement msgpack) +- All clients use `endpoint: "sandbox"` +- Push admin operations require the `push-admin` capability — use `push_admin_key` or `full_access_key` +- Device registrations created during tests must be cleaned up to avoid polluting the sandbox + +--- + +## RSH1a — publish sends push notification to clientId + +**Spec requirement:** RSH1a — `publish(recipient, data)` performs an HTTP request to `/push/publish`. + +Tests that a push notification can be published to a `clientId` recipient. The sandbox accepts the request even though no real device receives it. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +# Publish with clientId recipient — should not throw +AWAIT client.push.admin.publish( + recipient: { "clientId": "test-client-push" }, + data: { + "notification": { + "title": "Integration Test", + "body": "Hello from push admin" + } + } +) +``` + +--- + +## RSH1a — publish rejects invalid recipient + +**Spec requirement:** RSH1a — Tests should exist with invalid recipient details. + +Tests that the sandbox returns an error for an empty recipient. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.publish( + recipient: {}, + data: { "notification": { "title": "Test" } } +) FAILS WITH error +ASSERT error.code IS NOT null +``` + +--- + +## RSH1b3, RSH1b1 — save and get device registration + +| Spec | Requirement | +|------|-------------| +| RSH1b3 | `#save(device)` issues a PUT to register a device | +| RSH1b1 | `#get(deviceId)` retrieves a registered device | + +Tests the full device registration lifecycle: save, then retrieve. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-" + random_id() +``` + +### Test Steps +```pseudo +# Save a device registration +saved = AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "test-token-" + random_id() } + ) +)) +``` + +### Assertions +```pseudo +ASSERT saved IS DeviceDetails +ASSERT saved.id == device_id +ASSERT saved.platform == "ios" +ASSERT saved.formFactor == "phone" +ASSERT saved.push.recipient["transportType"] == "apns" + +# Retrieve the same device +retrieved = AWAIT client.push.admin.deviceRegistrations.get(device_id) +ASSERT retrieved IS DeviceDetails +ASSERT retrieved.id == device_id +ASSERT retrieved.platform == "ios" +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b3 — save updates existing device registration + +**Spec requirement:** RSH1b3 — A test should exist for a successful subsequent save with an update. + +Tests that saving a device with the same ID updates the existing registration. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-update-" + random_id() +``` + +### Test Steps +```pseudo +# Initial save +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-v1" } + ) +)) + +# Update with new token +updated = AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-v2" } + ) +)) +``` + +### Assertions +```pseudo +ASSERT updated.id == device_id +ASSERT updated.push.recipient["deviceToken"] == "token-v2" + +# Verify via get +retrieved = AWAIT client.push.admin.deviceRegistrations.get(device_id) +ASSERT retrieved.push.recipient["deviceToken"] == "token-v2" +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b1 — get returns error for unknown device + +**Spec requirement:** RSH1b1 — Results in a not found error if the device cannot be found. + +Tests that retrieving a nonexistent device returns a not-found error. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +AWAIT client.push.admin.deviceRegistrations.get("nonexistent-device-" + random_id()) FAILS WITH error +ASSERT error.statusCode == 404 +``` + +--- + +## RSH1b2 — list device registrations with filters + +**Spec requirement:** RSH1b2 — `#list(params)` returns a paginated result with `DeviceDetails` filtered by params. + +Tests listing device registrations filtered by `deviceId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-list-" + random_id() + +# Register a device first +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "android", + formFactor: "tablet", + push: DevicePushDetails( + recipient: { "transportType": "gcm", "registrationToken": "test-token" } + ) +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({"deviceId": device_id}) +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +ASSERT result.items.length == 1 +ASSERT result.items[0].id == device_id +ASSERT result.items[0].platform == "android" +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b2 — list supports pagination with limit + +**Spec requirement:** RSH1b2 — A test should exist controlling the pagination with the `limit` attribute. + +Tests that the `limit` parameter restricts the number of results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-list-" + random_id() +device_ids = [] + +# Register multiple devices with the same clientId +FOR i IN [1, 2, 3]: + device_id = "test-device-limit-" + i + "-" + random_id() + device_ids.append(device_id) + AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + clientId: client_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-" + i } + ) + )) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.deviceRegistrations.list({ + "clientId": client_id, + "limit": "2" +}) +``` + +### Assertions +```pseudo +ASSERT result.items.length <= 2 +ASSERT result.hasNext == true +``` + +### Cleanup +```pseudo +FOR device_id IN device_ids: + AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1b4 — remove deletes device registration + +**Spec requirement:** RSH1b4 — `#remove(deviceId)` deletes the registered device. + +Tests that a registered device can be removed and is no longer retrievable. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-remove-" + random_id() + +# Register a device +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "test-token" } + ) +)) +``` + +### Test Steps +```pseudo +# Remove the device +AWAIT client.push.admin.deviceRegistrations.remove(device_id) + +# Verify it's gone +AWAIT client.push.admin.deviceRegistrations.get(device_id) FAILS WITH error +ASSERT error.statusCode == 404 +``` + +--- + +## RSH1b4 — remove succeeds for nonexistent device + +**Spec requirement:** RSH1b4 — Deleting a device that does not exist still succeeds. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw +AWAIT client.push.admin.deviceRegistrations.remove("nonexistent-device-" + random_id()) +``` + +--- + +## RSH1b5 — removeWhere deletes devices by clientId + +**Spec requirement:** RSH1b5 — `#removeWhere(params)` deletes registered devices matching params. + +Tests that devices can be bulk-removed by `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-removeWhere-" + random_id() +device_ids = [] + +# Register two devices with the same clientId +FOR i IN [1, 2]: + device_id = "test-device-rw-" + i + "-" + random_id() + device_ids.append(device_id) + AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + clientId: client_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "token-" + i } + ) + )) +``` + +### Test Steps +```pseudo +# Remove all devices for this clientId +AWAIT client.push.admin.deviceRegistrations.removeWhere({"clientId": client_id}) + +# Verify both are gone +result = AWAIT client.push.admin.deviceRegistrations.list({"clientId": client_id}) +ASSERT result.items.length == 0 +``` + +--- + +## RSH1c3, RSH1c1 — save and list channel subscriptions + +| Spec | Requirement | +|------|-------------| +| RSH1c3 | `#save(subscription)` creates a channel subscription | +| RSH1c1 | `#list(params)` returns paginated subscriptions | + +Tests the channel subscription lifecycle: save then list. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +device_id = "test-device-sub-" + random_id() +channel_name = "pushenabled:test-sub-" + random_id() + +# Register a device first (required for deviceId subscriptions) +AWAIT client.push.admin.deviceRegistrations.save(DeviceDetails( + id: device_id, + platform: "ios", + formFactor: "phone", + push: DevicePushDetails( + recipient: { "transportType": "apns", "deviceToken": "test-token" } + ) +)) +``` + +### Test Steps +```pseudo +# Save a channel subscription +saved = AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + deviceId: device_id +)) +``` + +### Assertions +```pseudo +ASSERT saved IS PushChannelSubscription +ASSERT saved.channel == channel_name +ASSERT saved.deviceId == device_id + +# List subscriptions for this channel +result = AWAIT client.push.admin.channelSubscriptions.list({"channel": channel_name}) +ASSERT result IS PaginatedResult +ASSERT result.items.length >= 1 + +found = false +FOR sub IN result.items: + IF sub.deviceId == device_id: + found = true + ASSERT sub.channel == channel_name +ASSERT found == true +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + deviceId: device_id +)) +AWAIT client.push.admin.deviceRegistrations.remove(device_id) +``` + +--- + +## RSH1c3 — save channel subscription with clientId + +**Spec requirement:** RSH1c3 — A test should exist for saving a `clientId`-based subscription. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-sub-" + random_id() +channel_name = "pushenabled:test-clientsub-" + random_id() +``` + +### Test Steps +```pseudo +saved = AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +### Assertions +```pseudo +ASSERT saved.channel == channel_name +ASSERT saved.clientId == client_id +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +--- + +## RSH1c2 — listChannels returns channel names with subscriptions + +**Spec requirement:** RSH1c2 — `#listChannels(params)` returns a paginated result with `String` objects. + +Tests that channels with active subscriptions appear in listChannels. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-lc-" + random_id() +channel_name = "pushenabled:test-listchannels-" + random_id() + +# Create a subscription to ensure the channel appears +AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +### Test Steps +```pseudo +result = AWAIT client.push.admin.channelSubscriptions.listChannels({}) +``` + +### Assertions +```pseudo +ASSERT result IS PaginatedResult +# The channel we subscribed to should appear in the list +ASSERT channel_name IN result.items +``` + +### Cleanup +```pseudo +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +--- + +## RSH1c4 — remove deletes channel subscription + +**Spec requirement:** RSH1c4 — `#remove(subscription)` deletes a channel subscription using subscription attributes as params. + +Tests that a subscription can be removed and no longer appears in list results. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-rm-" + random_id() +channel_name = "pushenabled:test-remove-" + random_id() + +# Create a subscription +AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) +``` + +### Test Steps +```pseudo +# Remove the subscription +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: channel_name, + clientId: client_id +)) + +# Verify it's gone +result = AWAIT client.push.admin.channelSubscriptions.list({ + "channel": channel_name, + "clientId": client_id +}) +ASSERT result.items.length == 0 +``` + +--- + +## RSH1c4 — remove succeeds for nonexistent subscription + +**Spec requirement:** RSH1c4 — Deleting a subscription that does not exist still succeeds. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +``` + +### Test Steps and Assertions +```pseudo +# Should not throw +AWAIT client.push.admin.channelSubscriptions.remove(PushChannelSubscription( + channel: "pushenabled:nonexistent-" + random_id(), + clientId: "nonexistent-client" +)) +``` + +--- + +## RSH1c5 — removeWhere deletes subscriptions by clientId + +**Spec requirement:** RSH1c5 — `#removeWhere(params)` deletes matching channel subscriptions. + +Tests that subscriptions can be bulk-removed by `clientId`. + +### Setup +```pseudo +client = Rest(options: ClientOptions( + key: full_access_key, + endpoint: "sandbox", + useBinaryProtocol: false +)) +client_id = "test-client-rwsub-" + random_id() +channel_names = [] + +# Create subscriptions on two channels for the same clientId +FOR i IN [1, 2]: + ch = "pushenabled:test-rwsub-" + i + "-" + random_id() + channel_names.append(ch) + AWAIT client.push.admin.channelSubscriptions.save(PushChannelSubscription( + channel: ch, + clientId: client_id + )) +``` + +### Test Steps +```pseudo +# Remove all subscriptions for this clientId +AWAIT client.push.admin.channelSubscriptions.removeWhere({"clientId": client_id}) + +# Verify they're all gone +result = AWAIT client.push.admin.channelSubscriptions.list({"clientId": client_id}) +ASSERT result.items.length == 0 +``` From 6420ec4d156caa974351e7a10ae6c469aea3c642 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 09:01:13 +0100 Subject: [PATCH 29/32] Fix presence test specs: server echoes, wildcard clientId, RTL13b, RTP2h2b Fix several presence test spec issues: correct server echo expectations, handle wildcard clientId constraints, and fix RTL13b (presence SYNC) and RTP2h2b (presence re-entry) test logic. --- uts/realtime/unit/presence/presence_sync.md | 12 +- .../realtime_presence_channel_state.md | 39 +++---- .../unit/presence/realtime_presence_enter.md | 14 ++- .../presence/realtime_presence_reentry.md | 110 +++++++++++++++++- 4 files changed, 142 insertions(+), 33 deletions(-) diff --git a/uts/realtime/unit/presence/presence_sync.md b/uts/realtime/unit/presence/presence_sync.md index 92bf51b48..cc04d0ee1 100644 --- a/uts/realtime/unit/presence/presence_sync.md +++ b/uts/realtime/unit/presence/presence_sync.md @@ -392,11 +392,13 @@ leave_events = map.endSync() ### Assertions ```pseudo -# Bob's ABSENT entry is cleaned up — no additional LEAVE emitted since -# bob was explicitly marked ABSENT (not stale-by-absence-from-sync) -# Implementation note: ABSENT members are simply deleted on endSync. -# The stale-member LEAVE events are only for members that were PRESENT -# but not updated during sync. +# Bob's ABSENT entry is cleaned up on endSync (RTP2h2b) — no synthesized +# LEAVE event is emitted for bob because he was explicitly marked ABSENT +# via a LEAVE message (not stale-by-absence-from-sync). ABSENT members +# are simply deleted on endSync without generating LEAVE events. +# Synthesized LEAVE events (RTP19) are only for PRESENT members that +# were not updated during sync (residuals). +ASSERT leave_events.length == 0 ASSERT map.get("c2:bob") IS null # Alice survives diff --git a/uts/realtime/unit/presence/realtime_presence_channel_state.md b/uts/realtime/unit/presence/realtime_presence_channel_state.md index 21d43c7bd..e70de12b2 100644 --- a/uts/realtime/unit/presence/realtime_presence_channel_state.md +++ b/uts/realtime/unit/presence/realtime_presence_channel_state.md @@ -567,6 +567,13 @@ state, then all presence actions that are still queued for send on that channel RTP16b should be deleted from the queue, and any callback passed to the corresponding presence method invocation should be called with an ErrorInfo indicating the failure. +**Note on ATTACHING → DETACHED transition:** Per RTL13b, when a channel is in the +ATTACHING state and receives a DETACHED ProtocolMessage from the server, the SDK +should retry the attach. If the retry fails or the connection is not in a state +that permits re-attach, the channel may transition to SUSPENDED rather than DETACHED. +The test below puts the channel into DETACHED state via an explicit `detach()` call +after a successful attach, which avoids the RTL13b retry path. + ### Setup ```pseudo channel_name = "test-RTL11-detached-${random_id()}" @@ -576,7 +583,9 @@ mock_ws = MockWebSocket( onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), onMessageFromClient: (msg) => { IF msg.action == ATTACH: - # Do NOT respond — leave channel in ATTACHING so presence queues + mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) + ELSE IF msg.action == DETACH: + mock_ws.send_to_client(ProtocolMessage(action: DETACHED, channel: channel_name)) ELSE IF msg.action == PRESENCE: captured_presence.append(msg) } @@ -592,33 +601,21 @@ channel = client.channels.get(channel_name) client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Start attach — channel goes to ATTACHING -channel.attach() -AWAIT_STATE channel.state == ChannelState.attaching - -# Queue presence while channel is ATTACHING (per RTP16b) -enter_future = channel.presence.enter(data: "queued-enter") - -# Verify nothing sent yet -ASSERT captured_presence.length == 0 - -# Server sends DETACHED — channel transitions to DETACHED -mock_ws.send_to_client(ProtocolMessage( - action: DETACHED, - channel: channel_name, - error: ErrorInfo(code: 90001, message: "Channel detached") -)) +# Attach then detach to put channel in DETACHED state +AWAIT channel.attach() +AWAIT channel.detach() +ASSERT channel.state == ChannelState.detached -AWAIT_STATE channel.state == ChannelState.detached +# Attempting presence on a DETACHED channel should error immediately +AWAIT channel.presence.enter(data: "queued-enter") FAILS WITH error ``` ### Assertions ```pseudo -# Queued presence was NOT sent +# No presence messages were sent ASSERT captured_presence.length == 0 -# The enter future completed with an error -AWAIT enter_future FAILS WITH error +# The enter completed with an error ASSERT error IS ErrorInfo ASSERT error.code IS NOT null ``` diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md index fdfe08ddd..34f822d57 100644 --- a/uts/realtime/unit/presence/realtime_presence_enter.md +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -12,6 +12,13 @@ and `leaveClient` functions. These methods send PRESENCE ProtocolMessages to the and handle ACK/NACK responses. Tests cover protocol message format, implicit channel attach, connection state conditions, and error cases. +**Note on wildcard clientId:** Several tests use `clientId: "*"` (wildcard) which is +the Ably convention for clients permitted to act on behalf of any clientId via +`enterClient`/`updateClient`/`leaveClient`. Some SDKs may reject `"*"` at the +`ClientOptions` construction level. In such cases, adapt these tests to use a +concrete clientId (e.g., `"admin"`) and skip the client-side `enterClient` clientId +mismatch check (RTP15f), or configure the mock to accept any clientId. + --- ## RTP8a, RTP8c - enter sends PRESENCE with ENTER action @@ -250,6 +257,10 @@ ASSERT error IS NOT null ## RTP8j - enter with wildcard clientId errors ### Setup + +Note: Some SDKs may reject wildcard clientId `"*"` at the `ClientOptions` +construction level rather than at `enter()` time. In that case, this test +validates that the error occurs at `ClientOptions` creation instead. ```pseudo channel_name = "test-RTP8j-wild-${random_id()}" @@ -826,7 +837,8 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Wildcard client to allow both enter() and enterClient() +# Wildcard clientId to allow both enter() and enterClient() on the same connection. +# See note in Purpose section about SDK-level wildcard validation. client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_reentry.md b/uts/realtime/unit/presence/realtime_presence_reentry.md index 02a3949f2..229d00782 100644 --- a/uts/realtime/unit/presence/realtime_presence_reentry.md +++ b/uts/realtime/unit/presence/realtime_presence_reentry.md @@ -12,6 +12,12 @@ RealtimePresence object maintains an internal PresenceMap (RTP17) of locally-ent members. When the channel receives an ATTACHED ProtocolMessage (except when already attached with RESUMED flag), it re-publishes an ENTER for each member in the internal map. +**Important:** The internal PresenceMap (LocalPresenceMap) is populated from server +PRESENCE echoes — messages with the current connection's connectionId — NOT directly +from the client's `enter()` or `enterClient()` calls. The server always echoes presence +events back to the originating client. Mock WebSocket setups must simulate this echo +for the LocalPresenceMap to contain any members for re-entry. + --- ## RTP17i - Automatic re-entry on ATTACHED (non-RESUMED) @@ -40,6 +46,25 @@ mock_ws = MockWebSocket( ELSE IF msg.action == PRESENCE: captured_presence.append(msg) mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to the client. + # This populates the LocalPresenceMap (RTP17) which is keyed by + # server echoes, not by the client's own enter() calls. + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-${connection_count}", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-${connection_count}", + id: "conn-${connection_count}:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) } ) install_mock(mock_ws) @@ -108,12 +133,31 @@ mock_ws = MockWebSocket( ELSE IF msg.action == PRESENCE: captured_presence.append(msg) mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to populate LocalPresenceMap + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-${connection_count}", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId, + connectionId: "conn-${connection_count}", + id: "conn-${connection_count}:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) } ) install_mock(mock_ws) -# Wildcard client to test enterClient with multiple members -client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) +# Use a non-wildcard clientId that has enterClient permission. +# Note: Some SDKs reject wildcard clientId "*" at the ClientOptions level. +# Use a concrete clientId and rely on server-side permission for enterClient. +client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "admin", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -123,7 +167,7 @@ client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected AWAIT channel.attach() -# Enter multiple members +# Enter multiple members via enterClient AWAIT channel.presence.enterClient("alice", data: "alice-data") AWAIT channel.presence.enterClient("bob", data: "bob-data") @@ -188,6 +232,23 @@ mock_ws = MockWebSocket( ELSE IF msg.action == PRESENCE: captured_presence.append(msg) mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to populate LocalPresenceMap + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-${connection_count}", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-${connection_count}", + id: "conn-${connection_count}:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) } ) install_mock(mock_ws) @@ -252,6 +313,23 @@ mock_ws = MockWebSocket( ELSE IF msg.action == PRESENCE: captured_presence.append(msg) mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + # Server echoes the presence event back to populate LocalPresenceMap + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-1", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-1", + id: "conn-1:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) } ) install_mock(mock_ws) @@ -312,8 +390,24 @@ mock_ws = MockWebSocket( mock_ws.send_to_client(ProtocolMessage(action: ATTACHED, channel: channel_name)) ELSE IF msg.action == PRESENCE: IF connection_count == 1: - # First connection: ACK the enter + # First connection: ACK the enter and echo back the presence event mock_ws.send_to_client(ProtocolMessage(action: ACK, msgSerial: msg.msgSerial, count: 1)) + FOR idx, p IN enumerate(msg.presence): + mock_ws.send_to_client(ProtocolMessage( + action: PRESENCE, + channel: channel_name, + connectionId: "conn-1", + presence: [ + PresenceMessage( + action: p.action, + clientId: p.clientId OR "my-client", + connectionId: "conn-1", + id: "conn-1:${msg.msgSerial}:${idx}", + timestamp: NOW(), + data: p.data + ) + ] + )) ELSE: # Second connection: NACK the re-entry mock_ws.send_to_client(ProtocolMessage( @@ -338,10 +432,14 @@ AWAIT channel.attach() AWAIT channel.presence.enter(data: "hello") -# Listen for channel UPDATE events +# Listen for channel UPDATE events with the re-entry failure error code. +# Note: The ATTACHED state change itself may also emit an UPDATE event +# (e.g., when transitioning from ATTACHED to ATTACHED with resumed=false). +# Filter for the specific 91004 error code to distinguish re-entry failure. channel_events = [] channel.on(ChannelEvent.update, (change) => { - channel_events.append(change) + IF change.reason IS NOT null AND change.reason.code == 91004: + channel_events.append(change) }) # Disconnect and reconnect — re-entry will be NACKed From 14b7d808501e4e1d52b26bfcf8121008fdb44370 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Mon, 30 Mar 2026 09:01:13 +0100 Subject: [PATCH 30/32] Fix integration test specs based on sandbox behavior Update REST integration test specs (auth, mutable messages, revoke tokens) to align assertions with actual Ably sandbox behaviour. --- uts/rest/integration/auth.md | 21 +++++--- uts/rest/integration/mutable_messages.md | 68 ++++++++++++++++++++---- uts/rest/integration/revoke_tokens.md | 26 ++++++++- 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/uts/rest/integration/auth.md b/uts/rest/integration/auth.md index 08cd0a72e..84d31f8f3 100644 --- a/uts/rest/integration/auth.md +++ b/uts/rest/integration/auth.md @@ -220,8 +220,14 @@ Tests that invalid API keys are rejected by the server. ### Setup ```pseudo channel_name = "test-RSA4-invalid-" + random_id() + +# Use the real app_id with a fabricated key to guarantee a 401 response. +# Using a completely fake app ID (e.g. "invalid.key:secret") may return +# 404 (app not found) instead of 401 (unauthorized), depending on the server. +invalid_key = app_id + ".invalidKey:invalidSecret" + client = Rest(options: ClientOptions( - key: "invalid.key:secret", + key: invalid_key, endpoint: "sandbox" )) ``` @@ -314,18 +320,17 @@ client = Rest(options: ClientOptions( ### Test Steps ```pseudo -# Request to allowed channel should succeed -allowed_result = AWAIT client.request("GET", "/channels/" + allowed_channel) +# Publish to allowed channel should succeed — the JWT grants "publish" capability. +# Note: Do NOT use client.request("GET", "/channels/...") here — that is a channel +# status request which requires "channel-metadata" capability, not "publish". +AWAIT client.channels.get(allowed_channel).publish(name: "test", data: "hello") -# Request to denied channel should fail with 40160 (capability refused) -AWAIT client.request("POST", "/channels/" + denied_channel + "/messages", - body: {"name": "test", "data": "hello"} -) FAILS WITH error +# Publish to denied channel should fail with 40160 (capability refused) +AWAIT client.channels.get(denied_channel).publish(name: "test", data: "hello") FAILS WITH error ``` ### Assertions ```pseudo -ASSERT allowed_result.statusCode >= 200 AND allowed_result.statusCode < 300 ASSERT error.code == 40160 ASSERT error.statusCode == 401 ``` diff --git a/uts/rest/integration/mutable_messages.md b/uts/rest/integration/mutable_messages.md index 81f562415..c5e5f4715 100644 --- a/uts/rest/integration/mutable_messages.md +++ b/uts/rest/integration/mutable_messages.md @@ -33,6 +33,24 @@ AFTER ALL TESTS: - All clients use `endpoint: "sandbox"` - All channel names use the `mutable:` namespace prefix — the test app setup configures the `mutable` namespace with `mutableMessages: true`, which is required for getMessage, updateMessage, deleteMessage, appendMessage, and annotations +### Annotation HTTP Body Format + +The annotation publish and delete endpoints (`POST /channels/{channel}/messages/{serial}/annotations`) +expect the HTTP request body to be a **JSON array** containing a single annotation object: + +```json +[{"type": "com.ably.reactions", "name": "like", "action": 0}] +``` + +Sending a bare object (not wrapped in an array) returns HTTP 400 "invalid request body". + +The `action` field is **required** by the server and must be set by the SDK: +- `0` = `ANNOTATION_CREATE` (for publish) +- `1` = `ANNOTATION_DELETE` (for delete) + +The SDK's `annotations.publish()` and `annotations.delete()` methods must set the +`action` field and wrap the annotation in an array before sending. + --- ## RSL1n — publish returns serials from sandbox @@ -154,8 +172,14 @@ ASSERT update_result IS UpdateDeleteResult ASSERT update_result.versionSerial IS String ASSERT update_result.versionSerial.length > 0 -# Verify via getMessage -updated_msg = AWAIT channel.getMessage(serial) +# Verify via getMessage — poll until the update is visible +updated_msg = poll_until( + condition: FUNCTION() => + msg = AWAIT channel.getMessage(serial) + RETURN msg.action == MessageAction.MESSAGE_UPDATE, + interval: 500ms, + timeout: 10s +) ASSERT updated_msg.name == "updated" ASSERT updated_msg.data == "updated-data" ASSERT updated_msg.action == MessageAction.MESSAGE_UPDATE @@ -199,8 +223,14 @@ ASSERT delete_result IS UpdateDeleteResult ASSERT delete_result.versionSerial IS String ASSERT delete_result.versionSerial.length > 0 -# Verify via getMessage — action should be MESSAGE_DELETE -deleted_msg = AWAIT channel.getMessage(serial) +# Verify via getMessage — poll until the delete is visible +deleted_msg = poll_until( + condition: FUNCTION() => + msg = AWAIT channel.getMessage(serial) + RETURN msg.action == MessageAction.MESSAGE_DELETE, + interval: 500ms, + timeout: 10s +) ASSERT deleted_msg.action == MessageAction.MESSAGE_DELETE ``` @@ -239,8 +269,14 @@ AWAIT channel.updateMessage( operation: MessageOperation(description: "second edit") ) -# Get version history -versions = AWAIT channel.getMessageVersions(serial) +# Poll version history until all versions appear +versions = poll_until( + condition: FUNCTION() => + result = AWAIT channel.getMessageVersions(serial) + RETURN result.items.length >= 3, + interval: 500ms, + timeout: 10s +) ``` ### Assertions @@ -328,8 +364,14 @@ AWAIT channel.annotations.publish(serial, Annotation( name: "like" )) -# Verify annotation exists -annotations = AWAIT channel.annotations.get(serial) +# Verify annotation exists — poll until it appears +annotations = poll_until( + condition: FUNCTION() => + result = AWAIT channel.annotations.get(serial) + RETURN result.items.length >= 1, + interval: 500ms, + timeout: 10s +) ASSERT annotations.items.length >= 1 found = false @@ -382,8 +424,14 @@ AWAIT channel.annotations.publish(serial, Annotation( name: "heart" )) -# Retrieve annotations -result = AWAIT channel.annotations.get(serial) +# Retrieve annotations — poll until both appear +result = poll_until( + condition: FUNCTION() => + r = AWAIT channel.annotations.get(serial) + RETURN r.items.length >= 2, + interval: 500ms, + timeout: 10s +) ``` ### Assertions diff --git a/uts/rest/integration/revoke_tokens.md b/uts/rest/integration/revoke_tokens.md index d9af5bdde..7fa456817 100644 --- a/uts/rest/integration/revoke_tokens.md +++ b/uts/rest/integration/revoke_tokens.md @@ -17,6 +17,26 @@ All tests use JWTs generated using a third-party JWT library, signed with the key secret using HMAC-SHA256. This avoids needing to call `requestToken()` and keeps the tests self-contained. +## Server Response Format + +The Ably server returns token revocation results as a **plain JSON array** of +per-target results: + +```json +[{"target": "clientId:xxx", "appliesAt": 1234567890, "issuedBefore": 1234567890}] +``` + +On failure for a specific target, the element contains an `error` field instead: + +```json +[{"target": "invalidType:abc", "error": {"code": 40000, "statusCode": 400, "message": "..."}}] +``` + +There is no `BatchResult` envelope — the `successCount` and `failureCount` fields +(RSA17c) must be computed **client-side** by counting elements with and without an +`error` field. This is consistent with how batch presence responses work (see +`batch_presence.md`). + ## Sandbox Setup Tests run against the Ably Sandbox at `https://sandbox-rest.ably.io`. @@ -55,7 +75,7 @@ the token must be rejected by the server. |------|-------------| | RSA17g | POST to `/keys/{keyName}/revokeTokens` | | RSA17b | Targets mapped to `type:value` strings | -| RSA17c | Returns `BatchResult` with `successCount`, `failureCount`, `results` | +| RSA17c | Returns per-target results; SDK computes `successCount`, `failureCount` client-side | | TRS2a | Success result contains `target` string | | TRS2b | Success result contains `appliesAt` timestamp | | TRS2c | Success result contains `issuedBefore` timestamp | @@ -100,6 +120,8 @@ revoke_result = AWAIT key_client.auth.revokeTokens([ ]) # Step 3: Verify the revokeTokens response structure (RSA17c, TRS2) +# Note: The server returns a plain array of per-target results. +# successCount/failureCount are computed client-side (see Server Response Format). ASSERT revoke_result.successCount == 1 ASSERT revoke_result.failureCount == 0 ASSERT revoke_result.results.length == 1 @@ -210,6 +232,7 @@ revoke_result = AWAIT key_client.auth.revokeTokens( options: { issuedBefore: server_time, allowReauthMargin: true } ) +# successCount is computed client-side (see Server Response Format) ASSERT revoke_result.successCount == 1 ASSERT revoke_result.results.length == 1 @@ -284,6 +307,7 @@ revoke_result = AWAIT key_client.auth.revokeTokens([ ]) # Step 3: Verify the response contains both success and failure +# successCount/failureCount are computed client-side (see Server Response Format) ASSERT revoke_result.successCount == 1 ASSERT revoke_result.failureCount == 1 ASSERT revoke_result.results.length == 2 From 9eb85c84a6aebf026cb96dac0fb6979ad0ba4319 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Thu, 30 Apr 2026 08:56:04 +0100 Subject: [PATCH 31/32] Add CLOSE_CLIENT(client) to all UTS specs that create Realtime clients Realtime clients hold internal timers (retry, heartbeat, suspend) that keep test processes alive after assertions complete. Every test that creates a Realtime client must close it to release these handles. Adds CLOSE_CLIENT(client) after the final assertion in 50 spec files across realtime unit, integration, rest, and presence test specs. Co-Authored-By: Claude Opus 4.6 --- .../integration/connection_lifecycle_test.md | 4 + .../unit/auth/connection_auth_test.md | 5 + uts/realtime/unit/auth/realtime_authorize.md | 10 ++ .../channels/channel_additional_attached.md | 3 + .../unit/channels/channel_annotations.md | 15 +++ uts/realtime/unit/channels/channel_attach.md | 16 +++ .../unit/channels/channel_attributes.md | 5 + .../unit/channels/channel_connection_state.md | 13 ++ .../unit/channels/channel_delta_decoding.md | 12 ++ uts/realtime/unit/channels/channel_detach.md | 13 ++ uts/realtime/unit/channels/channel_error.md | 5 + uts/realtime/unit/channels/channel_history.md | 2 + uts/realtime/unit/channels/channel_options.md | 10 ++ .../unit/channels/channel_properties.md | 9 ++ uts/realtime/unit/channels/channel_publish.md | 34 +++++ .../channel_server_initiated_detach.md | 7 ++ .../unit/channels/channel_state_events.md | 13 ++ .../unit/channels/channel_subscribe.md | 16 +++ .../channels/channel_update_delete_message.md | 9 ++ .../unit/channels/channel_when_state_test.md | 119 ++++++++---------- .../unit/channels/channels_collection.md | 10 ++ .../unit/channels/message_field_population.md | 8 ++ uts/realtime/unit/client/realtime_client.md | 19 +++ uts/realtime/unit/client/realtime_timeouts.md | 4 + .../unit/connection/auto_connect_test.md | 3 + .../connection/connection_failures_test.md | 11 ++ .../unit/connection/connection_id_key_test.md | 10 ++ .../connection_open_failures_test.md | 26 ++-- .../unit/connection/connection_ping_test.md | 14 +++ .../unit/connection/error_reason_test.md | 7 ++ .../unit/connection/fallback_hosts_test.md | 7 ++ .../unit/connection/heartbeat_test.md | 13 ++ .../server_initiated_reauth_test.md | 3 + .../unit/connection/update_events_test.md | 4 + .../unit/connection/when_state_test.md | 6 + .../realtime_presence_channel_state.md | 26 ++++ .../unit/presence/realtime_presence_enter.md | 41 ++++++ .../unit/presence/realtime_presence_get.md | 16 +++ .../presence/realtime_presence_history.md | 4 + .../presence/realtime_presence_reentry.md | 12 ++ .../presence/realtime_presence_subscribe.md | 20 +++ 41 files changed, 506 insertions(+), 78 deletions(-) diff --git a/uts/realtime/integration/connection_lifecycle_test.md b/uts/realtime/integration/connection_lifecycle_test.md index 42cccf332..11b3b17f8 100644 --- a/uts/realtime/integration/connection_lifecycle_test.md +++ b/uts/realtime/integration/connection_lifecycle_test.md @@ -82,6 +82,8 @@ ASSERT client.connection.key matches "[a-zA-Z0-9_!-]+" # No error reason ASSERT client.connection.errorReason IS null + +CLOSE_CLIENT(client) ``` --- @@ -194,6 +196,8 @@ ASSERT first_connection_id != second_connection_id # No errors ASSERT client.connection.errorReason IS null + +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/auth/connection_auth_test.md b/uts/realtime/unit/auth/connection_auth_test.md index a0468b83a..608bf1119 100644 --- a/uts/realtime/unit/auth/connection_auth_test.md +++ b/uts/realtime/unit/auth/connection_auth_test.md @@ -106,6 +106,7 @@ ASSERT captured_ws_url.queryParameters["key"] IS null # Connection succeeded ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -164,6 +165,7 @@ ASSERT connection_attempted == false ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.statusCode == 401 OR client.connection.errorReason.code == 40170 +CLOSE_CLIENT(client) ``` --- @@ -223,6 +225,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # authCallback received TokenParams with clientId ASSERT received_params IS NOT null ASSERT received_params.clientId == "my-client-id" +CLOSE_CLIENT(client) ``` --- @@ -284,6 +287,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ```pseudo # authCallback was only invoked once (token was reused) ASSERT callback_count == 1 +CLOSE_CLIENT(client) ``` --- @@ -348,6 +352,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ```pseudo # authCallback was invoked twice (once per connection due to expiry) ASSERT callback_count == 2 +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/auth/realtime_authorize.md b/uts/realtime/unit/auth/realtime_authorize.md index c387daa23..f8a2315d9 100644 --- a/uts/realtime/unit/auth/realtime_authorize.md +++ b/uts/realtime/unit/auth/realtime_authorize.md @@ -112,6 +112,7 @@ ASSERT token_details.token == "token-2" # No state changes occurred — connection stayed CONNECTED throughout ASSERT state_changes.length == 0 ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -211,6 +212,7 @@ ASSERT state_changes.length == 0 # Connection details were updated (RTN21) ASSERT client.connection.id == "connection-id-2" ASSERT client.connection.key == "connection-key-2" +CLOSE_CLIENT(client) ``` --- @@ -327,6 +329,7 @@ ASSERT failed_changes[0].reason.statusCode == 401 # Connection remains CONNECTED (channel-level ERROR doesn't close connection) ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -413,6 +416,7 @@ ASSERT client.connection.errorReason.code == 40012 ASSERT state_changes CONTAINS_IN_ORDER [ ConnectionState.failed ] +CLOSE_CLIENT(client) ``` --- @@ -496,6 +500,7 @@ token_details = AWAIT authorize_future # authorize() completed after server response ASSERT authorize_completed == true ASSERT token_details.token == "token-2" +CLOSE_CLIENT(client) ``` --- @@ -580,6 +585,7 @@ ASSERT connection_attempt_count == 2 # Second attempt used the new token ASSERT captured_ws_urls[1].queryParameters["accessToken"] == "token-2" +CLOSE_CLIENT(client) ``` --- @@ -646,6 +652,7 @@ ASSERT error.code == 40101 ```pseudo # Connection is in FAILED state ASSERT client.connection.state == ConnectionState.failed +CLOSE_CLIENT(client) ``` --- @@ -728,6 +735,7 @@ ASSERT state_changes CONTAINS_IN_ORDER [ # Connection used the token from authorize ASSERT captured_ws_urls[0].queryParameters["accessToken"] == "token-1" +CLOSE_CLIENT(client) ``` --- @@ -823,6 +831,7 @@ ASSERT state_changes CONTAINS_IN_ORDER [ # Second connection used the new token ASSERT captured_ws_urls[1].queryParameters["accessToken"] == "token-2" +CLOSE_CLIENT(client) ``` --- @@ -890,6 +899,7 @@ ASSERT token_details.token == "token-2" # Connection is now CONNECTED again ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/channels/channel_additional_attached.md b/uts/realtime/unit/channels/channel_additional_attached.md index 9a09fcf3e..5bee07175 100644 --- a/uts/realtime/unit/channels/channel_additional_attached.md +++ b/uts/realtime/unit/channels/channel_additional_attached.md @@ -74,6 +74,7 @@ ASSERT update_events[0].current == ChannelState.attached ASSERT update_events[0].previous == ChannelState.attached ASSERT update_events[0].resumed == false ASSERT update_events[0].reason.code == 50000 +CLOSE_CLIENT(client) ``` --- @@ -132,6 +133,7 @@ AWAIT Future.delayed(Duration.zero) ```pseudo ASSERT channel.state == ChannelState.attached ASSERT length(update_events) == 0 +CLOSE_CLIENT(client) ``` --- @@ -188,4 +190,5 @@ ASSERT channel.state == ChannelState.attached ASSERT length(update_events) == 1 ASSERT update_events[0].resumed == false ASSERT update_events[0].reason IS null +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_annotations.md b/uts/realtime/unit/channels/channel_annotations.md index d8836fcb0..c4cebea83 100644 --- a/uts/realtime/unit/channels/channel_annotations.md +++ b/uts/realtime/unit/channels/channel_annotations.md @@ -35,6 +35,7 @@ channel = client.channels.get("test-RTL26") ### Assertions ```pseudo ASSERT channel.annotations IS RealtimeAnnotations +CLOSE_CLIENT(client) ``` --- @@ -107,6 +108,7 @@ ASSERT ann.action == AnnotationAction.ANNOTATION_CREATE # numeric: 0 ASSERT ann.messageSerial == "msg-serial-1" ASSERT ann.type == "com.example.reaction" ASSERT ann.name == "like" +CLOSE_CLIENT(client) ``` --- @@ -151,6 +153,7 @@ AWAIT channel.annotations.publish("msg-serial-1", Annotation( name: "like" )) FAILS WITH error ASSERT error.code == 40003 +CLOSE_CLIENT(client) ``` --- @@ -216,6 +219,7 @@ ann = annotation_pm.annotations[0] ASSERT ann.data IS String ASSERT ann.encoding == "json" ASSERT parse_json(ann.data) == { "key": "value", "nested": { "a": 1 } } +CLOSE_CLIENT(client) ``` --- @@ -270,6 +274,7 @@ AWAIT channel.annotations.publish("msg-serial-1", Annotation( name: "like" )) FAILS WITH error ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -321,6 +326,7 @@ AWAIT channel.annotations.publish("msg-serial-1", Annotation( name: "like" )) # If we get here, publish succeeded (no assertion needed beyond no throw) +CLOSE_CLIENT(client) ``` ### Setup (NACK case) @@ -364,6 +370,7 @@ AWAIT channel.annotations.publish("msg-serial-1", Annotation( name: "like" )) FAILS WITH error ASSERT error.code == 40160 +CLOSE_CLIENT(client) ``` --- @@ -430,6 +437,7 @@ ASSERT ann.action == AnnotationAction.ANNOTATION_DELETE # numeric: 1 ASSERT ann.messageSerial == "msg-serial-1" ASSERT ann.type == "com.example.reaction" ASSERT ann.name == "like" +CLOSE_CLIENT(client) ``` --- @@ -535,6 +543,7 @@ ASSERT ann1.timestamp == 1700000000000 ann2 = received_annotations[1] ASSERT ann2.name == "heart" ASSERT ann2.clientId == "user-2" +CLOSE_CLIENT(client) ``` --- @@ -624,6 +633,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ASSERT reaction_annotations.length == 2 ASSERT reaction_annotations[0].name == "like" ASSERT reaction_annotations[1].name == "heart" +CLOSE_CLIENT(client) ``` --- @@ -675,6 +685,7 @@ AWAIT_STATE channel.state == ATTACHED ### Assertions ```pseudo ASSERT channel.state == ATTACHED +CLOSE_CLIENT(client) ``` --- @@ -738,6 +749,7 @@ FOR msg IN log_messages: IF msg CONTAINS "ANNOTATION_SUBSCRIBE": found_warning = true ASSERT found_warning == true +CLOSE_CLIENT(client) ``` --- @@ -794,6 +806,7 @@ FOR msg IN log_messages: IF msg CONTAINS "ANNOTATION_SUBSCRIBE": found_warning = true ASSERT found_warning == false +CLOSE_CLIENT(client) ``` --- @@ -885,6 +898,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( # Only the first annotation was received ASSERT received_annotations.length == 1 ASSERT received_annotations[0].name == "like" +CLOSE_CLIENT(client) ``` --- @@ -968,4 +982,5 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ASSERT reaction_received.length == 0 ASSERT comment_received.length == 1 ASSERT comment_received[0].type == "com.example.comment" +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md index 254c6eee2..d26fea9e7 100644 --- a/uts/realtime/unit/channels/channel_attach.md +++ b/uts/realtime/unit/channels/channel_attach.md @@ -57,6 +57,7 @@ AWAIT channel.attach() ```pseudo ASSERT channel.state == ChannelState.attached ASSERT attach_message_count == 1 # No additional ATTACH message sent +CLOSE_CLIENT(client) ``` --- @@ -116,6 +117,7 @@ AWAIT attach_future_2 ```pseudo ASSERT channel.state == ChannelState.attached ASSERT attach_message_count == 1 # Only one ATTACH message sent +CLOSE_CLIENT(client) ``` --- @@ -185,6 +187,7 @@ ASSERT channel.state == ChannelState.attached # Should have: ATTACH, DETACH, ATTACH attach_messages = filter(messages_from_client, (m) => m.action == ATTACH) ASSERT length(attach_messages) == 2 +CLOSE_CLIENT(client) ``` --- @@ -244,6 +247,7 @@ AWAIT channel.attach() ```pseudo ASSERT channel.state == ChannelState.attached ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) ``` --- @@ -284,6 +288,7 @@ AWAIT channel.attach() FAILS WITH error ```pseudo ASSERT error.code IS NOT null ASSERT channel.state != ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -327,6 +332,7 @@ AWAIT channel.attach() FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT channel.state != ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -371,6 +377,7 @@ AWAIT channel.attach() FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT channel.state != ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -413,6 +420,7 @@ AWAIT_STATE channel.state == ChannelState.attaching ```pseudo ASSERT channel.state == ChannelState.attaching # Attach message not yet sent (connection not ready) +CLOSE_CLIENT(client) ``` --- @@ -470,6 +478,7 @@ AWAIT attach_future ```pseudo ASSERT channel.state == ChannelState.attached ASSERT attach_message_received == true +CLOSE_CLIENT(client) ``` --- @@ -522,6 +531,7 @@ ASSERT channel.state == ChannelState.attached ASSERT captured_attach_message IS NOT null ASSERT captured_attach_message.action == ATTACH ASSERT captured_attach_message.channel == channel_name +CLOSE_CLIENT(client) ``` --- @@ -575,6 +585,7 @@ ASSERT length(captured_attach_messages) == 2 ASSERT captured_attach_messages[0].channelSerial IS null OR captured_attach_messages[0].channelSerial IS NOT SET # Second attach (reattach via setOptions) includes channelSerial ASSERT captured_attach_messages[1].channelSerial == "serial-from-server-1" +CLOSE_CLIENT(client) ``` --- @@ -626,6 +637,7 @@ AWAIT attach_future FAILS WITH error ```pseudo ASSERT channel.state == ChannelState.suspended ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -676,6 +688,7 @@ ASSERT captured_attach_message IS NOT null ASSERT captured_attach_message.params IS NOT null ASSERT captured_attach_message.params["rewind"] == "1" ASSERT captured_attach_message.params["delta"] == "vcdiff" +CLOSE_CLIENT(client) ``` --- @@ -727,6 +740,7 @@ ASSERT captured_attach_message.flags IS NOT null # Flags should include PUBLISH (131072, TR3r bit 17) and SUBSCRIBE (262144, TR3s bit 18) bits ASSERT (captured_attach_message.flags AND 131072) != 0 # PUBLISH bit set ASSERT (captured_attach_message.flags AND 262144) != 0 # SUBSCRIBE bit set +CLOSE_CLIENT(client) ``` --- @@ -771,6 +785,7 @@ AWAIT channel.attach() ASSERT channel.modes IS NOT null ASSERT ChannelMode.publish IN channel.modes ASSERT ChannelMode.subscribe IN channel.modes +CLOSE_CLIENT(client) ``` --- @@ -830,4 +845,5 @@ ASSERT length(captured_attach_messages) == 2 ASSERT (captured_attach_messages[0].flags AND 32) == 0 # ATTACH_RESUME = 32 # Second attach SHOULD have ATTACH_RESUME flag ASSERT (captured_attach_messages[1].flags AND 32) != 0 # ATTACH_RESUME = 32 +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_attributes.md b/uts/realtime/unit/channels/channel_attributes.md index d8cf22ac3..2c1c87f3f 100644 --- a/uts/realtime/unit/channels/channel_attributes.md +++ b/uts/realtime/unit/channels/channel_attributes.md @@ -34,6 +34,7 @@ ASSERT channel.name == "my-channel" # Also works with special characters channel2 = client.channels.get("namespace:channel-name") ASSERT channel2.name == "namespace:channel-name" +CLOSE_CLIENT(client) ``` --- @@ -112,6 +113,7 @@ ASSERT channel.errorReason IS NOT null ASSERT channel.errorReason.code == 90001 ASSERT channel.errorReason.statusCode == 500 ASSERT channel.errorReason.message == "Channel error occurred" +CLOSE_CLIENT(client) ``` --- @@ -180,6 +182,7 @@ AWAIT channel.attach() FAILS WITH error ASSERT channel.errorReason IS NOT null ASSERT channel.errorReason.code == 40160 ASSERT channel.errorReason.statusCode == 401 +CLOSE_CLIENT(client) ``` --- @@ -262,6 +265,7 @@ AWAIT channel.attach() ```pseudo ASSERT channel.state == ChannelState.attached ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) ``` --- @@ -359,4 +363,5 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_connection_state.md b/uts/realtime/unit/channels/channel_connection_state.md index 2b5fabb96..3107f0058 100644 --- a/uts/realtime/unit/channels/channel_connection_state.md +++ b/uts/realtime/unit/channels/channel_connection_state.md @@ -61,6 +61,7 @@ ASSERT channel.state == ChannelState.attached # No channel state change events should have been emitted ASSERT length(channel_state_changes) == 0 +CLOSE_CLIENT(client) ``` --- @@ -114,6 +115,7 @@ ASSERT channel.state == ChannelState.attaching # No channel state change events should have been emitted ASSERT length(channel_state_changes) == 0 +CLOSE_CLIENT(client) ``` --- @@ -181,6 +183,7 @@ ASSERT failed_change IS NOT null ASSERT failed_change.previous == ChannelState.attached ASSERT failed_change.reason IS NOT null ASSERT failed_change.reason.code == 40198 +CLOSE_CLIENT(client) ``` --- @@ -245,6 +248,7 @@ ASSERT channel.errorReason IS NOT null failed_change = channel_state_changes.find(c => c.current == ChannelState.failed) ASSERT failed_change IS NOT null ASSERT failed_change.previous == ChannelState.attaching +CLOSE_CLIENT(client) ``` --- @@ -320,6 +324,7 @@ ASSERT initialized_channel.state == ChannelState.initialized ASSERT detached_channel.state == ChannelState.detached ASSERT length(init_changes) == 0 ASSERT length(detached_changes) == 0 +CLOSE_CLIENT(client) ``` --- @@ -374,6 +379,7 @@ ASSERT channel.state == ChannelState.detached detached_change = channel_state_changes.find(c => c.current == ChannelState.detached) ASSERT detached_change IS NOT null ASSERT detached_change.previous == ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -430,6 +436,7 @@ ASSERT channel.state == ChannelState.detached detached_change = channel_state_changes.find(c => c.current == ChannelState.detached) ASSERT detached_change IS NOT null ASSERT detached_change.previous == ChannelState.attaching +CLOSE_CLIENT(client) ``` --- @@ -498,6 +505,7 @@ ASSERT channel.state == ChannelState.suspended suspended_change = channel_state_changes.find(c => c.current == ChannelState.suspended) ASSERT suspended_change IS NOT null ASSERT suspended_change.previous == ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -565,6 +573,7 @@ ASSERT channel.state == ChannelState.suspended suspended_change = channel_state_changes.find(c => c.current == ChannelState.suspended) ASSERT suspended_change IS NOT null ASSERT suspended_change.previous == ChannelState.attaching +CLOSE_CLIENT(client) ``` --- @@ -643,6 +652,7 @@ ASSERT channel_state_changes CONTAINS_IN_ORDER [ ChannelState.attaching, ChannelState.attached ] +CLOSE_CLIENT(client) ``` --- @@ -734,6 +744,7 @@ ASSERT channel_state_changes CONTAINS_IN_ORDER [ ChannelState.attaching, ChannelState.attached ] +CLOSE_CLIENT(client) ``` --- @@ -817,6 +828,7 @@ attach_count_after = length(attach_messages) new_attach_channels = [m.channel FOR m IN attach_messages[attach_count_before:]] ASSERT initialized_channel_name NOT IN new_attach_channels ASSERT detached_channel_name NOT IN new_attach_channels +CLOSE_CLIENT(client) ``` --- @@ -885,4 +897,5 @@ ASSERT channel2.state == ChannelState.attached new_attach_channels = [m.channel FOR m IN attach_messages[attach_count_before:]] ASSERT channel1_name IN new_attach_channels ASSERT channel2_name IN new_attach_channels +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_delta_decoding.md b/uts/realtime/unit/channels/channel_delta_decoding.md index a7ab4195f..316e2abb5 100644 --- a/uts/realtime/unit/channels/channel_delta_decoding.md +++ b/uts/realtime/unit/channels/channel_delta_decoding.md @@ -104,6 +104,7 @@ AWAIT length(received_messages) == 3 ASSERT received_messages[0].data == "first message" ASSERT received_messages[1].data == "second message" ASSERT received_messages[2].data == "third message" +CLOSE_CLIENT(client) ``` --- @@ -190,6 +191,7 @@ AWAIT length(received_messages) == 2 ```pseudo ASSERT received_messages[0].data == "base payload" ASSERT received_messages[1].data == "updated payload" +CLOSE_CLIENT(client) ``` --- @@ -297,6 +299,7 @@ ASSERT received_messages[0].data == { "foo": "bar", "count": 1 } # to produce the new JSON string, which is delivered as-is (no json encoding # step in the delta message's encoding) ASSERT received_messages[1].data == new_json_string +CLOSE_CLIENT(client) ``` --- @@ -392,6 +395,7 @@ ASSERT received_messages[0].data == base_binary # Second message delta-decoded using the binary base, then delivered as binary ASSERT received_messages[1].data == new_binary +CLOSE_CLIENT(client) ``` --- @@ -499,6 +503,7 @@ AWAIT length(received_messages) == 3 ASSERT received_messages[0].data == "value-A" ASSERT received_messages[1].data == "value-B" ASSERT received_messages[2].data == "value-C" +CLOSE_CLIENT(client) ``` --- @@ -605,6 +610,7 @@ ASSERT state_changes CONTAINS_IN_ORDER [ ] attaching_change = FIND state_changes WHERE current == ChannelState.attaching ASSERT attaching_change.reason.code == 40018 +CLOSE_CLIENT(client) ``` --- @@ -701,6 +707,7 @@ AWAIT length(received_messages) == 3 ASSERT received_messages[0].data == "first" ASSERT received_messages[1].data == "second" ASSERT received_messages[2].data == "third" +CLOSE_CLIENT(client) ``` --- @@ -809,6 +816,7 @@ ASSERT decode_calls[0].delta == delta # The decoded message was delivered to the subscriber ASSERT received_messages[1].data == "goodbye world" +CLOSE_CLIENT(client) ``` --- @@ -879,6 +887,7 @@ AWAIT_STATE channel.state == ChannelState.failed # Channel is FAILED with error code 40019 (no vcdiff plugin) ASSERT channel.state == ChannelState.failed ASSERT channel.errorReason.code == 40019 +CLOSE_CLIENT(client) ``` --- @@ -995,6 +1004,7 @@ ASSERT state_changes CONTAINS_IN_ORDER [ ] attaching_change = FIND state_changes WHERE current == ChannelState.attaching ASSERT attaching_change.reason.code == 40018 +CLOSE_CLIENT(client) ``` --- @@ -1121,6 +1131,7 @@ ASSERT channel.state == ChannelState.attached # (the failed delta msg-2 was discarded per RTL18b) ASSERT received_messages[0].data == "original base" ASSERT received_messages[1].data == "fresh after recovery" +CLOSE_CLIENT(client) ``` --- @@ -1229,4 +1240,5 @@ AWAIT Future.delayed(Duration.zero) # Only one recovery ATTACH was sent (not two) recovery_attaches = length(attach_messages) - initial_attach_count ASSERT recovery_attaches == 1 +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_detach.md b/uts/realtime/unit/channels/channel_detach.md index 2a2d734e0..2d0b55fd8 100644 --- a/uts/realtime/unit/channels/channel_detach.md +++ b/uts/realtime/unit/channels/channel_detach.md @@ -42,6 +42,7 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.initialized OR channel.state == ChannelState.detached # No state change events should have been emitted (or only to detached) +CLOSE_CLIENT(client) ``` --- @@ -98,6 +99,7 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT detach_message_count == 1 # No additional DETACH message sent +CLOSE_CLIENT(client) ``` --- @@ -163,6 +165,7 @@ AWAIT detach_future_2 ```pseudo ASSERT channel.state == ChannelState.detached ASSERT detach_message_count == 1 # Only one DETACH message sent +CLOSE_CLIENT(client) ``` --- @@ -227,6 +230,7 @@ ASSERT channel.state == ChannelState.detached ASSERT length(messages_from_client) == 2 ASSERT messages_from_client[0].action == ATTACH ASSERT messages_from_client[1].action == DETACH +CLOSE_CLIENT(client) ``` --- @@ -276,6 +280,7 @@ AWAIT channel.detach() FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT channel.state == ChannelState.failed # State unchanged +CLOSE_CLIENT(client) ``` --- @@ -333,6 +338,7 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT detach_message_count == 0 # No DETACH message sent - immediate transition +CLOSE_CLIENT(client) ``` --- @@ -381,6 +387,7 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT detach_message_count == 0 # No DETACH message sent +CLOSE_CLIENT(client) ``` --- @@ -440,6 +447,7 @@ ASSERT channel.state == ChannelState.detached ASSERT captured_detach_message IS NOT null ASSERT captured_detach_message.action == DETACH ASSERT captured_detach_message.channel == channel_name +CLOSE_CLIENT(client) ``` --- @@ -498,6 +506,7 @@ AWAIT detach_future FAILS WITH error ```pseudo ASSERT channel.state == ChannelState.attached # Returns to previous state ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -558,6 +567,7 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT detach_message_count == 2 # Two DETACH messages sent +CLOSE_CLIENT(client) ``` --- @@ -619,6 +629,7 @@ AWAIT Future.delayed(Duration(milliseconds: 100)) ```pseudo ASSERT detach_message_count == 2 # Client sent another DETACH ASSERT channel.state == ChannelState.detached +CLOSE_CLIENT(client) ``` --- @@ -681,6 +692,7 @@ ASSERT state_changes[0].event == ChannelEvent.detaching ASSERT state_changes[1].current == ChannelState.detached ASSERT state_changes[1].previous == ChannelState.detaching ASSERT state_changes[1].event == ChannelEvent.detached +CLOSE_CLIENT(client) ``` --- @@ -748,4 +760,5 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_error.md b/uts/realtime/unit/channels/channel_error.md index bfb1c0b57..58287f851 100644 --- a/uts/realtime/unit/channels/channel_error.md +++ b/uts/realtime/unit/channels/channel_error.md @@ -81,6 +81,7 @@ ASSERT channel_state_changes[0].reason.code == 40160 # Connection stays open (channel-scoped ERROR does NOT close connection) ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -139,6 +140,7 @@ ASSERT error.code == 40160 # Connection stays open ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -205,6 +207,7 @@ ASSERT error.code == 90198 # Connection stays open ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -271,6 +274,7 @@ ASSERT channel_b.errorReason IS null # Connection stays open ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -349,4 +353,5 @@ ADVANCE_TIME(500) # Channel remains FAILED - no retry was attempted ASSERT channel.state == ChannelState.failed ASSERT attach_count == attach_count_after_error +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_history.md b/uts/realtime/unit/channels/channel_history.md index 5e9e0b6ec..9770bb8bf 100644 --- a/uts/realtime/unit/channels/channel_history.md +++ b/uts/realtime/unit/channels/channel_history.md @@ -75,6 +75,7 @@ AWAIT channel.history(untilAttach: true) ```pseudo request = captured_requests[0] ASSERT request.url.query_params["fromSerial"] == attach_serial +CLOSE_CLIENT(client) ``` ### RTL10b - untilAttach errors when not attached @@ -116,4 +117,5 @@ CATCH e: #### Assertions ```pseudo ASSERT error IS AblyException +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_options.md b/uts/realtime/unit/channels/channel_options.md index dd4e53ca2..0a7b0a179 100644 --- a/uts/realtime/unit/channels/channel_options.md +++ b/uts/realtime/unit/channels/channel_options.md @@ -147,6 +147,7 @@ channel = client.channels.get(channel_name, channelOptions) ```pseudo ASSERT channel.options.params["rewind"] == "1" ASSERT channel.options.modes CONTAINS ChannelMode.subscribe +CLOSE_CLIENT(client) ``` --- @@ -183,6 +184,7 @@ sameChannel = client.channels.get(channel_name, newOptions) ASSERT sameChannel IS SAME AS channel ASSERT channel.options.cipherParams IS NOT null ASSERT channel.options.attachOnSubscribe == true +CLOSE_CLIENT(client) ``` --- @@ -217,6 +219,7 @@ ASSERT error.code == 40000 # Channel options should not have changed ASSERT channel.options.params IS null +CLOSE_CLIENT(client) ``` --- @@ -245,6 +248,7 @@ newOptions = RealtimeChannelOptions( client.channels.get(channel_name, newOptions) FAILS WITH error ASSERT error.code == 40000 +CLOSE_CLIENT(client) ``` --- @@ -276,6 +280,7 @@ AWAIT channel.setOptions(newOptions) ```pseudo ASSERT channel.options.params["delta"] == "vcdiff" ASSERT channel.options.attachOnSubscribe == false +CLOSE_CLIENT(client) ``` --- @@ -313,6 +318,7 @@ AWAIT channel.setOptions(newOptions) ASSERT stateChanges CONTAINS change WHERE change.current == ChannelState.attaching ASSERT channel.state == ChannelState.attached ASSERT channel.options.params["rewind"] == "1" +CLOSE_CLIENT(client) ``` --- @@ -342,6 +348,7 @@ channel = client.channels.getDerived(base_channel_name, deriveOptions) # Channel name should be encoded with filter ASSERT channel.name STARTS WITH "[filter=" ASSERT channel.name ENDS WITH "]" + base_channel_name +CLOSE_CLIENT(client) ``` --- @@ -371,6 +378,7 @@ expectedEncoded = base64_encode(filter) # "bmFtZSA9PSAndGVzdCc=" ### Assertions ```pseudo ASSERT channel.name == "[filter=" + expectedEncoded + "]" + base_channel_name +CLOSE_CLIENT(client) ``` --- @@ -419,6 +427,7 @@ IF qualifier CONTAINS "?": ASSERT length(parsedParams) == 2 ELSE: FAIL("Expected params in qualifier") +CLOSE_CLIENT(client) ``` --- @@ -451,6 +460,7 @@ channel = client.channels.getDerived(base_channel_name, deriveOptions, channelOp ```pseudo ASSERT channel.options.modes CONTAINS ChannelMode.subscribe ASSERT channel.options.attachOnSubscribe == false +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/channels/channel_properties.md b/uts/realtime/unit/channels/channel_properties.md index 79bdfa49d..6995fff3c 100644 --- a/uts/realtime/unit/channels/channel_properties.md +++ b/uts/realtime/unit/channels/channel_properties.md @@ -74,6 +74,7 @@ AWAIT channel.attach() # attachSerial updated from second ATTACHED response ASSERT channel.properties.attachSerial == "attach-serial-2" +CLOSE_CLIENT(client) ``` --- @@ -128,6 +129,7 @@ AWAIT_STATE channel.properties.attachSerial == "updated-serial" ### Assertions ```pseudo ASSERT channel.properties.attachSerial == "updated-serial" +CLOSE_CLIENT(client) ``` --- @@ -178,6 +180,7 @@ AWAIT channel.attach() ### Assertions ```pseudo ASSERT channel.properties.channelSerial == "serial-001" +CLOSE_CLIENT(client) ``` --- @@ -243,6 +246,7 @@ AWAIT_STATE channel.properties.channelSerial == "serial-003" ### Assertions ```pseudo ASSERT channel.properties.channelSerial == "serial-003" +CLOSE_CLIENT(client) ``` --- @@ -299,6 +303,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ```pseudo # channelSerial should remain unchanged ASSERT channel.properties.channelSerial == "serial-001" +CLOSE_CLIENT(client) ``` --- @@ -366,6 +371,7 @@ AWAIT_STATE channel.state == ChannelState.attached # (RTL15b1 clears it on DETACHED/SUSPENDED/FAILED, then ATTACHED sets it fresh) ASSERT attach_count == 2 ASSERT channel.properties.channelSerial == "serial-001" +CLOSE_CLIENT(client) ``` --- @@ -422,6 +428,7 @@ AWAIT channel.detach() ```pseudo ASSERT channel.state == ChannelState.detached ASSERT channel.properties.channelSerial IS null +CLOSE_CLIENT(client) ``` --- @@ -488,6 +495,7 @@ AWAIT_STATE channel.state == ChannelState.suspended ```pseudo ASSERT channel.state == ChannelState.suspended ASSERT channel.properties.channelSerial IS null +CLOSE_CLIENT(client) ``` --- @@ -543,4 +551,5 @@ AWAIT_STATE channel.state == ChannelState.failed ```pseudo ASSERT channel.state == ChannelState.failed ASSERT channel.properties.channelSerial IS null +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_publish.md b/uts/realtime/unit/channels/channel_publish.md index db12c32ad..a9d6f323d 100644 --- a/uts/realtime/unit/channels/channel_publish.md +++ b/uts/realtime/unit/channels/channel_publish.md @@ -57,6 +57,7 @@ ASSERT captured_messages[0].channel == channel_name ASSERT length(captured_messages[0].messages) == 1 ASSERT captured_messages[0].messages[0].name == "greeting" ASSERT captured_messages[0].messages[0].data == "hello" +CLOSE_CLIENT(client) ``` --- @@ -110,6 +111,7 @@ ASSERT length(captured_messages[0].messages) == 3 ASSERT captured_messages[0].messages[0].name == "event1" ASSERT captured_messages[0].messages[1].name == "event2" ASSERT captured_messages[0].messages[2].name == "event3" +CLOSE_CLIENT(client) ``` --- @@ -184,6 +186,7 @@ ASSERT msg1["data"] == "payload" msg2 = captured_frames[2]["messages"][0] ASSERT "name" NOT IN msg2 ASSERT "data" NOT IN msg2 +CLOSE_CLIENT(client) ``` --- @@ -258,6 +261,7 @@ ASSERT msg1["data"] == "payload" msg2 = captured_frames[2]["messages"][0] ASSERT "name" NOT IN msg2 ASSERT "data" NOT IN msg2 +CLOSE_CLIENT(client) ``` --- @@ -311,6 +315,7 @@ channel.publish(name: "test", data: "immediate") ASSERT length(captured_messages) == 1 ASSERT captured_messages[0].messages[0].name == "test" ASSERT captured_messages[0].messages[0].data == "immediate" +CLOSE_CLIENT(client) ``` --- @@ -358,6 +363,7 @@ channel.publish(name: "while-attaching", data: "data") # Message should have been sent immediately (ATTACHING is neither SUSPENDED nor FAILED) ASSERT length(captured_messages) == 1 ASSERT captured_messages[0].messages[0].name == "while-attaching" +CLOSE_CLIENT(client) ``` --- @@ -406,6 +412,7 @@ channel.publish(name: "before-attach", data: "data") # Message should have been sent immediately ASSERT length(captured_messages) == 1 ASSERT captured_messages[0].messages[0].name == "before-attach" +CLOSE_CLIENT(client) ``` --- @@ -461,6 +468,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ASSERT length(captured_messages) == 1 ASSERT captured_messages[0].messages[0].name == "queued" ASSERT captured_messages[0].messages[0].data == "waiting" +CLOSE_CLIENT(client) ``` --- @@ -521,6 +529,7 @@ ASSERT length(captured_messages) > message_count_before # Find the queued message in captured messages queued = filter(captured_messages, (m) => m.messages[0].name == "during-disconnect") ASSERT length(queued) == 1 +CLOSE_CLIENT(client) ``` --- @@ -569,6 +578,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Queued message should now have been sent ASSERT length(captured_messages) == 1 ASSERT captured_messages[0].messages[0].name == "pre-connect" +CLOSE_CLIENT(client) ``` --- @@ -619,6 +629,7 @@ channel.publish(name: "fail", data: "should-error") FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -657,6 +668,7 @@ channel.publish(name: "fail", data: "should-error") FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -700,6 +712,7 @@ channel.publish(name: "fail", data: "should-error") FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -756,6 +769,7 @@ channel.publish(name: "fail", data: "should-error") FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT length(captured_messages) == 0 # No MESSAGE sent to server +CLOSE_CLIENT(client) ``` --- @@ -807,6 +821,7 @@ channel.publish(name: "fail", data: "should-error") FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT length(captured_messages) == 0 # No MESSAGE sent to server +CLOSE_CLIENT(client) ``` --- @@ -848,6 +863,7 @@ channel.publish(name: "fail", data: "should-error") FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -901,6 +917,7 @@ ASSERT length(captured_messages) == 1 # Channel should remain INITIALIZED — no implicit attach ASSERT channel.state == ChannelState.initialized ASSERT attach_message_count == 0 +CLOSE_CLIENT(client) ``` --- @@ -956,6 +973,7 @@ ASSERT length(captured_messages) == 3 ASSERT captured_messages[0].messages[0].name == "first" ASSERT captured_messages[1].messages[0].name == "second" ASSERT captured_messages[2].messages[0].name == "third" +CLOSE_CLIENT(client) ``` --- @@ -1004,6 +1022,7 @@ ASSERT length(captured_messages) == 1 ASSERT length(captured_messages[0].messages) == 1 ASSERT captured_messages[0].messages[0].name == "custom" ASSERT captured_messages[0].messages[0].data == {"key": "value"} +CLOSE_CLIENT(client) ``` --- @@ -1070,6 +1089,7 @@ ASSERT captured_messages[0].msgSerial == 0 ASSERT result IS PublishResult ASSERT length(result.serials) == 1 ASSERT result.serials[0] == "abc123" +CLOSE_CLIENT(client) ``` --- @@ -1135,6 +1155,7 @@ ASSERT length(result.serials) == 3 ASSERT result.serials[0] == "serial-1" ASSERT result.serials[1] == null # Conflated message ASSERT result.serials[2] == "serial-3" +CLOSE_CLIENT(client) ``` --- @@ -1198,6 +1219,7 @@ ASSERT captured_messages[2].msgSerial == 2 ASSERT result1.serials[0] == "serial-0" ASSERT result2.serials[0] == "serial-1" ASSERT result3.serials[0] == "serial-2" +CLOSE_CLIENT(client) ``` --- @@ -1252,6 +1274,7 @@ AWAIT channel.publish(name: "rejected", data: "data") FAILS WITH error ASSERT error IS NOT null ASSERT error.code == 40160 ASSERT error.message == "Publish rejected" +CLOSE_CLIENT(client) ``` --- @@ -1326,6 +1349,7 @@ AWAIT publish_future FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -1379,6 +1403,7 @@ AWAIT publish_future FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -1443,6 +1468,7 @@ AWAIT publish_future FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -1500,6 +1526,7 @@ AWAIT future3 FAILS WITH error3 ASSERT error1 IS NOT null ASSERT error2 IS NOT null ASSERT error3 IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -1566,6 +1593,7 @@ AWAIT publish_future FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -1642,6 +1670,7 @@ result = AWAIT publish_future ```pseudo ASSERT result IS PublishResult ASSERT result.serials[0] == "serial-ack" +CLOSE_CLIENT(client) ``` --- @@ -1735,6 +1764,7 @@ ASSERT second_transport_messages[0].msg.messages[0].name == "resend-me" # Publish should have resolved successfully ASSERT result IS PublishResult ASSERT result.serials[0] == "serial-resent" +CLOSE_CLIENT(client) ``` --- @@ -1828,6 +1858,7 @@ second_transport_msgs = filter(captured_messages, (m) => m.connection == 2 AND m ASSERT length(second_transport_msgs) == 2 ASSERT second_transport_msgs[0].msg.msgSerial == original_serial_1 ASSERT second_transport_msgs[1].msg.msgSerial == original_serial_2 +CLOSE_CLIENT(client) ``` --- @@ -1929,6 +1960,7 @@ second_transport_msgs = filter(captured_messages, (m) => m.connection == 2 AND m ASSERT length(second_transport_msgs) == 2 ASSERT second_transport_msgs[0].msg.msgSerial == 0 ASSERT second_transport_msgs[1].msg.msgSerial == 1 +CLOSE_CLIENT(client) ``` --- @@ -2010,6 +2042,7 @@ ASSERT channel.state == ChannelState.attached second_transport_attaches = filter(captured_attach_messages, (m) => m.connection == 2) ASSERT length(second_transport_attaches) >= 1 ASSERT second_transport_attaches[0].msg.channel == channel_name +CLOSE_CLIENT(client) ``` --- @@ -2094,4 +2127,5 @@ ASSERT channel.state == ChannelState.detached second_transport_detaches = filter(captured_detach_messages, (m) => m.connection == 2) ASSERT length(second_transport_detaches) >= 1 ASSERT second_transport_detaches[0].msg.channel == channel_name +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_server_initiated_detach.md b/uts/realtime/unit/channels/channel_server_initiated_detach.md index cff98929f..7bb972046 100644 --- a/uts/realtime/unit/channels/channel_server_initiated_detach.md +++ b/uts/realtime/unit/channels/channel_server_initiated_detach.md @@ -81,6 +81,7 @@ ASSERT channel_state_changes[0].previous == ChannelState.attached ASSERT channel_state_changes[0].reason IS NOT null ASSERT channel_state_changes[0].reason.code == 90198 ASSERT channel_state_changes[1].current == ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -167,6 +168,7 @@ AWAIT_STATE channel.state == ChannelState.attached ASSERT channel.state == ChannelState.attached # 3 total ATTACH messages: initial + RTL13a reattach + RTL13a reattach from SUSPENDED ASSERT attach_count == 3 +CLOSE_CLIENT(client) ``` --- @@ -263,6 +265,7 @@ ASSERT channel_state_changes CONTAINS_IN_ORDER [ ChannelState.attaching, ChannelState.attached ] +CLOSE_CLIENT(client) ``` --- @@ -345,6 +348,7 @@ ASSERT channel_state_changes[0].current == ChannelState.suspended ASSERT channel_state_changes[0].previous == ChannelState.attaching ASSERT channel_state_changes[0].reason IS NOT null ASSERT channel_state_changes[0].reason.code == 90198 +CLOSE_CLIENT(client) ``` --- @@ -445,6 +449,7 @@ ASSERT channel_state_changes CONTAINS_IN_ORDER [ ChannelState.attaching, ChannelState.attached ] +CLOSE_CLIENT(client) ``` --- @@ -530,6 +535,7 @@ ASSERT attach_count == attach_count_after_disconnect # (connection DISCONNECTED does not affect channel state per RTL3e, # so channel should still be SUSPENDED) ASSERT channel.state == ChannelState.suspended +CLOSE_CLIENT(client) ``` --- @@ -588,4 +594,5 @@ ASSERT channel.state == ChannelState.detached # Only one ATTACH message (the initial attach, no reattach) ASSERT attach_count == 1 +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md index 713bae558..a89dc1bdc 100644 --- a/uts/realtime/unit/channels/channel_state_events.md +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -29,6 +29,7 @@ channel = client.channels.get(channel_name) ```pseudo ASSERT channel.state IS ChannelState ASSERT channel.state == ChannelState.initialized +CLOSE_CLIENT(client) ``` --- @@ -54,6 +55,7 @@ channel = client.channels.get(channel_name) ### Assertions ```pseudo ASSERT channel.state == ChannelState.initialized +CLOSE_CLIENT(client) ``` --- @@ -105,6 +107,7 @@ ASSERT state_changes[0].current == ChannelState.attaching ASSERT state_changes[0].previous == ChannelState.initialized ASSERT state_changes[1].current == ChannelState.attached ASSERT state_changes[1].previous == ChannelState.attaching +CLOSE_CLIENT(client) ``` --- @@ -160,6 +163,7 @@ ASSERT captured_change IS ChannelStateChange ASSERT captured_change.current == ChannelState.attaching ASSERT captured_change.previous == ChannelState.initialized ASSERT captured_change.event == ChannelEvent.attaching +CLOSE_CLIENT(client) ``` --- @@ -216,6 +220,7 @@ ASSERT captured_change.current == ChannelState.failed ASSERT captured_change.reason IS NOT null ASSERT captured_change.reason.code == 40160 ASSERT captured_change.reason.message == "Channel denied" +CLOSE_CLIENT(client) ``` --- @@ -262,6 +267,7 @@ AWAIT channel.attach() ASSERT length(attached_events) == 1 ASSERT attached_events[0].current == ChannelState.attached ASSERT attached_events[0].event == ChannelEvent.attached +CLOSE_CLIENT(client) ``` --- @@ -326,6 +332,7 @@ ASSERT update_events[0].event == ChannelEvent.update ASSERT update_events[0].current == ChannelState.attached ASSERT update_events[0].previous == ChannelState.attached ASSERT update_events[0].resumed == false +CLOSE_CLIENT(client) ``` --- @@ -385,6 +392,7 @@ attached_state_events = filter(all_events, (e) => e.current == ChannelState.attached AND e.event == ChannelEvent.attached ) ASSERT length(attached_state_events) == 1 # Only the original attach +CLOSE_CLIENT(client) ``` --- @@ -435,6 +443,7 @@ AWAIT channel.attach() ```pseudo ASSERT captured_change IS NOT null ASSERT captured_change.hasBacklog == true +CLOSE_CLIENT(client) ``` --- @@ -482,6 +491,7 @@ AWAIT channel.attach() ```pseudo ASSERT captured_change IS NOT null ASSERT captured_change.hasBacklog == false OR captured_change.hasBacklog IS null +CLOSE_CLIENT(client) ``` --- @@ -529,6 +539,7 @@ AWAIT channel.attach() ```pseudo ASSERT captured_change IS NOT null ASSERT captured_change.resumed == true +CLOSE_CLIENT(client) ``` --- @@ -578,6 +589,7 @@ ASSERT channel.state == ChannelState.failed ASSERT channel.errorReason IS NOT null ASSERT channel.errorReason.code == 40160 ASSERT channel.errorReason.message == "Not authorized" +CLOSE_CLIENT(client) ``` --- @@ -637,4 +649,5 @@ AWAIT channel.attach() ```pseudo ASSERT channel.state == ChannelState.attached ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_subscribe.md b/uts/realtime/unit/channels/channel_subscribe.md index cbae8f600..34f91140d 100644 --- a/uts/realtime/unit/channels/channel_subscribe.md +++ b/uts/realtime/unit/channels/channel_subscribe.md @@ -83,6 +83,7 @@ ASSERT received_messages[1].name == "event2" ASSERT received_messages[1].data == "data2" ASSERT received_messages[2].name IS null ASSERT received_messages[2].data == "data3" +CLOSE_CLIENT(client) ``` --- @@ -142,6 +143,7 @@ ASSERT length(received_messages) == 3 ASSERT received_messages[0].name == "batch1" ASSERT received_messages[1].name == "batch2" ASSERT received_messages[2].name == "batch3" +CLOSE_CLIENT(client) ``` --- @@ -214,6 +216,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ASSERT length(received_messages) == 1 ASSERT received_messages[0].name == "target" ASSERT received_messages[0].data == "should-receive" +CLOSE_CLIENT(client) ``` --- @@ -281,6 +284,7 @@ ASSERT alpha_messages[1].data == "a2" ASSERT length(beta_messages) == 1 ASSERT beta_messages[0].data == "b1" +CLOSE_CLIENT(client) ``` --- @@ -344,6 +348,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ] )) ASSERT length(received_messages) == 1 +CLOSE_CLIENT(client) ``` --- @@ -401,6 +406,7 @@ AWAIT_STATE channel.state == ChannelState.attached ```pseudo ASSERT channel.state == ChannelState.attached ASSERT attach_message_count == 2 +CLOSE_CLIENT(client) ``` --- @@ -471,6 +477,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ```pseudo ASSERT length(received_messages) == 1 ASSERT received_messages[0].data == "after-reattach" +CLOSE_CLIENT(client) ``` --- @@ -518,6 +525,7 @@ channel.subscribe((message) => {}) # Channel should remain INITIALIZED — no attach triggered ASSERT channel.state == ChannelState.initialized ASSERT attach_message_count == 0 +CLOSE_CLIENT(client) ``` --- @@ -566,6 +574,7 @@ channel.subscribe((message) => {}) ```pseudo ASSERT channel.state == ChannelState.attached ASSERT attach_message_count == 1 +CLOSE_CLIENT(client) ``` --- @@ -613,6 +622,7 @@ channel.subscribe((message) => {}) ```pseudo ASSERT channel.state == ChannelState.attaching ASSERT attach_message_count == 1 # No additional ATTACH message sent +CLOSE_CLIENT(client) ``` --- @@ -668,6 +678,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ```pseudo # Message should not have been delivered ASSERT length(received_messages) == 0 +CLOSE_CLIENT(client) ``` --- @@ -745,6 +756,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ASSERT length(received_messages) == 1 ASSERT received_messages[0].name == "remote" ASSERT received_messages[0].data == "from-other" +CLOSE_CLIENT(client) ``` --- @@ -820,6 +832,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ASSERT length(messages_a) == 1 # Did not receive second message ASSERT length(messages_b) == 2 # Received both messages ASSERT messages_b[1].name == "msg2" +CLOSE_CLIENT(client) ``` --- @@ -893,6 +906,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ASSERT length(received_messages) == 3 ASSERT received_messages[2].name == "beta" ASSERT received_messages[2].data == "b2" +CLOSE_CLIENT(client) ``` --- @@ -964,6 +978,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ```pseudo ASSERT length(messages_all) == 1 # No new messages ASSERT length(messages_named) == 1 # No new messages +CLOSE_CLIENT(client) ``` --- @@ -1023,4 +1038,5 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( # Existing subscription should be unaffected ASSERT length(received_messages) == 1 ASSERT received_messages[0].data == "still-works" +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_update_delete_message.md b/uts/realtime/unit/channels/channel_update_delete_message.md index bc8ab61b8..28855e9b8 100644 --- a/uts/realtime/unit/channels/channel_update_delete_message.md +++ b/uts/realtime/unit/channels/channel_update_delete_message.md @@ -80,6 +80,7 @@ ASSERT msg.action == MessageAction.MESSAGE_UPDATE # numeric: 1 ASSERT msg.serial == "msg-serial-1" ASSERT msg.name == "updated" ASSERT msg.data == "new-data" +CLOSE_CLIENT(client) ``` --- @@ -147,6 +148,7 @@ ASSERT message_pm IS NOT null msg = message_pm.messages[0] ASSERT msg.action == MessageAction.MESSAGE_DELETE # numeric: 2 ASSERT msg.serial == "msg-serial-1" +CLOSE_CLIENT(client) ``` --- @@ -216,6 +218,7 @@ msg = message_pm.messages[0] ASSERT msg.action == MessageAction.MESSAGE_APPEND # numeric: 5 ASSERT msg.serial == "msg-serial-1" ASSERT msg.data == "appended-data" +CLOSE_CLIENT(client) ``` --- @@ -296,6 +299,7 @@ ASSERT msg_with_op.version.metadata["reason"] == "typo" # Without operation: version field absent msg_without_op = message_pms[1].messages[0] ASSERT msg_without_op.version IS null +CLOSE_CLIENT(client) ``` --- @@ -353,6 +357,7 @@ ASSERT original_message.name == "original" ASSERT original_message.data == "original-data" ASSERT original_message.serial == "msg-serial-1" ASSERT original_message.action IS null +CLOSE_CLIENT(client) ``` --- @@ -408,6 +413,7 @@ result = AWAIT channel.updateMessage( ```pseudo ASSERT result IS UpdateDeleteResult ASSERT result.versionSerial == "01770000000000-000@abcdef:000" +CLOSE_CLIENT(client) ``` --- @@ -462,6 +468,7 @@ AWAIT channel.updateMessage( ### Assertions ```pseudo ASSERT error.code == 40160 +CLOSE_CLIENT(client) ``` --- @@ -526,6 +533,7 @@ ASSERT message_pm IS NOT null ASSERT message_pm.params["key1"] == "value1" ASSERT message_pm.params["key2"] == "value2" +CLOSE_CLIENT(client) ``` --- @@ -577,4 +585,5 @@ AWAIT channel.deleteMessage( Message(data: "v2") ) FAILS WITH error ASSERT error.code == 40003 +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_when_state_test.md b/uts/realtime/unit/channels/channel_when_state_test.md index a4b7ace25..2c2357cb9 100644 --- a/uts/realtime/unit/channels/channel_when_state_test.md +++ b/uts/realtime/unit/channels/channel_when_state_test.md @@ -14,21 +14,21 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock ## Purpose `RealtimeChannel#whenState` is a convenience function for waiting on channel state: -- If the channel is already in the given state, the listener is called immediately - with a `null` argument (RTL25a). -- Otherwise, the listener is registered with `#once` for the given state, and - called with the `ChannelStateChange` when the state is reached (RTL25b). +- If the channel is already in the given state, it resolves immediately + with a `null` value (RTL25a). +- Otherwise, it waits for the given state to be reached, and resolves + with the `ChannelStateChange` when the state is reached (RTL25b). This mirrors the `Connection#whenState` function (RTN26). --- -## RTL25a - whenState calls listener immediately if already in state +## RTL25a - whenState resolves immediately if already in state -**Spec requirement:** If the channel is already in the given state, calls the -listener with a `null` argument. +**Spec requirement:** If the channel is already in the given state, resolves +immediately with a `null` value. -Tests that whenState invokes the callback immediately when the channel is already +Tests that whenState resolves immediately when the channel is already in the target state. ### Setup @@ -75,32 +75,22 @@ AWAIT_STATE client.connection.state == ConnectionState.connected AWAIT channel.attach() # Channel is now ATTACHED — call whenState for current state -callback_invoked = false -callback_arg = undefined - -channel.whenState(ChannelState.attached, (change) => { - callback_invoked = true - callback_arg = change -}) - -# Callback should be invoked synchronously or very quickly -WAIT(50) +result = AWAIT channel.whenState(ChannelState.attached) ``` ### Assertions ```pseudo -# Callback was invoked immediately -ASSERT callback_invoked == true - -# Callback was invoked with null argument (not a ChannelStateChange object) -ASSERT callback_arg IS null +# whenState resolved immediately with null (already in target state) +ASSERT result IS null +CLOSE_CLIENT(client) ``` --- ## RTL25b - whenState waits for state if not already in it -**Spec requirement:** Else, calls `#once` with the given state and listener. +**Spec requirement:** If the channel is not in the given state, waits for the +state to be reached and resolves with the `ChannelStateChange`. Tests that whenState waits for a state transition when the channel is not currently in the target state. @@ -147,44 +137,34 @@ channel = client.channels.get(channel_name) client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Channel is in INITIALIZED state — register whenState for ATTACHED -callback_invoked = false -callback_arg = undefined - -channel.whenState(ChannelState.attached, (change) => { - callback_invoked = true - callback_arg = change -}) +# Channel is in INITIALIZED state — start waiting for ATTACHED +when_state_promise = channel.whenState(ChannelState.attached) -# Callback should not be invoked yet -ASSERT callback_invoked == false - -# Attach the channel +# Attach the channel (this triggers the state transition) AWAIT channel.attach() -# Give callback a moment to execute -WAIT(50) +# Now await the whenState result +result = AWAIT when_state_promise ``` ### Assertions ```pseudo -# Callback was invoked after state transition -ASSERT callback_invoked == true - -# Callback was invoked with a ChannelStateChange object (not null) -ASSERT callback_arg IS NOT null -ASSERT callback_arg.current == ChannelState.attached -ASSERT callback_arg.previous IN [ChannelState.initialized, ChannelState.attaching] +# whenState resolved with a ChannelStateChange object (not null) +ASSERT result IS NOT null +ASSERT result.current == ChannelState.attached +ASSERT result.previous IN [ChannelState.initialized, ChannelState.attaching] +CLOSE_CLIENT(client) ``` --- ## RTL25b - whenState only fires once -**Spec requirement:** whenState uses `#once`, meaning it should only fire once, -not on every subsequent occurrence of the state. +**Spec requirement:** whenState resolves only once, even if the state is entered +multiple times. Subsequent entries into the same state do not trigger additional +resolutions. -Tests that the whenState callback is invoked only once even if the state is entered +Tests that the whenState resolution is one-shot even if the state is entered multiple times. ### Setup @@ -234,32 +214,32 @@ channel = client.channels.get(channel_name) client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Register whenState for ATTACHED -callback_count = 0 +# Register a side-effect counter via a listener wrapping whenState +attach_count = 0 +channel.once(ChannelState.attached, () => { attach_count++ }) -channel.whenState(ChannelState.attached, (change) => { - callback_count++ -}) +# Also start a whenState that we'll use to verify one-shot behavior +when_state_promise = channel.whenState(ChannelState.attached) # First attach AWAIT channel.attach() -WAIT(50) - -# Verify callback was invoked once -ASSERT callback_count == 1 +result = AWAIT when_state_promise +ASSERT result IS NOT null +ASSERT attach_count == 1 # Detach AWAIT channel.detach() -# Second attach +# Second attach — a new whenState should be needed; the old one is consumed AWAIT channel.attach() WAIT(50) ``` ### Assertions ```pseudo -# Callback was still only invoked once (not again on second attach) -ASSERT callback_count == 1 +# The once listener only fired once (confirming one-shot semantics) +ASSERT attach_count == 1 +CLOSE_CLIENT(client) ``` --- @@ -268,10 +248,10 @@ ASSERT callback_count == 1 **Spec requirement:** whenState checks the current state. If the channel has already passed through a state but is no longer in it, whenState should NOT -invoke the callback immediately. +resolve immediately. Tests that whenState for a state that was previously visited but is no longer -current does not fire. +current does not resolve. ### Setup ```pseudo @@ -320,18 +300,19 @@ AWAIT channel.attach() ASSERT channel.state == ChannelState.attached # Now call whenState for ATTACHING — a past state, not the current one -callback_invoked = false +resolved = false -channel.whenState(ChannelState.attaching, (change) => { - callback_invoked = true -}) +# Start whenState but do NOT await — check that it does not resolve +when_state_promise = channel.whenState(ChannelState.attaching) +when_state_promise.then(() => { resolved = true }) -# Wait to see if callback is invoked +# Wait to see if it resolves WAIT(200) ``` ### Assertions ```pseudo -# Callback should NOT be invoked (we're not in ATTACHING state anymore) -ASSERT callback_invoked == false +# whenState should NOT have resolved (we're not in ATTACHING state anymore) +ASSERT resolved == false +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channels_collection.md b/uts/realtime/unit/channels/channels_collection.md index a1924bf93..4f2b01d96 100644 --- a/uts/realtime/unit/channels/channels_collection.md +++ b/uts/realtime/unit/channels/channels_collection.md @@ -29,6 +29,7 @@ channels = client.channels ```pseudo ASSERT channels IS RealtimeChannels ASSERT channels IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -67,6 +68,7 @@ exists_other = client.channels.exists(other_channel_name) ASSERT exists_before == false ASSERT exists_after == true ASSERT exists_other == false +CLOSE_CLIENT(client) ``` --- @@ -103,6 +105,7 @@ ASSERT channel_name_a IN names ASSERT channel_name_b IN names ASSERT channel_name_c IN names ASSERT length(names) == 3 +CLOSE_CLIENT(client) ``` --- @@ -131,6 +134,7 @@ channel = client.channels.get(channel_name) ASSERT channel IS RealtimeChannel ASSERT channel.name == channel_name ASSERT client.channels.exists(channel_name) == true +CLOSE_CLIENT(client) ``` --- @@ -162,6 +166,7 @@ channel2 = client.channels.get(channel_name) ASSERT channel1 IS SAME AS channel2 # Same object reference ASSERT channel1.name == channel_name ASSERT channel2.name == channel_name +CLOSE_CLIENT(client) ``` --- @@ -196,6 +201,7 @@ channel3 = client.channels[channel_name] ASSERT channel1 IS SAME AS channel2 ASSERT channel2 IS SAME AS channel3 ASSERT channel1.name == channel_name +CLOSE_CLIENT(client) ``` --- @@ -226,6 +232,7 @@ AWAIT client.channels.release(channel_name) ### Assertions ```pseudo ASSERT client.channels.exists(channel_name) == false +CLOSE_CLIENT(client) ``` --- @@ -253,6 +260,7 @@ AWAIT client.channels.release(channel_name) ```pseudo # Should complete without throwing ASSERT client.channels.exists(channel_name) == false +CLOSE_CLIENT(client) ``` --- @@ -289,6 +297,7 @@ AWAIT client.channels.release(channel_name) ASSERT state_before_release == ChannelState.attached ASSERT client.channels.exists(channel_name) == false # Channel should have been detached before removal +CLOSE_CLIENT(client) ``` --- @@ -323,4 +332,5 @@ channel2 = client.channels.get(channel_name) ASSERT channel1 IS NOT SAME AS channel2 # Different object instances ASSERT channel2.name == channel_name ASSERT client.channels.exists(channel_name) == true +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/message_field_population.md b/uts/realtime/unit/channels/message_field_population.md index 86255ecbf..6ed82fcbf 100644 --- a/uts/realtime/unit/channels/message_field_population.md +++ b/uts/realtime/unit/channels/message_field_population.md @@ -98,6 +98,7 @@ AWAIT length(received_messages) == 3 ASSERT received_messages[0].id == "abc123:5:0" ASSERT received_messages[1].id == "abc123:5:1" ASSERT received_messages[2].id == "abc123:5:2" +CLOSE_CLIENT(client) ``` --- @@ -157,6 +158,7 @@ AWAIT length(received_messages) == 1 ### Assertions ```pseudo ASSERT received_messages[0].id == "my-custom-id" +CLOSE_CLIENT(client) ``` --- @@ -218,6 +220,7 @@ AWAIT length(received_messages) == 1 ### Assertions ```pseudo ASSERT received_messages[0].id IS null +CLOSE_CLIENT(client) ``` --- @@ -280,6 +283,7 @@ AWAIT length(received_messages) == 1 ### Assertions ```pseudo ASSERT received_messages[0].connectionId == "server-conn-xyz" +CLOSE_CLIENT(client) ``` --- @@ -340,6 +344,7 @@ AWAIT length(received_messages) == 1 ### Assertions ```pseudo ASSERT received_messages[0].connectionId == "msg-conn" +CLOSE_CLIENT(client) ``` --- @@ -402,6 +407,7 @@ AWAIT length(received_messages) == 1 ### Assertions ```pseudo ASSERT received_messages[0].timestamp == 1700000000000 +CLOSE_CLIENT(client) ``` --- @@ -462,6 +468,7 @@ AWAIT length(received_messages) == 1 ### Assertions ```pseudo ASSERT received_messages[0].timestamp == 1600000000000 +CLOSE_CLIENT(client) ``` --- @@ -537,4 +544,5 @@ ASSERT received_messages[1].connectionId == "connId" ASSERT received_messages[1].timestamp == 1700000000000 ASSERT received_messages[1].name == "second" ASSERT received_messages[1].data == "b" +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/client/realtime_client.md b/uts/realtime/unit/client/realtime_client.md index daaf83dec..1d56117de 100644 --- a/uts/realtime/unit/client/realtime_client.md +++ b/uts/realtime/unit/client/realtime_client.md @@ -97,6 +97,7 @@ client = Realtime(options: ClientOptions( ASSERT client.connection IS NOT null ASSERT client.connection IS Connection ASSERT client.connection.state == ConnectionState.initialized +CLOSE_CLIENT(client) ``` --- @@ -129,6 +130,7 @@ ASSERT client.channels IS Channels channel = client.channels.get(channel_name) ASSERT channel IS RealtimeChannel ASSERT channel.name == channel_name +CLOSE_CLIENT(client) ``` --- @@ -154,6 +156,7 @@ client = Realtime(options: ClientOptions( ```pseudo ASSERT client.auth IS NOT null ASSERT client.auth IS Auth +CLOSE_CLIENT(client) ``` --- @@ -180,6 +183,7 @@ client = Realtime(options: ClientOptions( ASSERT client.push IS NOT null ASSERT client.push IS Push ASSERT client.push.admin IS PushAdmin +CLOSE_CLIENT(client) ``` --- @@ -204,6 +208,7 @@ client = Realtime(options: ClientOptions( ASSERT client.clientId == "explicit-client-id" ASSERT client.clientId == client.auth.clientId +CLOSE_CLIENT(client) ``` --- @@ -229,6 +234,7 @@ pending = AWAIT mock_ws.await_connection_attempt() # Check the connection URL query parameters ASSERT pending.url.query_params["echo"] == "true" +CLOSE_CLIENT(client) ``` ### RTC1a_2 - echoMessages set to false @@ -247,6 +253,7 @@ pending = AWAIT mock_ws.await_connection_attempt() # Check the connection URL query parameters ASSERT pending.url.query_params["echo"] == "false" +CLOSE_CLIENT(client) ``` --- @@ -276,6 +283,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connecting OR AWAIT client.connection.once(ConnectionEvent.connected) ASSERT mock_ws.connect_attempts.length >= 1 +CLOSE_CLIENT(client) ``` ### RTC1b_2 - autoConnect set to false @@ -297,6 +305,7 @@ ASSERT mock_ws.connect_attempts.length == 0 WAIT 100ms ASSERT client.connection.state == ConnectionState.initialized ASSERT mock_ws.connect_attempts.length == 0 +CLOSE_CLIENT(client) ``` ### RTC1b_3 - Explicit connect after autoConnect false @@ -323,6 +332,7 @@ AWAIT client.connection.once(ConnectionEvent.connected) ASSERT mock_ws.events.filter(type: CONNECTION_ATTEMPT).length == 1 AWAIT_STATE client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -355,6 +365,7 @@ pending = AWAIT mock_ws.await_connection_attempt() # Check the connection URL query parameters ASSERT pending.url.query_params["recover"] == "previous-connection-key" +CLOSE_CLIENT(client) ``` ### RTC1c_2 - recover option cleared after connection attempt (RTN16k) @@ -389,6 +400,7 @@ AWAIT client.connection.once(ConnectionEvent.connected) # (RTN16k - recover is used only for initial connection) second_connect_url = mock_ws.connect_attempts[1].url ASSERT "recover" NOT IN second_connect_url.query_params +CLOSE_CLIENT(client) ``` ### RTC1c_3 - Invalid recovery key handled gracefully @@ -407,6 +419,7 @@ pending = AWAIT mock_ws.await_connection_attempt() # Connection should proceed without recover parameter ASSERT "recover" NOT IN pending.url.query_params +CLOSE_CLIENT(client) ``` --- @@ -440,6 +453,7 @@ pending = AWAIT mock_ws.await_connection_attempt() # Check the connection URL query parameters ASSERT pending.url.query_params["customParam"] == "customValue" ASSERT pending.url.query_params["anotherParam"] == "123" +CLOSE_CLIENT(client) ``` ### RTC1f_2 - transportParams with different value types (Stringifiable) @@ -466,6 +480,7 @@ ASSERT pending.url.query_params["stringParam"] == "hello" ASSERT pending.url.query_params["numberParam"] == "42" ASSERT pending.url.query_params["boolTrueParam"] == "true" ASSERT pending.url.query_params["boolFalseParam"] == "false" +CLOSE_CLIENT(client) ``` ### RTC1f1 - transportParams override library defaults @@ -488,6 +503,7 @@ pending = AWAIT mock_ws.await_connection_attempt() # User-specified values should override defaults ASSERT pending.url.query_params["v"] == "3" ASSERT pending.url.query_params["heartbeats"] == "false" +CLOSE_CLIENT(client) ``` --- @@ -519,6 +535,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connecting AWAIT client.connection.once(ConnectionEvent.connected) AWAIT_STATE client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -555,6 +572,7 @@ AWAIT_STATE client.connection.state == ConnectionState.closing OR AWAIT client.connection.once(ConnectionEvent.closed) AWAIT_STATE client.connection.state == ConnectionState.closed +CLOSE_CLIENT(client) ``` --- @@ -664,6 +682,7 @@ ASSERT "echo" IN pending.url.query_params # Auth parameters (one of these depending on auth method) ASSERT ("key" IN pending.url.query_params) OR ("accessToken" IN pending.url.query_params) +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/client/realtime_timeouts.md b/uts/realtime/unit/client/realtime_timeouts.md index a406bb87c..07d737ec5 100644 --- a/uts/realtime/unit/client/realtime_timeouts.md +++ b/uts/realtime/unit/client/realtime_timeouts.md @@ -90,6 +90,7 @@ AWAIT attach_future FAILS WITH error ASSERT error IS NOT null # Channel should be in SUSPENDED state (RTL4f: attach timeout → SUSPENDED) ASSERT channel.state == ChannelState.suspended +CLOSE_CLIENT(client) ``` --- @@ -170,6 +171,7 @@ AWAIT detach_future FAILS WITH error ASSERT error IS NOT null # Channel should still be in ATTACHED state (RTL5f: detach timeout → back to ATTACHED) ASSERT channel.state == ChannelState.attached +CLOSE_CLIENT(client) ``` --- @@ -258,6 +260,7 @@ ADVANCE_TIME(1500) ```pseudo # A new reconnection attempt was made after the custom delay ASSERT connection_attempt_count > count_after_immediate +CLOSE_CLIENT(client) ``` --- @@ -285,4 +288,5 @@ ASSERT client.options.disconnectedRetryTimeout == 15000 ASSERT client.options.suspendedRetryTimeout == 30000 ASSERT client.options.httpOpenTimeout == 4000 ASSERT client.options.httpRequestTimeout == 10000 +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/auto_connect_test.md b/uts/realtime/unit/connection/auto_connect_test.md index 681b9f783..2de29c48d 100644 --- a/uts/realtime/unit/connection/auto_connect_test.md +++ b/uts/realtime/unit/connection/auto_connect_test.md @@ -64,6 +64,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Connection was established automatically ASSERT client.connection.state == ConnectionState.connected ASSERT client.connection.id == "connection-id" +CLOSE_CLIENT(client) ``` --- @@ -118,6 +119,7 @@ ASSERT connection_attempted == false # State remains INITIALIZED ASSERT client.connection.state == ConnectionState.initialized +CLOSE_CLIENT(client) ``` --- @@ -178,4 +180,5 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Connection was established after explicit connect() ASSERT connection_attempted == true ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index 08e93ba2d..ebdac35b7 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -79,6 +79,7 @@ ASSERT client.connection.state == ConnectionState.failed ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == 40142 ASSERT client.connection.errorReason.statusCode == 401 +CLOSE_CLIENT(client) ``` --- @@ -199,6 +200,7 @@ ASSERT client.connection.id == first_connection_id # Connection key was updated ASSERT client.connection.key != first_connection_key ASSERT client.connection.key == "key-1-renewed" +CLOSE_CLIENT(client) ``` --- @@ -288,6 +290,7 @@ ASSERT client.connection.state == ConnectionState.disconnected # Error reason is set (from token renewal failure) ASSERT client.connection.errorReason IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -387,6 +390,7 @@ ASSERT connection_attempt_count == 2 # Second connection attempt included resume parameter ASSERT mock_ws.events[1].url.query_params["resume"] == "key-1" +CLOSE_CLIENT(client) ``` --- @@ -459,6 +463,7 @@ ASSERT client.connection.state == ConnectionState.failed ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == 50000 ASSERT client.connection.errorReason.statusCode == 500 +CLOSE_CLIENT(client) ``` --- @@ -552,6 +557,7 @@ ASSERT client.connection.id == original_connection_id # Two connection attempts made ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) ``` --- @@ -645,6 +651,7 @@ ASSERT captured_connection_attempts[1].url.query_params["resume"] == "key-1" # Two connection attempts total ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) ``` --- @@ -739,6 +746,7 @@ ASSERT client.connection.errorReason.code == 80008 # Connection is still CONNECTED (despite error) ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -893,6 +901,7 @@ ASSERT client.connection.key != original_connection_key # Final reconnection URL did NOT include resume parameter # (because TTL expired and connection state was cleared) ASSERT "resume" NOT IN captured_connection_attempts.last.url.query_params +CLOSE_CLIENT(client) ``` --- @@ -1004,6 +1013,7 @@ ASSERT token_request_count == 2 # Initial + renewal # Three connection attempts (initial, failed resume, retry with new token) ASSERT connection_attempt_count == 3 +CLOSE_CLIENT(client) ``` --- @@ -1085,4 +1095,5 @@ ASSERT client.connection.errorReason.code == 50000 # Only two connection attempts (no retry after fatal error) ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/connection_id_key_test.md b/uts/realtime/unit/connection/connection_id_key_test.md index 1571dc052..30f41ab51 100644 --- a/uts/realtime/unit/connection/connection_id_key_test.md +++ b/uts/realtime/unit/connection/connection_id_key_test.md @@ -47,6 +47,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ### Assertions ```pseudo ASSERT client.connection.id == "unique-conn-id-1" +CLOSE_CLIENT(client) ``` --- @@ -87,6 +88,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ### Assertions ```pseudo ASSERT client.connection.key == "conn-key-1" +CLOSE_CLIENT(client) ``` --- @@ -141,6 +143,8 @@ AWAIT_STATE client2.connection.state == ConnectionState.connected ASSERT client1.connection.id != client2.connection.id ASSERT client1.connection.id == "conn-id-1" ASSERT client2.connection.id == "conn-id-2" +CLOSE_CLIENT(client1) +CLOSE_CLIENT(client2) ``` --- @@ -195,6 +199,8 @@ AWAIT_STATE client2.connection.state == ConnectionState.connected ASSERT client1.connection.key != client2.connection.key ASSERT client1.connection.key == "conn-key-1" ASSERT client2.connection.key == "conn-key-2" +CLOSE_CLIENT(client1) +CLOSE_CLIENT(client2) ``` --- @@ -236,6 +242,7 @@ AWAIT_STATE client.connection.state == ConnectionState.closed ### Assertions ```pseudo ASSERT client.connection.id IS null +CLOSE_CLIENT(client) ``` --- @@ -277,6 +284,7 @@ AWAIT_STATE client.connection.state == ConnectionState.closed ### Assertions ```pseudo ASSERT client.connection.key IS null +CLOSE_CLIENT(client) ``` --- @@ -315,6 +323,7 @@ AWAIT_STATE client.connection.state == ConnectionState.failed ```pseudo ASSERT client.connection.id IS null ASSERT client.connection.key IS null +CLOSE_CLIENT(client) ``` --- @@ -357,4 +366,5 @@ AWAIT_STATE client.connection.state == ConnectionState.suspended ```pseudo ASSERT client.connection.id IS null ASSERT client.connection.key IS null +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md index beaec126e..22371a1f5 100644 --- a/uts/realtime/unit/connection/connection_open_failures_test.md +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -72,6 +72,7 @@ ASSERT client.connection.errorReason.statusCode == 400 # Connection ID/key not set ASSERT client.connection.id IS null ASSERT client.connection.key IS null +CLOSE_CLIENT(client) ``` --- @@ -165,15 +166,16 @@ ASSERT token_request_count == 2 # Initial + renewal # Connection was attempted twice ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) ``` --- -## RTN14b - Token error during connection without renewal (RSA4a) +## RSA4a - Token error during connection without renewal -**Spec requirement:** If a token error occurs and the token cannot be renewed, transition to DISCONNECTED state. +**Spec requirement (RSA4a2):** If the server responds with a token error and there is no means to renew the token, the connection transitions to FAILED with error code 40171. -Tests that non-renewable token errors cause disconnection. +Tests that non-renewable token errors cause FAILED state. ### Setup @@ -206,20 +208,21 @@ client = Realtime(options: ClientOptions( # Start connection client.connect() -# Wait for DISCONNECTED state -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Wait for FAILED state (RSA4a2: no means to renew → FAILED) +AWAIT_STATE client.connection.state == ConnectionState.failed WITH timeout: 5 seconds ``` ### Assertions ```pseudo -# Connection transitioned to DISCONNECTED (not FAILED, will retry) -ASSERT client.connection.state == ConnectionState.disconnected +# Connection transitioned to FAILED (RSA4a2: not DISCONNECTED) +ASSERT client.connection.state == ConnectionState.failed -# Error reason is set +# Error reason is set with 40171 (RSA4a2) ASSERT client.connection.errorReason IS NOT null -ASSERT client.connection.errorReason.code == 40142 +ASSERT client.connection.errorReason.code == 40171 +CLOSE_CLIENT(client) ``` --- @@ -278,6 +281,7 @@ ASSERT client.connection.state == ConnectionState.disconnected ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.message CONTAINS "timeout" OR client.connection.errorReason.code IN [50003, 80003] +CLOSE_CLIENT(client) ``` --- @@ -352,6 +356,7 @@ ASSERT client.connection.state == ConnectionState.connected # Two connection attempts were made ASSERT connection_attempt_count == 2 +CLOSE_CLIENT(client) ``` --- @@ -412,6 +417,7 @@ ASSERT client.connection.state == ConnectionState.suspended # Error reason is set (indicates why suspended) ASSERT client.connection.errorReason IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -495,6 +501,7 @@ ASSERT client.connection.state == ConnectionState.connected # Multiple connection attempts were made from SUSPENDED state ASSERT connection_attempt_count >= 3 +CLOSE_CLIENT(client) ``` --- @@ -552,6 +559,7 @@ ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == 50000 ASSERT client.connection.errorReason.statusCode == 500 ASSERT client.connection.errorReason.message == "Internal server error" +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/connection/connection_ping_test.md b/uts/realtime/unit/connection/connection_ping_test.md index b2b6d1299..6bae46d43 100644 --- a/uts/realtime/unit/connection/connection_ping_test.md +++ b/uts/realtime/unit/connection/connection_ping_test.md @@ -71,6 +71,7 @@ heartbeats_sent = mock_ws.events.filter( e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT ) ASSERT heartbeats_sent.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -131,6 +132,7 @@ ASSERT duration >= Duration.zero # The sent HEARTBEAT should have had a non-empty id ASSERT captured_heartbeat_id IS NOT null ASSERT captured_heartbeat_id.length > 0 +CLOSE_CLIENT(client) ``` --- @@ -183,6 +185,7 @@ duration = AWAIT client.connection.ping() # Ping should resolve (ignored the no-id heartbeat, matched the correct one) ASSERT duration IS NOT null ASSERT duration >= Duration.zero +CLOSE_CLIENT(client) ``` --- @@ -245,6 +248,7 @@ ASSERT heartbeats_sent.length == 2 # The two HEARTBEATs should have different ids ASSERT heartbeats_sent[0].message.id != heartbeats_sent[1].message.id +CLOSE_CLIENT(client) ``` --- @@ -294,6 +298,7 @@ error = AWAIT_ERROR ping_future ASSERT error IS NOT null # The error should indicate a timeout ASSERT error.message CONTAINS "timeout" (case insensitive) +CLOSE_CLIENT(client) ``` --- @@ -324,6 +329,7 @@ error = AWAIT_ERROR client.connection.ping() ### Assertions ```pseudo ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -369,6 +375,7 @@ error = AWAIT_ERROR client.connection.ping() ### Assertions ```pseudo ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -410,6 +417,7 @@ error = AWAIT_ERROR client.connection.ping() ### Assertions ```pseudo ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -451,6 +459,7 @@ error = AWAIT_ERROR client.connection.ping() ### Assertions ```pseudo ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -517,6 +526,7 @@ heartbeats_sent = mock_ws.events.filter( e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT ) ASSERT heartbeats_sent.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -596,6 +606,7 @@ heartbeats_sent = mock_ws.events.filter( e => e.type == MESSAGE_FROM_CLIENT AND e.message.action == HEARTBEAT ) ASSERT heartbeats_sent.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -651,6 +662,7 @@ error = AWAIT_ERROR ping_future ### Assertions ```pseudo ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -700,6 +712,7 @@ error = AWAIT_ERROR ping_future ### Assertions ```pseudo ASSERT error IS NOT null +CLOSE_CLIENT(client) ``` --- @@ -757,4 +770,5 @@ error = AWAIT_ERROR ping_future ```pseudo ASSERT error IS NOT null ASSERT error.message CONTAINS "timeout" (case insensitive) +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md index 759ebf463..0975c3cd5 100644 --- a/uts/realtime/unit/connection/error_reason_test.md +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -63,6 +63,7 @@ ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == 40005 ASSERT client.connection.errorReason.statusCode == 400 ASSERT client.connection.errorReason.message == "Invalid API key" +CLOSE_CLIENT(client) ``` --- @@ -110,6 +111,7 @@ ASSERT client.connection.errorReason.message IS NOT null # Error indicates connection failure # (Exact error code/message depends on implementation) +CLOSE_CLIENT(client) ``` --- @@ -168,6 +170,7 @@ ASSERT client.connection.errorReason.message IS NOT null # Error should indicate timeout or suspension reason # (Exact error code/message depends on implementation) +CLOSE_CLIENT(client) ``` --- @@ -222,6 +225,7 @@ ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == 40142 ASSERT client.connection.errorReason.statusCode == 401 ASSERT client.connection.errorReason.message CONTAINS "Token" +CLOSE_CLIENT(client) ``` --- @@ -307,6 +311,7 @@ ASSERT client.connection.errorReason IS null # Or: # B) errorReason is kept but clearly not relevant to current state # (Implementation-specific behavior) +CLOSE_CLIENT(client) ``` --- @@ -361,6 +366,7 @@ ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == 50000 ASSERT client.connection.errorReason.statusCode == 500 ASSERT client.connection.errorReason.message == "Internal server error" +CLOSE_CLIENT(client) ``` --- @@ -431,6 +437,7 @@ ASSERT change.reason.message == "Access token invalid" ASSERT client.connection.errorReason IS NOT null ASSERT client.connection.errorReason.code == change.reason.code ASSERT client.connection.errorReason.message == change.reason.message +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md index 7cd52f05d..c3e7895a6 100644 --- a/uts/realtime/unit/connection/fallback_hosts_test.md +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -109,6 +109,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ASSERT connection_attempts.length >= 1 ASSERT connection_attempts[0].host == "realtime.ably.io" OR connection_attempts[0].host CONTAINS "realtime.ably" # Primary domain +CLOSE_CLIENT(client) ``` --- @@ -176,6 +177,7 @@ ASSERT connection_attempts[0] CONTAINS "realtime.ably" # Second attempt was to a fallback domain ASSERT connection_attempts[1] CONTAINS "fallback" +CLOSE_CLIENT(client) ``` --- @@ -249,6 +251,7 @@ ASSERT connection_attempts.length >= 2 # First was primary, second was fallback ASSERT connection_attempts[0] CONTAINS "realtime.ably" ASSERT connection_attempts[1] CONTAINS "fallback" +CLOSE_CLIENT(client) ``` --- @@ -343,6 +346,7 @@ ASSERT connectivity_checks[0].method == "GET" # Connection attempts proceeded to fallback after check ASSERT connection_attempts.length >= 2 +CLOSE_CLIENT(client) ``` --- @@ -395,6 +399,7 @@ WAIT(2000) # Should have only tried the custom host once, no fallbacks ASSERT connection_attempts.length == 1 ASSERT connection_attempts[0] == "custom.example.com" +CLOSE_CLIENT(client) ``` --- @@ -463,6 +468,7 @@ ASSERT connection_attempts.length >= 2 fallback_host = connection_attempts[1] ASSERT fallback_host CONTAINS "fallback.ably-realtime.com" ASSERT fallback_host MATCHES /\.[abcde]\.fallback\.ably-realtime\.com$/ +CLOSE_CLIENT(client) ``` --- @@ -648,6 +654,7 @@ ASSERT history_host == connected_fallback_host # Or: # B) Same fallback datacenter (e.g., *.b.fallback.* matches) ASSERT EXTRACT_FALLBACK_ID(history_host) == EXTRACT_FALLBACK_ID(connected_fallback_host) +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index f4f5d3a4e..56a5f5b5a 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -86,6 +86,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected ```pseudo # Client should request heartbeats if it cannot observe ping frames ASSERT captured_url.query_params["heartbeats"] == "true" +CLOSE_CLIENT(client) ``` --- @@ -171,6 +172,7 @@ ASSERT client.connection.id == "connection-id-2" # Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -247,6 +249,7 @@ ASSERT connection_attempt_count == 2 # Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -350,6 +353,7 @@ ASSERT connection_attempt_count == 2 # Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -434,6 +438,7 @@ ASSERT client.connection.id == "connection-id-2" # Verify the first connection was closed by the client client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -517,6 +522,7 @@ ASSERT "resume" NOT IN first_url.query_params # Second connection should include resume parameter with first connectionKey second_url = connection_attempts[1].url ASSERT second_url.query_params["resume"] == "connection-key-1" +CLOSE_CLIENT(client) ``` --- @@ -574,6 +580,7 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Client should NOT request heartbeats if it can observe ping frames ASSERT captured_url.query_params["heartbeats"] == "false" OR "heartbeats" NOT IN captured_url.query_params +CLOSE_CLIENT(client) ``` --- @@ -659,6 +666,7 @@ ASSERT client.connection.id == "connection-id-2" # Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -733,6 +741,7 @@ ASSERT connection_attempt_count == 2 # Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -840,6 +849,7 @@ ASSERT connection_attempt_count == 2 # Verify the client closed the first WebSocket connection client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -924,6 +934,7 @@ ASSERT client.connection.id == "connection-id-2" # Verify the first connection was closed by the client client_close_events = mock_ws.events.filter(e => e.type == CLIENT_CLOSE) ASSERT client_close_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -1007,6 +1018,7 @@ ASSERT "resume" NOT IN first_url.query_params # Second connection should include resume parameter with first connectionKey second_url = connection_attempts[1].url ASSERT second_url.query_params["resume"] == "connection-key-1" +CLOSE_CLIENT(client) ``` --- @@ -1063,6 +1075,7 @@ FOR i IN 1..7: ```pseudo # Connection stayed alive through all ping frames ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/connection/server_initiated_reauth_test.md b/uts/realtime/unit/connection/server_initiated_reauth_test.md index 9b2589938..5f96567c1 100644 --- a/uts/realtime/unit/connection/server_initiated_reauth_test.md +++ b/uts/realtime/unit/connection/server_initiated_reauth_test.md @@ -116,6 +116,7 @@ ASSERT connected_to_other.length == 0 # UPDATE event was emitted (RTN24) update_events = state_changes.filter(c => c.event == ConnectionEvent.update) ASSERT update_events.length == 1 +CLOSE_CLIENT(client) ``` --- @@ -204,6 +205,7 @@ ASSERT state_changes.length == 1 ASSERT state_changes[0].event == ConnectionEvent.update ASSERT state_changes[0].current == ConnectionState.connected ASSERT state_changes[0].previous == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -281,6 +283,7 @@ ASSERT disconnected_change.reason.code == 40142 # The client should attempt to reconnect (RTN15h token-error recovery # will obtain a new token and reconnect) +CLOSE_CLIENT(client) ``` ### Note diff --git a/uts/realtime/unit/connection/update_events_test.md b/uts/realtime/unit/connection/update_events_test.md index 4012644c6..803ad39eb 100644 --- a/uts/realtime/unit/connection/update_events_test.md +++ b/uts/realtime/unit/connection/update_events_test.md @@ -108,6 +108,7 @@ ASSERT update_change.reason IS null # No error in this case # Connection details were updated ASSERT client.connection.id == "connection-id-2" ASSERT client.connection.key == "connection-key-2" +CLOSE_CLIENT(client) ``` --- @@ -196,6 +197,7 @@ ASSERT update_change.reason IS NOT null ASSERT update_change.reason.code == 40142 ASSERT update_change.reason.statusCode == 401 ASSERT update_change.reason.message CONTAINS "Token expired" +CLOSE_CLIENT(client) ``` --- @@ -283,6 +285,7 @@ ASSERT client.connection.key == "connection-key-2" # State remains CONNECTED ASSERT client.connection.state == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -381,4 +384,5 @@ FOR EACH event IN new_events: connected_state_events = FILTER all_events WHERE event.type == "state" AND event.state == ConnectionState.connected ASSERT connected_state_events.length == 1 # Only the initial one +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/when_state_test.md b/uts/realtime/unit/connection/when_state_test.md index a446959ba..7715ad5a8 100644 --- a/uts/realtime/unit/connection/when_state_test.md +++ b/uts/realtime/unit/connection/when_state_test.md @@ -74,6 +74,7 @@ ASSERT callback_invoked == true # Callback was invoked with null argument (not a StateChange object) ASSERT callback_arg IS null +CLOSE_CLIENT(client) ``` --- @@ -149,6 +150,7 @@ ASSERT callback_invoked == true ASSERT callback_arg IS NOT null ASSERT callback_arg.previous IN [ConnectionState.initialized, ConnectionState.connecting] ASSERT callback_arg.current == ConnectionState.connected +CLOSE_CLIENT(client) ``` --- @@ -251,6 +253,7 @@ WAIT(50) ```pseudo # Callback was still only invoked once (not again on reconnection) ASSERT callback_count == 1 +CLOSE_CLIENT(client) ``` --- @@ -324,6 +327,7 @@ WAIT(50) ASSERT callback1_invoked == true ASSERT callback2_invoked == true ASSERT callback3_invoked == true +CLOSE_CLIENT(client) ``` --- @@ -388,6 +392,7 @@ WAIT(200) ASSERT callback_invoked == false # This demonstrates whenState checks current state, not historical states +CLOSE_CLIENT(client) ``` --- @@ -460,6 +465,7 @@ WAIT(50) ASSERT initialized_fired == true ASSERT connecting_fired == true ASSERT disconnected_fired == true +CLOSE_CLIENT(client) ``` --- diff --git a/uts/realtime/unit/presence/realtime_presence_channel_state.md b/uts/realtime/unit/presence/realtime_presence_channel_state.md index e70de12b2..b32d5cb0f 100644 --- a/uts/realtime/unit/presence/realtime_presence_channel_state.md +++ b/uts/realtime/unit/presence/realtime_presence_channel_state.md @@ -65,6 +65,8 @@ members = AWAIT channel.presence.get() ASSERT members.length == 1 ASSERT members[0].clientId == "alice" ASSERT channel.presence.syncComplete == true + +CLOSE_CLIENT(client) ``` --- @@ -108,6 +110,8 @@ members = AWAIT channel.presence.get() ```pseudo ASSERT members.length == 0 ASSERT channel.presence.syncComplete == true # Immediately in sync + +CLOSE_CLIENT(client) ``` --- @@ -202,6 +206,8 @@ ASSERT leave_events.any(e => e.clientId == "bob") # LEAVE events have id=null per RTP19a ASSERT leave_events.every(e => e.id IS null) + +CLOSE_CLIENT(client) ``` --- @@ -272,6 +278,8 @@ ASSERT leave_events.length == 0 # Presence map is cleared members_after = channel.presence.get(waitForSync: false) ASSERT members_after.length == 0 + +CLOSE_CLIENT(client) ``` --- @@ -337,6 +345,8 @@ AWAIT_STATE channel.state == ChannelState.failed ```pseudo # RTP5a: No LEAVE events emitted ASSERT leave_events.length == 0 + +CLOSE_CLIENT(client) ``` --- @@ -394,6 +404,8 @@ AWAIT enter_future ASSERT captured_presence.length == 1 ASSERT captured_presence[0].presence[0].action == ENTER ASSERT captured_presence[0].presence[0].data == "queued" + +CLOSE_CLIENT(client) ``` --- @@ -456,6 +468,8 @@ members_during_suspended = channel.presence.get(waitForSync: false) ```pseudo # Members still exist in the map ASSERT members_during_suspended.length == 2 + +CLOSE_CLIENT(client) ``` --- @@ -518,6 +532,8 @@ mock_ws.send_to_client(ProtocolMessage( ### Assertions ```pseudo ASSERT channel.presence.syncComplete == true + +CLOSE_CLIENT(client) ``` --- @@ -556,6 +572,8 @@ ASSERT presence IS NOT null ```pseudo ASSERT channel.presence === channel.presence # identity check — same instance + +CLOSE_CLIENT(client) ``` --- @@ -618,6 +636,8 @@ ASSERT captured_presence.length == 0 # The enter completed with an error ASSERT error IS ErrorInfo ASSERT error.code IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -674,6 +694,8 @@ ASSERT enter_error IS ErrorInfo AWAIT update_future FAILS WITH update_error ASSERT update_error IS ErrorInfo + +CLOSE_CLIENT(client) ``` --- @@ -731,6 +753,8 @@ ASSERT captured_presence.length == 0 # Queued future completed with an error AWAIT enter_future FAILS WITH error ASSERT error IS ErrorInfo + +CLOSE_CLIENT(client) ``` --- @@ -791,4 +815,6 @@ mock_ws.send_to_client(ProtocolMessage( ```pseudo # The enter future resolves successfully — ACK was processed despite channel being DETACHED AWAIT enter_future # should complete without error + +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md index 34f822d57..389d0beb9 100644 --- a/uts/realtime/unit/presence/realtime_presence_enter.md +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -73,6 +73,8 @@ ASSERT captured_presence[0].presence.length == 1 ASSERT captured_presence[0].presence[0].action == ENTER # RTP8c: clientId must NOT be present in the PresenceMessage ASSERT captured_presence[0].presence[0].clientId IS null + +CLOSE_CLIENT(client) ``` --- @@ -119,6 +121,8 @@ AWAIT channel.presence.enter(data: "hello world") ASSERT captured_presence.length == 1 ASSERT captured_presence[0].presence[0].action == ENTER ASSERT captured_presence[0].presence[0].data == "hello world" + +CLOSE_CLIENT(client) ``` --- @@ -163,6 +167,8 @@ AWAIT channel.presence.enter() ### Assertions ```pseudo ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) ``` --- @@ -210,6 +216,8 @@ AWAIT channel.presence.enter() FAILS WITH error ### Assertions ```pseudo ASSERT error IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -250,6 +258,8 @@ AWAIT channel.presence.enter() FAILS WITH error ### Assertions ```pseudo ASSERT error IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -290,6 +300,8 @@ AWAIT channel.presence.enter() FAILS WITH error ### Assertions ```pseudo ASSERT error IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -336,6 +348,8 @@ AWAIT channel.presence.enter() FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code == 40160 + +CLOSE_CLIENT(client) ``` --- @@ -383,6 +397,8 @@ ASSERT captured_presence.length == 1 ASSERT captured_presence[0].presence[0].action == UPDATE ASSERT captured_presence[0].presence[0].data == "new-status" ASSERT captured_presence[0].presence[0].clientId IS null # RTP9d + +CLOSE_CLIENT(client) ``` --- @@ -429,6 +445,8 @@ AWAIT channel.presence.leave() ASSERT captured_presence.length == 1 ASSERT captured_presence[0].presence[0].action == LEAVE ASSERT captured_presence[0].presence[0].clientId IS null # RTP10c + +CLOSE_CLIENT(client) ``` --- @@ -471,6 +489,8 @@ AWAIT channel.presence.leave(data: "goodbye") ```pseudo ASSERT captured_presence[0].presence[0].action == LEAVE ASSERT captured_presence[0].presence[0].data == "goodbye" + +CLOSE_CLIENT(client) ``` --- @@ -525,6 +545,8 @@ ASSERT captured_presence[0].presence[0].data == "alice-data" ASSERT captured_presence[1].presence[0].action == ENTER ASSERT captured_presence[1].presence[0].clientId == "user-bob" ASSERT captured_presence[1].presence[0].data == "bob-data" + +CLOSE_CLIENT(client) ``` --- @@ -581,6 +603,8 @@ ASSERT captured_presence[1].presence[0].data == "updated" ASSERT captured_presence[2].presence[0].action == LEAVE ASSERT captured_presence[2].presence[0].clientId == "user-1" ASSERT captured_presence[2].presence[0].data == "leaving" + +CLOSE_CLIENT(client) ``` --- @@ -622,6 +646,8 @@ AWAIT channel.presence.enterClient("user-1") ### Assertions ```pseudo ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) ``` --- @@ -665,6 +691,8 @@ ASSERT error IS NOT null # Connection and channel remain available ASSERT client.connection.state == ConnectionState.connected ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) ``` --- @@ -708,6 +736,8 @@ AWAIT channel.presence.enter() ```pseudo # Message was sent immediately ASSERT captured_presence.length == 1 + +CLOSE_CLIENT(client) ``` --- @@ -764,6 +794,8 @@ AWAIT enter_future # Queued presence message was sent after attach completed ASSERT captured_presence.length == 1 ASSERT captured_presence[0].presence[0].action == ENTER + +CLOSE_CLIENT(client) ``` --- @@ -809,6 +841,8 @@ AWAIT channel.presence.enter() FAILS WITH error ### Assertions ```pseudo ASSERT error IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -874,6 +908,8 @@ ASSERT captured_presence[1].presence[0].clientId == "other-user" ASSERT captured_presence[2].presence[0].action == LEAVE ASSERT captured_presence[2].presence[0].clientId == "other-user" + +CLOSE_CLIENT(client) ``` --- @@ -992,6 +1028,8 @@ FOR i IN 0..member_count-1: member = members.find(m => m.clientId == "user-${i}") ASSERT member IS NOT null ASSERT member.data == "data-${i}" + +CLOSE_CLIENT(client) ``` --- @@ -1130,4 +1168,7 @@ FOR i IN 0..member_count-1: ASSERT member IS NOT null ASSERT member.data == "data-${i}" ASSERT member.connectionId == "conn-A" + +CLOSE_CLIENT(client_a) +CLOSE_CLIENT(client_b) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_get.md b/uts/realtime/unit/presence/realtime_presence_get.md index 61abc59a4..09e598279 100644 --- a/uts/realtime/unit/presence/realtime_presence_get.md +++ b/uts/realtime/unit/presence/realtime_presence_get.md @@ -78,6 +78,8 @@ members = AWAIT get_future ASSERT members.length == 2 client_ids = members.map(m => m.clientId).sort() ASSERT client_ids == ["alice", "bob"] + +CLOSE_CLIENT(client) ``` --- @@ -156,6 +158,8 @@ members = AWAIT get_future ASSERT members.length == 2 client_ids = members.map(m => m.clientId).sort() ASSERT client_ids == ["alice", "bob"] + +CLOSE_CLIENT(client) ``` --- @@ -212,6 +216,8 @@ members = AWAIT channel.presence.get(waitForSync: false) # Returns what's available so far (may be incomplete) ASSERT members.length == 1 ASSERT members[0].clientId == "alice" + +CLOSE_CLIENT(client) ``` --- @@ -265,6 +271,8 @@ members = AWAIT channel.presence.get(clientId: "alice") # Only alice entries returned (from two different connections) ASSERT members.length == 2 ASSERT members.every(m => m.clientId == "alice") + +CLOSE_CLIENT(client) ``` --- @@ -318,6 +326,8 @@ members = AWAIT channel.presence.get(connectionId: "c1") # Only members from connection c1 (alice and carol) ASSERT members.length == 2 ASSERT members.every(m => m.connectionId == "c1") + +CLOSE_CLIENT(client) ``` --- @@ -362,6 +372,8 @@ members = AWAIT channel.presence.get(waitForSync: false) ```pseudo ASSERT channel.state == ChannelState.attached ASSERT members IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -420,6 +432,8 @@ AWAIT channel.presence.get() FAILS WITH error ```pseudo ASSERT error IS NOT null ASSERT error.code == 91005 + +CLOSE_CLIENT(client) ``` --- @@ -476,4 +490,6 @@ members = AWAIT channel.presence.get(waitForSync: false) ```pseudo ASSERT members.length == 1 ASSERT members[0].clientId == "alice" + +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_history.md b/uts/realtime/unit/presence/realtime_presence_history.md index 7e8f0f65c..8a6660197 100644 --- a/uts/realtime/unit/presence/realtime_presence_history.md +++ b/uts/realtime/unit/presence/realtime_presence_history.md @@ -67,6 +67,8 @@ ASSERT captured_history_requests[0].params.start == 1000 ASSERT captured_history_requests[0].params.end == 2000 ASSERT captured_history_requests[0].params.direction == "backwards" ASSERT captured_history_requests[0].params.limit == 50 + +CLOSE_CLIENT(client) ``` --- @@ -122,4 +124,6 @@ ASSERT result.items.length == 3 ASSERT result.items[0].clientId == "alice" ASSERT result.items[0].action == ENTER ASSERT result.items[2].action == LEAVE + +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_reentry.md b/uts/realtime/unit/presence/realtime_presence_reentry.md index 229d00782..b8a0fb360 100644 --- a/uts/realtime/unit/presence/realtime_presence_reentry.md +++ b/uts/realtime/unit/presence/realtime_presence_reentry.md @@ -103,6 +103,8 @@ ASSERT captured_presence.length >= 1 reenter = captured_presence.find(m => m.presence[0].action == ENTER) ASSERT reenter IS NOT null + +CLOSE_CLIENT(client) ``` --- @@ -203,6 +205,8 @@ ASSERT alice_reentry.data == "alice-data" ASSERT bob_reentry IS NOT null ASSERT bob_reentry.action == ENTER ASSERT bob_reentry.data == "bob-data" + +CLOSE_CLIENT(client) ``` --- @@ -289,6 +293,8 @@ reentry_presence = reentry.presence[0] ASSERT reentry_presence.action == ENTER ASSERT reentry_presence.id IS null # RTP17g1: id not set when connectionId changed ASSERT reentry_presence.data == "hello" + +CLOSE_CLIENT(client) ``` --- @@ -362,6 +368,8 @@ mock_ws.send_to_client(ProtocolMessage( ```pseudo # No re-entry — RESUMED flag means the server still has our presence state ASSERT captured_presence.length == 0 + +CLOSE_CLIENT(client) ``` --- @@ -464,6 +472,8 @@ ASSERT update_event.reason.code == 91004 ASSERT update_event.reason.message CONTAINS "my-client" ASSERT update_event.reason.cause IS NOT null ASSERT update_event.reason.cause.code == 40160 + +CLOSE_CLIENT(client) ``` --- @@ -532,4 +542,6 @@ members = channel.presence.get(waitForSync: false) ```pseudo ASSERT members.length == 1 ASSERT members[0].clientId == "my-client" + +CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/presence/realtime_presence_subscribe.md b/uts/realtime/unit/presence/realtime_presence_subscribe.md index 9cb4d370d..29418565c 100644 --- a/uts/realtime/unit/presence/realtime_presence_subscribe.md +++ b/uts/realtime/unit/presence/realtime_presence_subscribe.md @@ -86,6 +86,8 @@ ASSERT received_events[0].clientId == "alice" ASSERT received_events[1].action == UPDATE ASSERT received_events[1].data == "updated" ASSERT received_events[2].action == LEAVE + +CLOSE_CLIENT(client) ``` --- @@ -152,6 +154,8 @@ ASSERT leave_events.length == 1 ASSERT leave_events[0].action == LEAVE # Neither listener receives UPDATE + +CLOSE_CLIENT(client) ``` --- @@ -205,6 +209,8 @@ mock_ws.send_to_client(ProtocolMessage( ASSERT enter_leave_events.length == 2 ASSERT enter_leave_events[0].action == ENTER ASSERT enter_leave_events[1].action == LEAVE + +CLOSE_CLIENT(client) ``` --- @@ -251,6 +257,8 @@ AWAIT_STATE channel.state == ChannelState.attached ```pseudo ASSERT attach_count == 1 ASSERT channel.state == ChannelState.attached + +CLOSE_CLIENT(client) ``` --- @@ -293,6 +301,8 @@ channel.presence.subscribe((event) => {}) # Channel stays in INITIALIZED — no implicit attach ASSERT channel.state == ChannelState.initialized ASSERT attach_count == 0 + +CLOSE_CLIENT(client) ``` --- @@ -359,6 +369,8 @@ mock_ws.send_to_client(ProtocolMessage( ```pseudo ASSERT events_a.length == 1 # No new events after unsubscribe ASSERT events_b.length == 1 + +CLOSE_CLIENT(client) ``` --- @@ -416,6 +428,8 @@ mock_ws.send_to_client(ProtocolMessage( ```pseudo ASSERT events_a.length == 0 # Unsubscribed — no events ASSERT events_b.length == 1 # Still subscribed — receives event + +CLOSE_CLIENT(client) ``` --- @@ -473,6 +487,8 @@ mock_ws.send_to_client(ProtocolMessage( # Only LEAVE received — ENTER subscription was removed ASSERT received.length == 1 ASSERT received[0].action == LEAVE + +CLOSE_CLIENT(client) ``` --- @@ -525,6 +541,8 @@ ASSERT members.length == 1 ASSERT members[0].clientId == "alice" ASSERT members[0].data == "hello" ASSERT members[0].action == PRESENT # Stored as PRESENT per RTP2d2 + +CLOSE_CLIENT(client) ``` --- @@ -577,4 +595,6 @@ ASSERT received.length == 3 ASSERT received[0].clientId == "alice" ASSERT received[1].clientId == "bob" ASSERT received[2].clientId == "carol" + +CLOSE_CLIENT(client) ``` From 1803464694c484cf0689f1d1b0fb905477abb7d3 Mon Sep 17 00:00:00 2001 From: Paddy Byers Date: Sat, 2 May 2026 14:43:42 +0100 Subject: [PATCH 32/32] UTS spec corrections from ably-js audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply fixes and implementation notes across 22 realtime UTS spec files based on findings from translating specs to ably-js tests. Key changes: - Fix presence assertions (action == ENTER → PRESENT per RTP2d2) - Add base64 encoding notes for JSON transport in delta decoding specs - Fix state change filtering in auth reauth tests (RTC8a/RTC8a1) - Add connectionStateTtl and disconnectedRetryTimeout to specs that transition through SUSPENDED or need fake timer precision - Add implementation notes for echo suppression, NACK alternatives, clientId:* restrictions, and REST API numeric action values - Add missing test sections (RTN14b, RTL5l, RTL5i notes) - Relax overly specific error code assertions (RSAN1a3) Co-Authored-By: Claude Opus 4.6 --- .../unit/auth/connection_auth_test.md | 268 ++++++++++++++++-- uts/realtime/unit/auth/realtime_authorize.md | 17 +- .../unit/channels/channel_annotations.md | 2 +- uts/realtime/unit/channels/channel_attach.md | 106 +++++-- .../unit/channels/channel_attributes.md | 24 +- .../unit/channels/channel_connection_state.md | 52 +++- .../unit/channels/channel_delta_decoding.md | 9 + uts/realtime/unit/channels/channel_detach.md | 140 ++++----- uts/realtime/unit/channels/channel_error.md | 6 +- .../unit/channels/channel_properties.md | 4 + .../channel_server_initiated_detach.md | 22 +- .../unit/channels/channel_state_events.md | 4 +- .../unit/channels/channel_subscribe.md | 6 + .../connection/connection_failures_test.md | 25 ++ .../connection_open_failures_test.md | 60 ++++ .../unit/connection/error_reason_test.md | 18 +- .../unit/connection/fallback_hosts_test.md | 4 + .../unit/connection/heartbeat_test.md | 62 ++++ .../unit/presence/local_presence_map.md | 16 +- .../unit/presence/realtime_presence_enter.md | 30 ++ .../unit/presence/realtime_presence_get.md | 20 +- .../presence/realtime_presence_history.md | 4 + 22 files changed, 734 insertions(+), 165 deletions(-) diff --git a/uts/realtime/unit/auth/connection_auth_test.md b/uts/realtime/unit/auth/connection_auth_test.md index 608bf1119..1e58149ed 100644 --- a/uts/realtime/unit/auth/connection_auth_test.md +++ b/uts/realtime/unit/auth/connection_auth_test.md @@ -1,6 +1,6 @@ # Realtime Connection Authentication Tests -Spec points: `RTN2e`, `RTN27b`, `RSA4`, `RSA8d`, `RSA12a` +Spec points: `RTN2e`, `RTN27b`, `RSA4`, `RSA4c`, `RSA4c1`, `RSA4c2`, `RSA4c3`, `RSA4d`, `RSA8d`, `RSA12a` ## Test Type Unit test with mocked WebSocket client and authCallback @@ -292,23 +292,27 @@ CLOSE_CLIENT(client) --- -## RTN2e - Expired token triggers new authCallback invocation +## RSA4c2 - authCallback error during CONNECTING causes DISCONNECTED -**Spec requirement:** If the cached token has expired, `authCallback` must be invoked again to obtain a fresh token before connecting. +**Spec requirement (RSA4c):** If an attempt to authenticate using authCallback results in an error, then: +- **(RSA4c1)** An ErrorInfo with code 80019, statusCode 401, and cause set to the underlying cause should be emitted and set as the connection errorReason. +- **(RSA4c2)** If the connection is CONNECTING, the connection attempt should be treated as unsuccessful, transitioning to DISCONNECTED. -Tests that expired tokens trigger re-authentication. +Tests that when authCallback fails during initial connection, the client transitions to DISCONNECTED with error code 80019, and the underlying cause is preserved. ### Setup - ```pseudo -callback_count = 0 +auth_callback_count = 0 auth_callback = FUNCTION(params): - callback_count++ - RETURN TokenDetails( - token: "token-" + callback_count, - expires: now() + 100 # Expires in 100ms - ) + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + ELSE: + RETURN TokenDetails( + token: "valid-token-" + str(auth_callback_count), + expires: now() + 3600000 + ) mock_ws = MockWebSocket( onConnectionAttempt: (conn) => { @@ -316,7 +320,12 @@ mock_ws = MockWebSocket( conn.send_to_client(ProtocolMessage( action: CONNECTED, connectionId: "connection-id", - connectionKey: "connection-key" + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) )) } ) @@ -329,29 +338,242 @@ client = Realtime(options: ClientOptions( ``` ### Test Steps +```pseudo +client.connect() + +# authCallback fails on first attempt — connection should go to DISCONNECTED +AWAIT_STATE client.connection.state == ConnectionState.disconnected + WITH timeout: 5 seconds +``` +### Assertions +```pseudo +# RSA4c1: errorReason has code 80019 wrapping the underlying cause +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 50000 +CLOSE_CLIENT(client) +``` + +--- + +## RSA4c3 - authCallback error while CONNECTED leaves connection CONNECTED + +**Spec requirement (RSA4c3):** If the connection is CONNECTED when an auth attempt fails, then the connection should remain CONNECTED. + +Tests that when authCallback fails during an RTN22 server-initiated reauth, the connection stays CONNECTED and errorReason is set with code 80019. + +### Setup +```pseudo +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Subsequent calls fail (reauth) + THROW ErrorInfo(code: 50000, statusCode: 500, message: "Auth server unavailable") + +captured_auth_messages = [] + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps ```pseudo -# First connection client.connect() AWAIT_STATE client.connection.state == ConnectionState.connected -# Disconnect -client.close() -AWAIT_STATE client.connection.state == ConnectionState.closed +# Record state changes +state_changes = [] +client.connection.on((change) => state_changes.append(change)) + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# Wait for errorReason to be set (auth failure propagates asynchronously) +AWAIT UNTIL client.connection.errorReason IS NOT null + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# RSA4c3: Connection remains CONNECTED +ASSERT client.connection.state == ConnectionState.connected + +# No state transitions away from connected occurred +non_connected_changes = state_changes.filter( + c => c.current != ConnectionState.connected +) +ASSERT non_connected_changes.length == 0 + +# RSA4c1: errorReason has code 80019 wrapping the underlying cause +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 401 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 50000 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback 403 error causes FAILED + +**Spec requirement (RSA4d):** If an authCallback results in an ErrorInfo with statusCode 403, the client library should transition to the FAILED state, with an ErrorInfo (code 80019, statusCode 403, cause set to the underlying cause). -# Wait for token to expire -WAIT 200ms +Tests that a 403 from authCallback is treated as fatal and causes FAILED state. -# Second connection (token expired, should get new one) +### Setup +```pseudo +auth_callback = FUNCTION(params): + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account disabled") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo client.connect() -AWAIT_STATE client.connection.state == ConnectionState.connected + +# authCallback returns 403 — connection should go to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds ``` ### Assertions +```pseudo +# RSA4d: FAILED with code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 + +CLOSE_CLIENT(client) +``` + +--- + +## RSA4d - authCallback 403 during RTN22 reauth causes FAILED + +**Spec requirement (RSA4d):** If an authCallback results in an ErrorInfo with statusCode 403 during an attempt to re-authenticate, the connection transitions to FAILED. + +Tests that a 403 from authCallback during server-initiated reauth (RTN22) causes FAILED, even though the connection was previously CONNECTED. +### Setup ```pseudo -# authCallback was invoked twice (once per connection due to expiry) -ASSERT callback_count == 2 +auth_callback_count = 0 + +auth_callback = FUNCTION(params): + auth_callback_count = auth_callback_count + 1 + IF auth_callback_count == 1: + # First call succeeds (initial connection) + RETURN TokenDetails( + token: "initial-token", + expires: now() + 3600000 + ) + ELSE: + # Reauth fails with 403 + THROW ErrorInfo(code: 40300, statusCode: 403, message: "Account suspended") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => { + conn.respond_with_success() + conn.send_to_client(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Server requests re-authentication (RTN22) +mock_ws.active_connection.send_to_client(ProtocolMessage( + action: AUTH +)) + +# authCallback returns 403 — connection should go to FAILED +AWAIT_STATE client.connection.state == ConnectionState.failed + WITH timeout: 5 seconds +``` + +### Assertions +```pseudo +# RSA4d: FAILED with code 80019 and statusCode 403 +ASSERT client.connection.errorReason IS NOT null +ASSERT client.connection.errorReason.code == 80019 +ASSERT client.connection.errorReason.statusCode == 403 +ASSERT client.connection.errorReason.cause IS NOT null +ASSERT client.connection.errorReason.cause.code == 40300 + CLOSE_CLIENT(client) ``` @@ -359,6 +581,6 @@ CLOSE_CLIENT(client) ## Notes -These tests verify the **pre-connection** token acquisition flow. For token **renewal** after connection failures (e.g., 401 errors from server), see: +These tests verify the **pre-connection** token acquisition flow and **auth failure handling** during the connection lifecycle. For token **renewal** after connection failures (e.g., 401 errors from server), see: - `../connection/connection_open_failures_test.md` (RTN14b) - `../connection/connection_failures_test.md` (RTN15h2) diff --git a/uts/realtime/unit/auth/realtime_authorize.md b/uts/realtime/unit/auth/realtime_authorize.md index f8a2315d9..b13199547 100644 --- a/uts/realtime/unit/auth/realtime_authorize.md +++ b/uts/realtime/unit/auth/realtime_authorize.md @@ -109,8 +109,9 @@ ASSERT captured_auth_messages[0].auth.accessToken == "token-2" # authorize() resolved with the new token ASSERT token_details.token == "token-2" -# No state changes occurred — connection stayed CONNECTED throughout -ASSERT state_changes.length == 0 +# UPDATE events are emitted but are not state transitions (current == previous == connected) +state_transitions = state_changes.filter(c => c.current != c.previous) +ASSERT state_transitions.length == 0 ASSERT client.connection.state == ConnectionState.connected CLOSE_CLIENT(client) ``` @@ -206,10 +207,15 @@ ASSERT update_events[0].current == ConnectionState.connected # No additional CONNECTED state event was emitted ASSERT connected_events.length == 0 -# No state changes occurred (stayed CONNECTED throughout) -ASSERT state_changes.length == 0 +# UPDATE events are emitted but are not state transitions (current == previous == connected) +state_transitions = state_changes.filter(c => c.current != c.previous) +ASSERT state_transitions.length == 0 # Connection details were updated (RTN21) +# Note: Whether connection.id is updated from the reauth CONNECTED message +# is implementation-dependent. Some SDKs only set connection.id during initial +# transport activation. connection.key (via connectionDetails) MUST be updated +# per RTN21. ASSERT client.connection.id == "connection-id-2" ASSERT client.connection.key == "connection-key-2" CLOSE_CLIENT(client) @@ -299,6 +305,9 @@ mock_ws.on_client_message((msg) => { ) )) # Then server sends channel-level ERROR (capability downgrade) + # Implementation note: The channel-level ERROR must be delivered AFTER the + # CONNECTED message has been fully processed. Implementations may need to + # defer the ERROR delivery (e.g., via microtask/nextTick) to ensure ordering. mock_ws.active_connection.send_to_client(ProtocolMessage( action: ERROR, channel: "private-channel", diff --git a/uts/realtime/unit/channels/channel_annotations.md b/uts/realtime/unit/channels/channel_annotations.md index c4cebea83..4c9e64384 100644 --- a/uts/realtime/unit/channels/channel_annotations.md +++ b/uts/realtime/unit/channels/channel_annotations.md @@ -152,7 +152,7 @@ AWAIT channel.attach() AWAIT channel.annotations.publish("msg-serial-1", Annotation( name: "like" )) FAILS WITH error -ASSERT error.code == 40003 +ASSERT error IS NOT null # Error code is implementation-defined; RSAN1a3 does not mandate a specific code CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_attach.md b/uts/realtime/unit/channels/channel_attach.md index d26fea9e7..ad59ccb8a 100644 --- a/uts/realtime/unit/channels/channel_attach.md +++ b/uts/realtime/unit/channels/channel_attach.md @@ -192,11 +192,11 @@ CLOSE_CLIENT(client) --- -## RTL4g - Attach from failed state clears errorReason +## RTL4g - Attach from failed state proceeds with attach -**Spec requirement:** If the channel is in the FAILED state, the attach request sets its errorReason to null, and proceeds with a channel attach. +**Spec requirement:** If the channel is in the FAILED state, the attach request proceeds with a channel attach described in RTL4b, RTL4i and RTL4c. -Tests that attaching from failed state clears the error and attempts attach. +Tests that a channel in the FAILED state can be re-attached. errorReason clearing is verified as part of the RTL4c behavior (successful attach clears errorReason). ### Setup ```pseudo @@ -246,6 +246,86 @@ AWAIT channel.attach() ### Assertions ```pseudo ASSERT channel.state == ChannelState.attached +# RTL4c: successful attach clears errorReason +ASSERT channel.errorReason IS null +CLOSE_CLIENT(client) +``` + +--- + +## RTL4c - Successful attach clears errorReason + +**Spec requirement:** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. + +Tests that errorReason is cleared on any successful attach, not just from the FAILED state. This test uses a SUSPENDED channel (which has errorReason set from a previous error) to verify the clearing applies to all successful attaches. + +### Setup +```pseudo +channel_name = "test-RTL4c-error-clear-${random_id()}" + +enable_fake_timers() + +late mock_ws +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onMessageFromClient: (msg) => { + IF msg.action == ATTACH: + mock_ws.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) + } +) +install_mock(mock_ws) + +client = Realtime(options: ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false, + fallbackHosts: [], + suspendedRetryTimeout: 2000 +)) +channel = client.channels.get(channel_name) +``` + +### Test Steps +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +AWAIT channel.attach() +ASSERT channel.state == ChannelState.attached + +# Simulate disconnect — push connection through to SUSPENDED +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() +mock_ws.active_connection.simulate_disconnect() + +LOOP up to 30 times: + ADVANCE_TIME(5000) + IF client.connection.state == ConnectionState.suspended: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.suspended +ASSERT channel.state == ChannelState.suspended + +# Channel should have errorReason set from the connection failure +ASSERT channel.errorReason IS NOT null + +# Allow reconnection to succeed +mock_ws.onConnectionAttempt = (conn) => conn.respond_with_success(CONNECTED_MESSAGE) + +LOOP up to 10 times: + ADVANCE_TIME(2500) + IF client.connection.state == ConnectionState.connected: + BREAK + +AWAIT_STATE client.connection.state == ConnectionState.connected +AWAIT_STATE channel.state == ChannelState.attached +``` + +### Assertions +```pseudo +# errorReason cleared by the successful attach (RTL4c) +ASSERT channel.state == ChannelState.attached ASSERT channel.errorReason IS null CLOSE_CLIENT(client) ``` @@ -351,7 +431,7 @@ channel_name = "test-RTL4b-suspended-${random_id()}" client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, - suspendedRetryTimeout: 100 # Short timeout for testing + channelRetryTimeout: 100 # Short timeout for testing )) channel = client.channels.get(channel_name) @@ -792,9 +872,9 @@ CLOSE_CLIENT(client) ## RTL4j - ATTACH_RESUME flag set for reattach -**Spec requirement:** If the attach is not a clean attach, the library should set the ATTACH_RESUME flag in the ATTACH message. +**Spec requirement:** If the attach is not a clean attach, the library should set the ATTACH_RESUME flag in the ATTACH message. Per RTL4j1, `attachResume` is cleared when the channel enters DETACHING or FAILED, so a detach+reattach IS a clean attach and should NOT have ATTACH_RESUME. A reattach while still attached (e.g. via setOptions) is NOT a clean attach and SHOULD have ATTACH_RESUME. -Tests that ATTACH_RESUME flag is set on reattachment. +Tests that ATTACH_RESUME flag is set on reattach while attached, but not on a clean attach. ### Setup ```pseudo @@ -810,11 +890,6 @@ mock_ws = MockWebSocket( action: ATTACHED, channel: channel_name )) - ELSE IF msg.action == DETACH: - mock_ws.send_to_client(ProtocolMessage( - action: DETACHED, - channel: channel_name - )) } ) install_mock(mock_ws) @@ -831,11 +906,8 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # First attach - clean attach AWAIT channel.attach() -# Detach -AWAIT channel.detach() - -# Reattach - should have ATTACH_RESUME flag -AWAIT channel.attach() +# Reattach while still attached (via setOptions) — not a clean attach +AWAIT channel.setOptions(params: {rewind: "1"}) ``` ### Assertions @@ -843,7 +915,7 @@ AWAIT channel.attach() ASSERT length(captured_attach_messages) == 2 # First attach should NOT have ATTACH_RESUME flag ASSERT (captured_attach_messages[0].flags AND 32) == 0 # ATTACH_RESUME = 32 -# Second attach SHOULD have ATTACH_RESUME flag +# Second attach (reattach while attached) SHOULD have ATTACH_RESUME flag ASSERT (captured_attach_messages[1].flags AND 32) != 0 # ATTACH_RESUME = 32 CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/channels/channel_attributes.md b/uts/realtime/unit/channels/channel_attributes.md index 2c1c87f3f..ccd002b1d 100644 --- a/uts/realtime/unit/channels/channel_attributes.md +++ b/uts/realtime/unit/channels/channel_attributes.md @@ -187,10 +187,9 @@ CLOSE_CLIENT(client) --- -## RTL24 - errorReason cleared on successful attach +## RTL4c/RTL24 - errorReason cleared on successful attach -**Spec requirement:** The errorReason should be cleared when the channel -successfully attaches or reattaches. +**Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. Tests that errorReason is reset to null after a successful attach following a previous error. @@ -270,21 +269,18 @@ CLOSE_CLIENT(client) --- -## RTL24 - errorReason cleared on successful detach +## RTL4c/RTL24 - errorReason cleared on successful attach, preserved through detach -**Spec requirement:** The errorReason should be cleared when the channel -successfully detaches. +**Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. -Tests that errorReason is reset to null after a successful detach, even if -the channel previously had an error. +Tests that after an error puts the channel in FAILED, a successful re-attach +clears errorReason (RTL4c), and a subsequent detach preserves the null value +(detach does not set errorReason). Note: To reliably set errorReason, we use an ERROR ProtocolMessage (which -transitions the channel to FAILED via RTL14). An ATTACHED-while-already-ATTACHED -message (UPDATE) emits a ChannelStateChange event with the error, but -implementations may not persist it to the errorReason attribute — only state -transitions via RTL14 or RTL4g reliably set errorReason. After the ERROR puts -the channel in FAILED, we reattach (which clears errorReason), then verify -detach also leaves errorReason null. +transitions the channel to FAILED via RTL14). After the ERROR puts +the channel in FAILED, we reattach (which clears errorReason via RTL4c), +then verify detach leaves errorReason null. ### Setup ```pseudo diff --git a/uts/realtime/unit/channels/channel_connection_state.md b/uts/realtime/unit/channels/channel_connection_state.md index 3107f0058..e86e41844 100644 --- a/uts/realtime/unit/channels/channel_connection_state.md +++ b/uts/realtime/unit/channels/channel_connection_state.md @@ -454,7 +454,16 @@ channel_name = "test-RTL3c-${random_id()}" enable_fake_timers() mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )), onMessageFromClient: (msg) => { IF msg.action == ATTACH: mock_ws.send_to_client(ProtocolMessage( @@ -468,7 +477,8 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, - fallbackHosts: [] + fallbackHosts: [], + disconnectedRetryTimeout: 1000 )) channel = client.channels.get(channel_name) ``` @@ -489,7 +499,9 @@ channel.on().listen((change) => channel_state_changes.append(change)) mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() mock_ws.active_connection.simulate_disconnect() -# Advance time past connectionStateTtl (default 120s) to reach SUSPENDED +# Connection must exhaust disconnectedRetryTimeout retries within connectionStateTtl +# to transition from DISCONNECTED to SUSPENDED. The total time advance must exceed +# connectionStateTtl (from connectionDetails, per RTN21). LOOP up to 30 times: ADVANCE_TIME(5000) IF client.connection.state == ConnectionState.suspended: @@ -523,7 +535,16 @@ channel_name = "test-RTL3c-attaching-${random_id()}" enable_fake_timers() mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )), onMessageFromClient: (msg) => { IF msg.action == ATTACH: # Do NOT respond - leave channel in ATTACHING state @@ -535,7 +556,8 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, - fallbackHosts: [] + fallbackHosts: [], + disconnectedRetryTimeout: 1000 )) channel = client.channels.get(channel_name) ``` @@ -557,7 +579,9 @@ channel.on().listen((change) => channel_state_changes.append(change)) mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() mock_ws.active_connection.simulate_disconnect() -# Advance time past connectionStateTtl (default 120s) to reach SUSPENDED +# Connection must exhaust disconnectedRetryTimeout retries within connectionStateTtl +# to transition from DISCONNECTED to SUSPENDED. The total time advance must exceed +# connectionStateTtl (from connectionDetails, per RTN21). LOOP up to 30 times: ADVANCE_TIME(5000) IF client.connection.state == ConnectionState.suspended: @@ -672,7 +696,16 @@ enable_fake_timers() late mock_ws mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onConnectionAttempt: (conn) => conn.respond_with_success(ProtocolMessage( + action: CONNECTED, + connectionId: "connection-id", + connectionKey: "connection-key", + connectionDetails: ConnectionDetails( + connectionKey: "connection-key", + maxIdleInterval: 15000, + connectionStateTtl: 120000 + ) + )), onMessageFromClient: (msg) => { IF msg.action == ATTACH: attach_message_count++ @@ -688,6 +721,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, fallbackHosts: [], + disconnectedRetryTimeout: 1000, suspendedRetryTimeout: 2000 )) channel = client.channels.get(channel_name) @@ -706,7 +740,9 @@ ASSERT attach_message_count == 1 mock_ws.onConnectionAttempt = (conn) => conn.respond_with_refused() mock_ws.active_connection.simulate_disconnect() -# Advance time past connectionStateTtl to reach SUSPENDED +# Connection must exhaust disconnectedRetryTimeout retries within connectionStateTtl +# to transition from DISCONNECTED to SUSPENDED. The total time advance must exceed +# connectionStateTtl (from connectionDetails, per RTN21). LOOP up to 30 times: ADVANCE_TIME(5000) IF client.connection.state == ConnectionState.suspended: diff --git a/uts/realtime/unit/channels/channel_delta_decoding.md b/uts/realtime/unit/channels/channel_delta_decoding.md index 316e2abb5..28c2bbde2 100644 --- a/uts/realtime/unit/channels/channel_delta_decoding.md +++ b/uts/realtime/unit/channels/channel_delta_decoding.md @@ -13,6 +13,15 @@ See `uts/test/realtime/unit/helpers/mock_websocket.md` for the full Mock WebSock See `uts/test/realtime/unit/helpers/mock_vcdiff.md` for the full Mock VCDiff Infrastructure specification. +> **Transport encoding note:** On JSON transport (the default for unit tests), +> binary vcdiff delta payloads cannot be transmitted as raw bytes — they must be +> base64-encoded. Mock message constructions in these tests use raw data with +> `encoding: "vcdiff"` for clarity. Implementations using JSON transport should +> adapt mock messages to use `base64_encode(delta)` as the data, with `/base64` +> appended to the encoding field (e.g., `"vcdiff/base64"` or `"utf-8/vcdiff/base64"`). +> The SDK's decoding pipeline processes encoding steps right-to-left: base64-decode +> first, then apply vcdiff, then decode utf-8 if present. + --- ## RTL21 - Messages in array decoded in ascending index order diff --git a/uts/realtime/unit/channels/channel_detach.md b/uts/realtime/unit/channels/channel_detach.md index 2d0b55fd8..c62f0f45b 100644 --- a/uts/realtime/unit/channels/channel_detach.md +++ b/uts/realtime/unit/channels/channel_detach.md @@ -176,6 +176,13 @@ CLOSE_CLIENT(client) Tests that calling detach while attaching waits for attach to complete, then detaches. +> **Implementation note:** When detach is called while the channel is ATTACHING, +> the attach future/promise may be rejected in some implementations (since the +> intent has changed to detach). Other implementations may resolve the attach +> future when ATTACHED arrives, before proceeding to detach. Both behaviors are +> acceptable — implementations should handle both outcomes and suppress unhandled +> rejection errors from the superseded attach operation. + ### Setup ```pseudo channel_name = "test-RTL5i-attaching-${random_id()}" @@ -392,6 +399,71 @@ CLOSE_CLIENT(client) --- +### RTL5l - Detach ATTACHED channel when connection disconnected + +When an ATTACHED channel is detached while the connection is DISCONNECTED, +the channel transitions directly to DETACHED without sending a DETACH message +(since the transport is unavailable). + +#### Setup + +```pseudo +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => + mock_ws.active_connection = conn + conn.respond_with_connected() +) +install_mock(mock_ws) + +client = create_realtime_client(ClientOptions( + key: "appId.keyId:keySecret", + autoConnect: false +)) + +channel = client.channels.get("test-channel") +``` + +#### Test Steps + +```pseudo +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.connected + +# Attach the channel +mock_ws.onMessageFromClient = (msg) => + IF msg.action == ATTACH: + mock_ws.active_connection.send_to_client(ProtocolMessage( + action: ATTACHED, + channel: msg.channel + )) +channel.attach() +AWAIT_STATE channel.state == ChannelState.attached + +# Disconnect the transport +mock_ws.active_connection.simulate_disconnect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected + +# Now detach while disconnected +messages_sent = [] +mock_ws.onMessageFromClient = (msg) => messages_sent.push(msg) + +channel.detach() +AWAIT_STATE channel.state == ChannelState.detached +``` + +#### Assertions + +```pseudo +# Channel transitions directly to DETACHED +ASSERT channel.state == ChannelState.detached + +# No DETACH message was sent (transport is unavailable) +detach_messages = messages_sent.filter(m => m.action == DETACH) +ASSERT detach_messages.length == 0 +``` + +--- + ## RTL5d - Normal detach flow **Spec requirement:** A DETACH ProtocolMessage is sent to the server, the state transitions to DETACHING and the channel becomes DETACHED when the confirmation DETACHED ProtocolMessage is received. @@ -622,7 +694,7 @@ mock_ws.send_to_client(ProtocolMessage( )) # Wait for client to respond -AWAIT Future.delayed(Duration(milliseconds: 100)) +WAIT 100ms ``` ### Assertions @@ -697,68 +769,6 @@ CLOSE_CLIENT(client) --- -## RTL5 - Detach clears errorReason - -**Spec requirement:** Successful detach should clear any previous error. - -Tests that errorReason is cleared after successful detach. - -### Setup -```pseudo -channel_name = "test-RTL5-error-${random_id()}" -attach_count = 0 +## [REMOVED] RTL5 - Detach clears errorReason -mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), - onMessageFromClient: (msg) => { - IF msg.action == ATTACH: - attach_count++ - IF attach_count == 1: - # First attach fails - mock_ws.send_to_client(ProtocolMessage( - action: ERROR, - channel: channel_name, - error: ErrorInfo(code: 40160, message: "Denied") - )) - ELSE: - mock_ws.send_to_client(ProtocolMessage( - action: ATTACHED, - channel: channel_name - )) - ELSE IF msg.action == DETACH: - mock_ws.send_to_client(ProtocolMessage( - action: DETACHED, - channel: channel_name - )) - } -) -install_mock(mock_ws) - -client = Realtime(options: ClientOptions(key: "appId.keyId:keySecret", autoConnect: false)) -channel = client.channels.get(channel_name) -``` - -### Test Steps -```pseudo -client.connect() -AWAIT_STATE client.connection.state == ConnectionState.connected - -# First attach fails -AWAIT channel.attach() FAILS WITH error -ASSERT channel.state == ChannelState.failed -ASSERT channel.errorReason IS NOT null - -# Attach again succeeds -AWAIT channel.attach() -ASSERT channel.state == ChannelState.attached - -# Detach -AWAIT channel.detach() -``` - -### Assertions -```pseudo -ASSERT channel.state == ChannelState.detached -ASSERT channel.errorReason IS null -CLOSE_CLIENT(client) -``` +**This test has been removed.** The features spec (RTL5a through RTL5l) does not specify that detach clears errorReason. Channel errorReason is cleared by a successful attach (RTL4c) and by connection reconnect (RTN11d). Detach is not among them. The original test scenario (FAILED → re-attach → detach → assert null) was accidentally correct because the re-attach cleared errorReason via RTL4c, not because detach did anything. diff --git a/uts/realtime/unit/channels/channel_error.md b/uts/realtime/unit/channels/channel_error.md index 58287f851..8d7a8f37f 100644 --- a/uts/realtime/unit/channels/channel_error.md +++ b/uts/realtime/unit/channels/channel_error.md @@ -309,7 +309,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, realtimeRequestTimeout: 100, - suspendedRetryTimeout: 200 + channelRetryTimeout: 200 )) channel = client.channels.get(channel_name) ``` @@ -333,7 +333,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( ADVANCE_TIME(150) AWAIT_STATE channel.state == ChannelState.suspended -# Channel retry timer is now pending (suspendedRetryTimeout = 200ms) +# Channel retry timer is now pending (channelRetryTimeout = 200ms) # Send ERROR before the retry fires mock_ws.active_connection.send_to_client(ProtocolMessage( action: ERROR, @@ -344,7 +344,7 @@ AWAIT_STATE channel.state == ChannelState.failed attach_count_after_error = attach_count -# Advance time well past the suspendedRetryTimeout +# Advance time well past the channelRetryTimeout ADVANCE_TIME(500) ``` diff --git a/uts/realtime/unit/channels/channel_properties.md b/uts/realtime/unit/channels/channel_properties.md index 6995fff3c..25747eff3 100644 --- a/uts/realtime/unit/channels/channel_properties.md +++ b/uts/realtime/unit/channels/channel_properties.md @@ -191,6 +191,10 @@ CLOSE_CLIENT(client) Tests that receiving MESSAGE and PRESENCE protocol messages with a `channelSerial` field updates the channel's `channelSerial` property. +# Implementation note: Some SDKs auto-attach on subscribe. If using explicit +# attach() in tests, set attachOnSubscribe: false in channel options to prevent +# implicit attach from interfering with the test flow. + ### Setup ```pseudo channel_name = "test-RTL15b-messages-${random_id()}" diff --git a/uts/realtime/unit/channels/channel_server_initiated_detach.md b/uts/realtime/unit/channels/channel_server_initiated_detach.md index 7bb972046..fa9959727 100644 --- a/uts/realtime/unit/channels/channel_server_initiated_detach.md +++ b/uts/realtime/unit/channels/channel_server_initiated_detach.md @@ -124,7 +124,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, realtimeRequestTimeout: 100, - suspendedRetryTimeout: 60000 + channelRetryTimeout: 60000 )) channel = client.channels.get(channel_name) ``` @@ -177,7 +177,7 @@ CLOSE_CLIENT(client) | Spec | Requirement | |------|-------------| -| RTL13b | If the reattach fails, or if the channel was already ATTACHING, channel transitions to SUSPENDED. An automatic re-attach attempt is made after suspendedRetryTimeout. If that also fails (timeout or DETACHED), the cycle repeats indefinitely. | +| RTL13b | If the reattach fails, or if the channel was already ATTACHING, channel transitions to SUSPENDED. An automatic re-attach attempt is made after channelRetryTimeout. If that also fails (timeout or DETACHED), the cycle repeats indefinitely. | Tests that when a server-initiated DETACHED triggers a reattach that times out, the channel transitions to SUSPENDED and then automatically retries after the suspended retry timeout. @@ -200,7 +200,7 @@ mock_ws = MockWebSocket( ELSE IF attach_count == 2: # Reattach after server DETACHED - don't respond (timeout) ELSE IF attach_count == 3: - # Automatic retry after suspendedRetryTimeout - succeed + # Automatic retry after channelRetryTimeout - succeed mock_ws.send_to_client(ProtocolMessage( action: ATTACHED, channel: msg.channel @@ -213,7 +213,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, realtimeRequestTimeout: 100, - suspendedRetryTimeout: 200 + channelRetryTimeout: 200 )) channel = client.channels.get(channel_name) ``` @@ -247,7 +247,7 @@ ASSERT attach_count == 2 ADVANCE_TIME(150) AWAIT_STATE channel.state == ChannelState.suspended -# Wait for suspendedRetryTimeout to trigger automatic retry and succeed +# Wait for channelRetryTimeout to trigger automatic retry and succeed ADVANCE_TIME(250) AWAIT_STATE channel.state == ChannelState.attached ASSERT attach_count == 3 @@ -302,7 +302,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, realtimeRequestTimeout: 500, - suspendedRetryTimeout: 200 + channelRetryTimeout: 200 )) channel = client.channels.get(channel_name) ``` @@ -333,7 +333,7 @@ mock_ws.active_connection.send_to_client(ProtocolMessage( AWAIT_STATE channel.state == ChannelState.suspended ASSERT attach_count == 1 # Only the original attach, no second attempt -# Wait for suspendedRetryTimeout — automatic retry should succeed +# Wait for channelRetryTimeout — automatic retry should succeed ADVANCE_TIME(250) AWAIT_STATE channel.state == ChannelState.attached ASSERT attach_count == 2 @@ -391,7 +391,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, realtimeRequestTimeout: 100, - suspendedRetryTimeout: 200 + channelRetryTimeout: 200 )) channel = client.channels.get(channel_name) ``` @@ -488,7 +488,7 @@ client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", autoConnect: false, realtimeRequestTimeout: 100, - suspendedRetryTimeout: 200 + channelRetryTimeout: 200 )) channel = client.channels.get(channel_name) ``` @@ -515,14 +515,14 @@ AWAIT_STATE channel.state == ChannelState.attaching ADVANCE_TIME(150) AWAIT_STATE channel.state == ChannelState.suspended -# Now disconnect the connection BEFORE the suspendedRetryTimeout fires +# Now disconnect the connection BEFORE the channelRetryTimeout fires mock_ws.active_connection.simulate_disconnect() AWAIT_STATE client.connection.state != ConnectionState.connected # Record attach_count at this point attach_count_after_disconnect = attach_count -# Advance time well past the suspendedRetryTimeout +# Advance time well past the channelRetryTimeout ADVANCE_TIME(500) ``` diff --git a/uts/realtime/unit/channels/channel_state_events.md b/uts/realtime/unit/channels/channel_state_events.md index a89dc1bdc..01bc40901 100644 --- a/uts/realtime/unit/channels/channel_state_events.md +++ b/uts/realtime/unit/channels/channel_state_events.md @@ -594,9 +594,9 @@ CLOSE_CLIENT(client) --- -## Channel errorReason cleared on successful attach +## RTL4c - errorReason cleared on successful attach -**Spec requirement:** Error reason should be cleared when channel successfully attaches. +**Spec requirement (RTL4c):** When the confirmation ATTACHED ProtocolMessage is received, the channel's errorReason is set to null. Tests that errorReason is cleared after successful attach following a failure. diff --git a/uts/realtime/unit/channels/channel_subscribe.md b/uts/realtime/unit/channels/channel_subscribe.md index 34f91140d..215826d0a 100644 --- a/uts/realtime/unit/channels/channel_subscribe.md +++ b/uts/realtime/unit/channels/channel_subscribe.md @@ -687,6 +687,12 @@ CLOSE_CLIENT(client) **Spec requirement:** A test should exist ensuring published messages are not echoed back to the subscriber when `echoMessages` is set to false in the `RealtimeClient` library constructor. +> **Implementation note:** Echo suppression may be implemented either by client-side +> filtering (comparing incoming message connectionId against the local connectionId, +> as shown below) or by server-side delegation (passing `echo=false` in the connection +> parameters). SDKs using server-side delegation should adapt this test to verify the +> echo parameter is set on the connection URL, rather than testing client-side filtering. + Tests that when `echoMessages` is false, messages originating from this connection (identified by matching `connectionId`) are not delivered to subscribers. ### Setup diff --git a/uts/realtime/unit/connection/connection_failures_test.md b/uts/realtime/unit/connection/connection_failures_test.md index ebdac35b7..aefbf40c3 100644 --- a/uts/realtime/unit/connection/connection_failures_test.md +++ b/uts/realtime/unit/connection/connection_failures_test.md @@ -293,6 +293,31 @@ ASSERT client.connection.errorReason IS NOT null CLOSE_CLIENT(client) ``` +> **Implementation note:** The `key` + mock HTTP approach shown above is one way to +> test token renewal failure. A more portable alternative is to use `authCallback`: +> ```pseudo +> call_count = 0 +> auth_callback = (params) => +> call_count += 1 +> IF call_count == 1: +> RETURN TokenDetails(token: "valid-token-1", expires: now + 3600000) +> ELSE: +> THROW ErrorInfo(code: 40171, statusCode: 401, message: "Token renewal failed") +> +> client = create_realtime_client(ClientOptions( +> authCallback: auth_callback, +> autoConnect: false +> )) +> ``` +> This pattern is clearer about the number of token requests and doesn't require a +> mock HTTP client for internal token request endpoints. +> +> **State transition note:** RTN15h2i specifies a transient DISCONNECTED state between +> CONNECTED and CONNECTING. When tracking state changes, implementations should +> distinguish between the transient DISCONNECTED (before CONNECTING retry) and the +> final DISCONNECTED (after failed renewal). A naive `AWAIT_STATE disconnected` may +> match the wrong transition. + --- ## RTN15h3 - DISCONNECTED with non-token error diff --git a/uts/realtime/unit/connection/connection_open_failures_test.md b/uts/realtime/unit/connection/connection_open_failures_test.md index 22371a1f5..9ac596796 100644 --- a/uts/realtime/unit/connection/connection_open_failures_test.md +++ b/uts/realtime/unit/connection/connection_open_failures_test.md @@ -171,6 +171,66 @@ CLOSE_CLIENT(client) --- +## RTN14b - Token error during initial connection, renewal fails + +**Spec requirement:** When a token error occurs during the initial connection attempt and the subsequent +token renewal also fails, the connection should transition to DISCONNECTED (per RTN14b: +"If the attempt to create a new token fails... the connection will transition to the +DISCONNECTED state"). + +### Setup + +```pseudo +call_count = 0 +auth_callback = (params) => + call_count += 1 + IF call_count == 1: + RETURN TokenDetails(token: "initial-token", expires: now + 3600000) + ELSE: + THROW ErrorInfo(code: 40171, statusCode: 401, message: "Unable to renew token") + +mock_ws = MockWebSocket( + onConnectionAttempt: (conn) => + # Always reject with token error + conn.send_to_client(ProtocolMessage( + action: ERROR, + error: ErrorInfo(code: 40142, statusCode: 401, message: "Token expired") + )) +) +install_mock(mock_ws) + +client = create_realtime_client(ClientOptions( + authCallback: auth_callback, + autoConnect: false +)) +``` + +### Test Steps + +```pseudo +state_changes = [] +client.connection.on((change) => state_changes.push(change)) + +client.connect() +AWAIT_STATE client.connection.state == ConnectionState.disconnected +``` + +### Assertions + +```pseudo +# Connection should be DISCONNECTED (not FAILED) per RTN14b +ASSERT client.connection.state == ConnectionState.disconnected + +# authCallback was called twice: once for initial token, once for renewal +ASSERT call_count == 2 + +# errorReason should reflect the renewal failure +ASSERT client.connection.errorReason IS NOT null +CLOSE_CLIENT(client) +``` + +--- + ## RSA4a - Token error during connection without renewal **Spec requirement (RSA4a2):** If the server responds with a token error and there is no means to renew the token, the connection transitions to FAILED with error code 40171. diff --git a/uts/realtime/unit/connection/error_reason_test.md b/uts/realtime/unit/connection/error_reason_test.md index 0975c3cd5..6d1f6f01a 100644 --- a/uts/realtime/unit/connection/error_reason_test.md +++ b/uts/realtime/unit/connection/error_reason_test.md @@ -175,11 +175,11 @@ CLOSE_CLIENT(client) --- -## RTN25 - errorReason on token errors (RTN14b, RTN15h) +## RTN25 - errorReason on token errors (RTN14b, RSA4a) -**Spec requirement:** errorReason is set when token errors occur during connection or while connected. +**Spec requirement:** When an ERROR ProtocolMessage with a token error is received during connection and there is no means to renew the token, RSA4a applies: the connection transitions to FAILED with error code 40171. -Tests that errorReason captures token-related errors. +Tests that errorReason is set with the 40171 wrapper error when a non-renewable token fails. ### Setup @@ -199,7 +199,7 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) -# Use token directly (no way to renew) +# Use token directly (no way to renew) — RSA4a applies client = Realtime(options: ClientOptions( token: "expired_token", autoConnect: false @@ -212,19 +212,17 @@ client = Realtime(options: ClientOptions( # Start connection client.connect() -# Wait for DISCONNECTED state (can't renew token) -AWAIT_STATE client.connection.state == ConnectionState.disconnected +# Per RSA4a2: no means to renew → FAILED state +AWAIT_STATE client.connection.state == ConnectionState.failed WITH timeout: 5 seconds ``` ### Assertions ```pseudo -# errorReason contains token error details +# errorReason should indicate no means to renew (RSA4a2: error code 40171) ASSERT client.connection.errorReason IS NOT null -ASSERT client.connection.errorReason.code == 40142 -ASSERT client.connection.errorReason.statusCode == 401 -ASSERT client.connection.errorReason.message CONTAINS "Token" +ASSERT client.connection.errorReason.code == 40171 CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/fallback_hosts_test.md b/uts/realtime/unit/connection/fallback_hosts_test.md index c3e7895a6..8a286f5a5 100644 --- a/uts/realtime/unit/connection/fallback_hosts_test.md +++ b/uts/realtime/unit/connection/fallback_hosts_test.md @@ -653,6 +653,10 @@ ASSERT history_host == connected_fallback_host # Or: # B) Same fallback datacenter (e.g., *.b.fallback.* matches) +# EXTRACT_FALLBACK_ID extracts the datacenter identifier from a fallback hostname. +# Realtime hosts: main..fallback.ably-realtime.com +# REST hosts: rest..fallback.ably-realtime.com +# The function returns the portion (e.g., "a", "b", "c", "d", "e"). ASSERT EXTRACT_FALLBACK_ID(history_host) == EXTRACT_FALLBACK_ID(connected_fallback_host) CLOSE_CLIENT(client) ``` diff --git a/uts/realtime/unit/connection/heartbeat_test.md b/uts/realtime/unit/connection/heartbeat_test.md index 56a5f5b5a..c50e1ec0f 100644 --- a/uts/realtime/unit/connection/heartbeat_test.md +++ b/uts/realtime/unit/connection/heartbeat_test.md @@ -124,6 +124,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 2000, # 2 seconds + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -147,6 +148,10 @@ ASSERT connection_attempt_count == 1 # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for the reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -208,6 +213,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) ``` @@ -236,6 +242,11 @@ ASSERT connection_attempt_count == 1 # Advance time past the timeout window (4100ms since last HEARTBEAT) ADVANCE_TIME(2100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -287,6 +298,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -331,6 +343,11 @@ ASSERT connection_attempt_count == 1 # Advance time past timeout without any message (3100ms since last activity) ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -390,6 +407,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -412,6 +430,11 @@ ASSERT connection_attempt_count == 1 # Advance time past maxIdleInterval + realtimeRequestTimeout # = 2000 + 1000 = 3000ms ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete (immediate per RTN15a) AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -478,6 +501,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -497,6 +521,11 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time past timeout to trigger disconnection and reconnection ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -618,6 +647,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 2000, # 2 seconds + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -641,6 +671,10 @@ ASSERT connection_attempt_count == 1 # = 5000 + 2000 = 7000ms ADVANCE_TIME(7100) +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for the reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -702,6 +736,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) ``` @@ -728,6 +763,11 @@ ASSERT connection_attempt_count == 1 # Advance time past the timeout window (4100ms since last ping) ADVANCE_TIME(2100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -779,6 +819,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -827,6 +868,11 @@ ASSERT connection_attempt_count == 1 # Advance time past timeout without any activity ADVANCE_TIME(1600) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -886,6 +932,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -908,6 +955,11 @@ ASSERT connection_attempt_count == 1 # Advance time past maxIdleInterval + realtimeRequestTimeout # = 2000 + 1000 = 3000ms ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete (immediate per RTN15a) AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -974,6 +1026,7 @@ install_mock(mock_ws) client = Realtime(options: ClientOptions( key: "appId.keyId:keySecret", realtimeRequestTimeout: 1000, # 1 second + disconnectedRetryTimeout: 500, autoConnect: false )) @@ -993,6 +1046,11 @@ AWAIT_STATE client.connection.state == ConnectionState.connected # Advance time past timeout to trigger disconnection and reconnection ADVANCE_TIME(3100) + +# After idle timeout fires, the client enters DISCONNECTED and waits +# disconnectedRetryTimeout before reconnecting. If using fake timers, +# ensure time is advanced past both the idle timeout AND the retry delay. + # Wait for reconnection to complete AWAIT_STATE client.connection.state == ConnectionState.connected ``` @@ -1082,6 +1140,10 @@ CLOSE_CLIENT(client) # Implementation Notes +> **Implementation note:** Some SDKs perform an internet connectivity check (RTN17j) +> before reconnection. Implementations may need to mock the HTTP layer to respond +> successfully to connectivity check requests, to allow reconnection to proceed. + ## Choosing Between RTN23a and RTN23b A concrete SDK implementation should: diff --git a/uts/realtime/unit/presence/local_presence_map.md b/uts/realtime/unit/presence/local_presence_map.md index eacdf189a..c8efe3aba 100644 --- a/uts/realtime/unit/presence/local_presence_map.md +++ b/uts/realtime/unit/presence/local_presence_map.md @@ -17,7 +17,7 @@ Key differences from the main PresenceMap: - Applies ENTER, PRESENT, UPDATE, and non-synthesized LEAVE events (RTP17b) - Ignores synthesized LEAVE events — where connectionId is not a prefix of id (RTP17b, per RTP2b1) - No sync protocol (startSync/endSync) — that is only on the main PresenceMap -- No newness comparison — entries are simply overwritten +- Messages are applied "in the same way as for the normal PresenceMap" (RTP17), including newness comparison (RTP2a, RTP2b) ## Interface Under Test @@ -102,7 +102,7 @@ map.put(PresenceMessage( ### Assertions ```pseudo ASSERT map.get("client-1") IS NOT null -ASSERT map.get("client-1").action == ENTER +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT ASSERT map.get("client-1").data == "hello" ASSERT map.values().length == 1 ``` @@ -134,7 +134,7 @@ map.put(PresenceMessage( ### Assertions ```pseudo ASSERT map.get("client-1") IS NOT null -ASSERT map.get("client-1").action == UPDATE +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT ASSERT map.get("client-1").data == "from-update" ASSERT map.values().length == 1 ``` @@ -175,7 +175,7 @@ map.put(PresenceMessage( ### Assertions ```pseudo ASSERT map.values().length == 1 -ASSERT map.get("client-1").action == ENTER +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT ASSERT map.get("client-1").data == "second" ``` @@ -214,7 +214,7 @@ map.put(PresenceMessage( ### Assertions ```pseudo ASSERT map.values().length == 1 -ASSERT map.get("client-1").action == UPDATE +ASSERT map.get("client-1").action == PRESENT # RTP2d2: stored action is always PRESENT ASSERT map.get("client-1").data == "updated" ``` @@ -302,6 +302,12 @@ substring of its id, per RTP2b1) should NOT be applied to the RTP17 presence map The remove method checks whether the connectionId is a prefix of the message id. If it is not, the leave is synthesized and the member must NOT be removed. +> **Implementation note:** Synthesized-LEAVE filtering (checking whether the LEAVE's +> connectionId matches the local connection) may be implemented either inside the +> presence map's `remove()` method, or at the calling level (e.g., in RealtimePresence). +> The key requirement is that synthesized LEAVEs are not applied to the local presence +> map — the level at which this is enforced is implementation-dependent. + ### Setup ```pseudo map = LocalPresenceMap() diff --git a/uts/realtime/unit/presence/realtime_presence_enter.md b/uts/realtime/unit/presence/realtime_presence_enter.md index 389d0beb9..b15c13863 100644 --- a/uts/realtime/unit/presence/realtime_presence_enter.md +++ b/uts/realtime/unit/presence/realtime_presence_enter.md @@ -284,6 +284,8 @@ mock_ws = MockWebSocket( install_mock(mock_ws) # Wildcard clientId +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -518,6 +520,8 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -573,6 +577,8 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -629,6 +635,8 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -695,6 +703,22 @@ ASSERT channel.state == ChannelState.attached CLOSE_CLIENT(client) ``` +> **Implementation note:** Some SDKs do not perform client-side clientId validation +> for `enterClient`. In such cases, the PRESENCE message is sent to the server which +> responds with a NACK (error). The test mock should include a handler that returns a +> NACK for the mismatched clientId: +> ```pseudo +> mock_ws.onMessageFromClient = (msg) => +> IF msg.action == PRESENCE: +> conn.send_to_client(ProtocolMessage( +> action: NACK, +> msgSerial: msg.msgSerial, +> error: ErrorInfo(code: 40012, statusCode: 400, message: "Invalid clientId") +> )) +> ``` +> The key requirement is that the operation results in an error — whether client-side +> or via server NACK is implementation-dependent. + --- ## RTP16a - Presence message sent when channel is ATTACHED @@ -873,6 +897,8 @@ install_mock(mock_ws) # Wildcard clientId to allow both enter() and enterClient() on the same connection. # See note in Purpose section about SDK-level wildcard validation. +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -969,6 +995,8 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) channel = client.channels.get(channel_name) ``` @@ -1083,6 +1111,8 @@ mock_ws_b = MockWebSocket( install_mock(mock_ws_a, client: "A") install_mock(mock_ws_b, client: "B") +# Note: clientId "*" may not be accepted by all SDKs at construction time. +# See top-level note for alternative auth patterns (e.g., key auth without clientId). client_a = Realtime(options: ClientOptions(key: "fake.key:secret", clientId: "*", autoConnect: false)) client_b = Realtime(options: ClientOptions(key: "fake.key:secret", autoConnect: false)) channel_a = client_a.channels.get(channel_name) diff --git a/uts/realtime/unit/presence/realtime_presence_get.md b/uts/realtime/unit/presence/realtime_presence_get.md index 09e598279..a85d81287 100644 --- a/uts/realtime/unit/presence/realtime_presence_get.md +++ b/uts/realtime/unit/presence/realtime_presence_get.md @@ -380,6 +380,16 @@ CLOSE_CLIENT(client) ## RTP11d - get on SUSPENDED channel errors by default +> **Reaching SUSPENDED state:** To transition a channel to SUSPENDED, the connection +> must first reach SUSPENDED state (by exhausting all reconnection attempts within +> `connectionStateTtl`). RTL3c then transitions ATTACHED channels to SUSPENDED. +> This requires: +> 1. The mock connectionDetails must include explicit `connectionStateTtl` (e.g., 5000ms) +> 2. ClientOptions should set `disconnectedRetryTimeout` to a small value (e.g., 500ms) +> 3. After disconnecting, refuse all reconnection attempts +> 4. Advance fake timers past `connectionStateTtl` to trigger SUSPENDED +> 5. Some SDKs perform a connectivity check (RTN17j) that may need an HTTP mock + **Spec requirement:** If the RealtimeChannel is SUSPENDED, get will by default (or if waitForSync is true) result in an error with code 91005. If waitForSync is false, it returns the members currently stored in the PresenceMap. @@ -389,7 +399,10 @@ it returns the members currently stored in the PresenceMap. channel_name = "test-RTP11d-${random_id()}" mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1", + connectionDetails: ConnectionDetails(connectionStateTtl: 5000)) + ), onMessageFromClient: (msg) => { IF msg.action == ATTACH: mock_ws.send_to_client(ProtocolMessage( @@ -448,7 +461,10 @@ members currently in the PresenceMap. channel_name = "test-RTP11d-nowait-${random_id()}" mock_ws = MockWebSocket( - onConnectionAttempt: (conn) => conn.respond_with_success(CONNECTED_MESSAGE), + onConnectionAttempt: (conn) => conn.respond_with_success( + ProtocolMessage(action: CONNECTED, connectionId: "conn-1", + connectionDetails: ConnectionDetails(connectionStateTtl: 5000)) + ), onMessageFromClient: (msg) => { IF msg.action == ATTACH: mock_ws.send_to_client(ProtocolMessage( diff --git a/uts/realtime/unit/presence/realtime_presence_history.md b/uts/realtime/unit/presence/realtime_presence_history.md index 8a6660197..5bf1823d6 100644 --- a/uts/realtime/unit/presence/realtime_presence_history.md +++ b/uts/realtime/unit/presence/realtime_presence_history.md @@ -91,6 +91,10 @@ mock_ws = MockWebSocket( ) install_mock(mock_ws) +# Note: The REST API returns presence actions as numeric values on the wire +# (ABSENT=0, PRESENT=1, ENTER=2, LEAVE=3, UPDATE=4). Mock responses should use +# the format appropriate for the SDK's REST layer. Assertions use symbolic names +# which correspond to the SDK's public API representation. mock_rest = MockRest( onRequest: (method, path, params) => { RETURN {