diff --git a/.claude/skills/uts-to-python/SKILL.md b/.claude/skills/uts-to-python/SKILL.md index 3dceac07..b81ed692 100644 --- a/.claude/skills/uts-to-python/SKILL.md +++ b/.claude/skills/uts-to-python/SKILL.md @@ -12,6 +12,7 @@ Fetch both fresh at the start of every run; do not work from memory. ```bash gh api repos/ably/specification/contents/uts/docs/writing-derived-tests.md --jq '.content' | base64 -d gh api repos/ably/specification/contents/uts/rest/unit/.md --jq '.content' | base64 -d +gh api repos/ably/specification/contents/uts/realtime/unit/.md --jq '.content' | base64 -d ``` `writing-derived-tests.md` governs. This file covers only what is particular to ably-python. @@ -22,7 +23,8 @@ A spec at `uts//.md` becomes `test/uts//_test. `uts/rest/unit/auth/token_renewal.md` becomes `test/uts/rest/unit/auth/token_renewal_test.py`. Every directory needs an `__init__.py`, as `test` is a package. -`test/uts/rest/unit/time_test.py` is the reference example. Follow its shape. +`test/uts/rest/unit/time_test.py` is the reference example for REST, and +`test/uts/realtime/unit/connection/auto_connect_test.py` for realtime. Follow their shape. ## Anatomy of a derived test @@ -75,7 +77,10 @@ async def test_rsc16_time_returns_server_time(): | `parse_json(request.body)` | `json.loads(request.body)` | | `msgpack_decode(x)` / `msgpack_encode(x)` | `msgpack.unpackb(x)` / `msgpack.packb(x, use_bin_type=False)` | | `process_pending_events()` | `await asyncio.sleep(0)` | -| `enable_fake_timers()` / `ADVANCE_TIME(ms)` | no equivalent; see Timers below | +| `enable_fake_timers()` / `ADVANCE_TIME(ms)` | `FakeClock` on a realtime client, nothing on REST; see Timers below | +| `install_mock(m)` + `Realtime(options: ...)` | `realtime_client(m, ...)` from `test.uts.helpers.client` | +| `AWAIT_STATE client.connection.state == X` | `await await_connection_state(client, ConnectionState.X)` | +| `mock_ws.active_connection` | the same, on `MockWebSocket` | Client options are snake_case throughout. Check the actual signature in `ably/types/options.py` before assuming an option exists. @@ -105,6 +110,332 @@ A response body given as a dict or list is encoded to match what the client aske so specs that exercise the binary protocol need no special handling. Pass `bytes` to control the encoding yourself, alongside an explicit `Content-Type`. +## The websocket mock + +`test/uts/helpers/mock_websocket.py`, matching `uts/realtime/unit/helpers/mock_websocket.md`. +Read it. Names match the pseudocode, transliterated to snake_case. + +A realtime client comes from `realtime_client(mock_ws, ...)` in +`test.uts.helpers.client`. It defaults the credentials to a key, `auto_connect` to +**false** and `fallback_hosts` to **empty**, and registers the client for teardown. +Pass `auto_connect=True` where the spec is about the default, and `mock_http=` or +`clock=` where a spec needs HTTP or controlled time on a realtime client. + +```python +mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), +) +client = realtime_client(mock_ws) + +client.connect() +await await_connection_state(client, ConnectionState.CONNECTED) +``` + +### `MockWebSocket` + +`MockWebSocket(on_connection_attempt=None, on_message_from_client=None, +on_text_data_frame=None, on_binary_data_frame=None)`. Every handler is reassignable. + +- `as_connect()` — the callable for `TestOptions(websocket_connect=...)`. +- `events` — the unified timeline, a list of `MockEvent(type, timestamp, data)`. +- `connection_attempts` / `messages_from_client` — the timeline filtered to the + `PendingConnection`s and the decoded messages the client sent. +- `events_of_type(MockEventType.X)` — the timeline filtered by type. +- `connections` / `active_connection` — the `MockConnection`s established, and the + most recent one. `active_connection` is `None` before the first connection and + after the client closes it. +- `handler_errors` — whatever a test's handler raised. The mock keeps those out of + the library rather than letting them escape. +- `send_to_client(message)`, `send_to_client_and_close(message)`, + `simulate_disconnect(error=None)`, `send_ping_frame()` — against + `active_connection`; they raise if there is none. +- `await_connection_attempt(timeout=5)`, `await_next_message_from_client(timeout=5)`, + `await_client_close(timeout=5)` — each **registers its waiter when called** and + returns an awaitable, so the next event can be awaited before the current one is + answered. Timeouts are seconds. A timeout raises `AssertionError`. +- `reset()` — clears the timeline, the waiters and the recorded connections, and + leaves the handlers and any live connection alone. + +`MockEventType`: `CONNECTION_ATTEMPT`, `CONNECTION_SUCCESS`, `CONNECTION_FAILURE`, +`MESSAGE_FROM_CLIENT`, `MESSAGE_TO_CLIENT`, `PING_FRAME`, `SERVER_DISCONNECT`, +`CLIENT_CLOSE`. + +### `PendingConnection` + +What a connection handler and `await_connection_attempt()` receive. + +- `url` — a `RecordedUrl`: `scheme`, `host`, `port`, `path`, `query_params`, `str(url)`. +- `protocol` — `'application/x-msgpack'` or `'application/json'`, from the `format` + query parameter. +- `headers` — the handshake headers, case-insensitive (`headers['Ably-Agent']`). +- `timestamp`, and `connection`, the `MockConnection` it will yield. +- `respond_with_success(connected_message=None)` — completes the connection, then + delivers the message behind it. +- `respond_with_refused()`, `respond_with_timeout()`, `respond_with_dns_error()`. +- `respond_with_error(error_message, then_close=True)` — connects, then has the + server send an ERROR. +- `send_to_client`, `send_to_client_and_close`, `simulate_disconnect`, + `send_ping_frame` — the server side of the connection it produces, so a handler + can call `respond_with_success()` and then `send_to_client(...)`. + +### `MockConnection` + +`send_to_client`, `send_to_client_and_close`, `simulate_disconnect(error=None)`, +`send_ping_frame`, plus `protocol`, `closed` and `close_event`. Its `__aiter__`, +`send` and `close` are the library's side; a test does not call them. + +`ClientCloseEvent` carries `code` and `reason`. + +### Templates and protocol-message builders + +All in `test.uts.helpers.mock_websocket`. Templates are plain dicts with wire names +(camelCase), **shared at module level** — build a variant with the builder rather +than mutating one. + +| Name | Is | +|---|---| +| `CONNECTED_MESSAGE` | action 4, `connectionId 'test-connection-id'`, `connectionDetails{connectionKey 'test-connection-key', clientId None, connectionStateTtl 120000, maxIdleInterval 15000}` | +| `CONNECTED_MESSAGE_NO_IDLE` | the same with `maxIdleInterval: 0`, so the transport never schedules the idle timer. **Every `FakeClock` test should connect with this** — see trap 6 | +| `CLOSED_MESSAGE`, `DISCONNECTED_MESSAGE`, `HEARTBEAT_MESSAGE` | actions 8, 6 (with a `statusCode`; see trap 9) and 0 | +| `connected_message(connection_id='test-connection-id', **connection_details)` | a CONNECTED variant | +| `ERROR_MESSAGE(code, message, status_code=None)` | action 9. Defaults 8xxxx to status 500; 4xxxx/5xxxx follow the spec formula (40142 -> 401) | +| `PING_MESSAGE(id)` | action 22 | +| `attached_message(channel, **fields)`, `detached_message(channel, **fields)` | actions 11 and 13 | +| `server_detached_message(channel, code, message, status_code=None)` | a DETACHED carrying an error, for RTL13 | +| `channel_error_message(channel, code, message, status_code=None)` | a channel-scoped ERROR. Routes through `on_error` -> `on_channel_message` (RTN15i) and fails the **channel**, which is the cheapest way into a FAILED channel | +| `message_protocol_message(channel, messages, **fields)` | action 15 | +| `annotation_protocol_message(channel, annotations, **fields)` | action 21 | +| `ack(message, serials=None, count=1)`, `nack(message, code, description, status_code=None, count=1)` | actions 1 and 2, built against a captured outgoing message | +| `PING_ACTION`, `PONG_ACTION`, `NORMAL_CLOSURE`, `MSGPACK_PROTOCOL`, `JSON_PROTOCOL` | constants | + +A message given as a dict is encoded for the connection's protocol, deep-copied +first, and unknown keys survive to the wire — so `send_to_client` with a plain dict +already **is** the specifications' `send_to_client_raw`, and forwards-compatibility +specs need no new mock method. Pass `bytes` or `str` to control the wire format +yourself. + +### Waiting on what the client sent + +| Helper | Is | +|---|---| +| `await_protocol_messages(mock, action, count=1, timeout=5.0)` | the primitive: waits until `count` messages of that action have left the client, and returns them | +| `await_published(mock, count=1, timeout=5.0)` | the MESSAGE wrapper | +| `await_presence_sent(mock, count=1, timeout=5.0)` | the PRESENCE wrapper | +| `contains_in_order(observed, expected)` | the specifications' `CONTAINS_IN_ORDER` | + +### Client and state helpers + +All in `test.uts.helpers.client`. + +| Helper | Is | +|---|---| +| `rest_client(mock_http, **kwargs)` | a REST client on the HTTP seam, registered for teardown | +| `realtime_client(mock_websocket=None, mock_http=None, clock=None, **kwargs)` | a realtime client on up to three seams. Defaults `key`, `auto_connect=False`, `fallback_hosts=[]` | +| `connected_client(mock_websocket, **kwargs)` | awaitable; a client already CONNECTED. Was copied into sixteen test files before it was promoted | +| `await_connection_state(client, state, timeout=5.0)` | `AWAIT_STATE`. Registers synchronously; **returns at once if the state is already current** — see trap 0 | +| `next_connection_state(client, state, timeout=5.0)` | waits for the *next fresh* entry into a state. The antidote to trap 0 for a reconnection | +| `await_channel_state(channel, state, timeout=5.0)` | the channel equivalent, with the same trap-0 hazard | +| `poll_until(condition, timeout=5.0, description='condition')` | `AWAIT UNTIL`, for a premise no state captures. Spins on **real** loop time, so it cannot wait for anything a `FakeClock` drives | +| `drop_transport(client, mock_websocket)` | stalls the attempt handler, disconnects, polls until DISCONNECTED is recorded, settles. Returns the recorded states. The cheap `FakeClock`-free way to hold DISCONNECTED | +| `reconnect_transport(client, mock_websocket, connected_message=None)` | drops and waits for a **fresh** CONNECTED | + +### Presence helpers + +All in `test.uts.helpers.presence`. The three white-box presence specifications drive a +`PresenceMap` and a `RealtimePresence` directly; the channel-state and `get` +specifications build presence wire messages by hand. + +| Helper | Is | +|---|---| +| `presence_map()` | the specification's `PresenceMap()`, keyed by memberKey (TP3h) | +| `presence_message(action, client_id, connection_id, id, timestamp, data=None)` | a `PresenceMessage` as the specification's test steps construct one. Always give it an explicit `connId:serial:index` id — see trap 15 | +| `subscribed_presence(connection_id='conn-1', name='presence-test')` | a `RealtimePresence` over a `SimpleNamespace` stub channel, plus the list of `(event_name, message)` pairs its subscribers receive. Registers one listener **per event name**, to dodge trap 12 | +| `present_member(client_id, connection_id, id, **fields)` | one entry of a PRESENCE or SYNC message's `presence` array | +| `sync_message(channel_name, channel_serial, presence)` | a SYNC protocol message | + +A stub channel is enough to drive `RealtimePresence` end to end: `set_presence` needs +only `channel.ably.connection.connection_manager.connection_id`, and `on_attached` a +running loop. + +### Timing helpers + +All in `test.uts.helpers.clock`. + +| Helper | Is | +|---|---| +| `FakeClock(settle_passes=20)`, passed as `realtime_client(mock, clock=clock)` | `enable_fake_timers()`. Notional only — it patches nothing onto `time`, `Date` or the loop clock, so real safety timeouts still work | +| `await clock.advance(ms)` | `ADVANCE_TIME(ms)`: fires what has fallen due, in due order, and lets the loop settle | +| `clock.now`, `clock.pending`, `clock.fired` | inspection. **`clock.now` inside a callback equals that timer's due time**, which makes a delay measurement exact rather than sampled | +| `settle(passes=20)` | `process_pending_events()`: twenty yields, because the realtime paths chain `create_task` several levels deep | +| `advance_to_connection_state(client, clock, state, step, limit=60)` | the specifications' `LOOP up to N: ADVANCE_TIME(x)`, for driving the connection to SUSPENDED | + +## Traps that cost the most time + +Ordered by how much they cost, not by subject. Every one was hit for real while +deriving the realtime unit specifications; the first three account for most of the +lost time. + +**0. `await_connection_state` returns immediately when the state is already held**, so +it silently no-ops and the assertions after it run against stale state. It looks like +it passed. Two shapes, both reported independently by two batches: + +- *"Drop the connection and reconnect"* — the target is CONNECTED and the client is + **still** CONNECTED when you call it. One batch lost five tests to false passes this + way, asserting `connection_key == 'key-1'` where `'key-1-updated'` was the point. + Use `next_connection_state(client, CONNECTED)`, or `reconnect_transport(...)`. +- *"An attempt is in flight"* — `client.connect()` sets CONNECTING **synchronously**, + so `connect(); await await_connection_state(CONNECTING)` returns before + `connect_base` has been scheduled. Poll for the **attempt**, never for the state: + `poll_until(lambda: len(mock_ws.connection_attempts) == 1, description='...')`. + +The same applies to `await_channel_state`: `send_to_client(DETACHED)` then +`await await_channel_state(channel, ATTACHED)` asserts nothing, because the channel +still *is* attached at that instant. Poll for the second ATTACH message first. + +**1. A registered `await_*` waiter takes precedence over the handler.** So the +specifications' commonest shape — build the mock with an `on_connection_attempt` that +responds, *and* then `pending = await mock_ws.await_connection_attempt()` — leaves the +attempt unanswered, and teardown waits the full five seconds on a dangling connect. +When you capture with `await_*`, respond manually afterwards. Conversely, a +`MockWebSocket()` with **no** `on_connection_attempt` auto-answers with +`respond_with_success()` and no CONNECTED message: the connection sits in CONNECTING, +which looks like what you wanted, but the attempt is already answered and a later +`respond_with_success(CONNECTED_MESSAGE)` is silently a no-op. To withhold a response, +spell it `MockWebSocket(on_connection_attempt=lambda conn: None)`. + +**2. `use_binary_protocol` defaults to True.** A mock feeding JSON strings must pass +`use_binary_protocol=False` or msgpack-pack its frames. Otherwise +`decode_raw_websocket_frame` raises, `ws_read_loop`'s broad `except Exception` +swallows it with a log line, and the test hangs to timeout with no clue. + +**3. Keep `TypeError` out of the mock's handlers.** `ws_connect`'s `except TypeError` +wraps the whole `async with` body, so a `TypeError` raised anywhere inside — including +in a listener — silently triggers a **second** connect call. Give any connect callable +a `**kwargs` signature. + +**4. A transient state cannot be polled for.** RTN15a retries immediately after a drop +from CONNECTED (`loop.call_soon`, not a timer), so DISCONNECTED is gone before the next +`await` returns and `await_connection_state(client, DISCONNECTED)` times out. Record +the sequence with `connection.on(...)` and assert on the list, as `mock_websocket.md` +says. To *rest* in DISCONNECTED, use `drop_transport(...)`, or fail the immediate retry +and raise `disconnected_retry_timeout`. + +**5. Deadlock: `await connection.once_async(SUSPENDED)` before calling `advance()` +hangs forever** — nothing fires timers but `advance`. Record with `connection.on(...)`, +advance, then assert. If a real await is needed, start it as a task **first** and +advance second. And put `await settle()` between `simulate_disconnect()` and +`clock.advance(...)`, or the transport's idle timer is still pending and `advance` +fires it before the disconnect is processed. + +**6. Do not install `FakeClock` for heartbeat or `maxIdleInterval` tests.** +`on_idle_timer_expire` compares `unix_time_ms()` (the real wall clock) against +`max_idle_interval` but schedules through the timer seam, so advancing fires the idle +timer while no real time has passed and it reschedules itself forever. Drive those with +a small `maxIdleInterval` in `connected_message(...)`, on real time. For every *other* +`FakeClock` test, connect with `CONNECTED_MESSAGE_NO_IDLE`. + +**7. `ping()`'s timeout is real loop time, not the seam.** +`connectionmanager.py:375` is `asyncio.wait_for(pending_ping.future, timeout)`, so +`advance()` will not move it. Use a short real `realtime_request_timeout`. + +**8. `clock.settle()` is twenty event-loop yields, not true quiescence.** The +disconnect path chains `create_task` several levels deep. If a derived test shows a +timer scheduled one interval off, raise `FakeClock(settle_passes=...)` before +suspecting the library. + +**9. Send DISCONNECTED with a `statusCode`.** `on_disconnected` compares +`exception.status_code >= 500` unguarded, so a DISCONNECTED without one raises +`TypeError` in a task whose exception is only logged, and the test hangs with no clue. +The template has one. + +**10. A refused connection and a connect timeout are indistinguishable, and skip the +fallback loop.** `ws_connect` catches only `WebSocketException` and `socket.gaierror`, +so `ConnectionRefusedError` and `asyncio.TimeoutError` never reach `_emit('failed')`: +the attempt hangs until the transition timer fires with a generic 50003/504, and each +one leaks a `connect_base` task. `respond_with_dns_error()` is the only fast, caught +failure — 40000/400 with the real cause — so **prefer it** whenever you just need "the +connect failed", and note the substitution at the site. Keep +`realtime_request_timeout` short in any test that does wait a refusal out. + +**11. Keep the fallback hosts empty** unless the spec is about them. +`check_connection()` is a module-level, **synchronous** `httpx.get` that no seam +reaches, so it blocks the event loop and goes to the real internet, once per host. A +test that must exercise the fallback loop has to `monkeypatch` +`ably.realtime.connectionmanager.httpx.get`. + +**12. Do not register one listener function for two events.** `EventEmitter` keys its +wrapper registry on the listener alone, so the second registration overwrites the +first and `off` for the first event raises `KeyError`. Use a separate `def` per event. +Relatedly, `connection.on(state, states.append)` raises +`ValueError: EventEmitter.on(): invalid args` — a bound built-in is not accepted, so +every listener must be a `def`. + +**13. Never name an attribute the channel collection does not define, and never use +`hasattr` on it.** `Channels.__getattr__` answers any unknown attribute with +`self.get(name)`, so probing it **creates a channel** and mutates what you are +measuring. + +**14. A publish hangs without an ACK.** `RealtimeChannel.publish()` is +`await send_protocol_message(...)` under RTL6b, and PRESENCE, ANNOTATION and OBJECT are +`ack_required` too. Most specifications write the publish un-awaited and never ACK: +read that as a promise the spec is eliding and **add the ACK** with `ack(msg)`, unless +the spec says in as many words not to — then drive the publish as a task. State which +you did in the module docstring. + +**15. Give every PresenceMessage an explicit `connId:serial:index` id.** A missing id +becomes the literal string `"None:0"`, which does not start with the member's +`connectionId`, so `is_synthesized()` returns True and the newness check silently takes +the RTP2b1 timestamp path instead of the RTP2b2 msgSerial path. The test then passes +while exercising the opposite branch from the one it names. + +**16. A server-sent CLOSED does nothing.** `on_closed` disposes the transport without +notifying a state, so the connection stays CONNECTED. CLOSED is reached only through +`client.close()`. Similarly, after `send_to_client_and_close()` the mock leaves +`active_connection` set, so a later `send_to_client` injects into a dead connection and +is silently swallowed rather than raising. + +**17. A spec's `mock_ws.active_connection.close()` means the *server* closing.** +Translate it to `simulate_disconnect()`. `MockConnection.close()` is the library's own +side. + +**18. `connection.id` does not exist**, nor `connection.key`. Read +`client.connection.connection_manager.connection_id` and +`client.connection.connection_details.connection_key`. +`client.connection.connection_details` and `connection.error_reason` **are** public. + +**19. Two noisy-but-harmless teardown messages.** `Task exception was never retrieved` +for a client whose connect failed — `WebSocketTransport.send` raises a bare +`Exception()` when `self.websocket is None`. And `Task was destroyed but it is +pending!`, one per refused attempt, which is trap 10's leak showing. Neither is a +failure; do not chase them. + +**20. A channel needs a SUSPENDED *connection* to reach SUSPENDED.** +`_propagate_connection_interruption` fires only for CLOSING/CLOSED/FAILED/SUSPENDED, so +a bare `simulate_disconnect()` leaves the channel ATTACHED. And RTL4f's channel attach +timeout and the connection's transition timeout are the **same option** (TO3l11), so a +test that needs the connection to suspend before the channel does must set +`realtime_request_timeout` very high. + +**21. `presence.subscribe()` and `annotations.subscribe()` are coroutines that +attach.** They must be awaited, and they cannot run before `connect()`. `unsubscribe()` +is not a coroutine. Presence event names are the lowercase action names from +`PresenceAction._action_name`, which is monkey-patched onto `PresenceAction` at the +bottom of `presence.py` and exists only once that module is imported. + +**22. `RecordedUrl` cannot survive a URL httpx rejects.** `httpx.URL` caps at 64 KiB +and raises inside `PendingConnection.__init__` **before** the attempt is recorded, so +the failure is invisible in `events`, `handler_errors` is empty, and the client hangs +in CONNECTING. Only matters if a spec drives an oversized query parameter. + +**23. A single `await settle()` after `send_to_client(...)` is provably enough** for +message delivery, so `poll_until(positive); await settle(); assert negative` is a sound +pattern for "and nothing else arrived". + +**24. RTN23b ping-frame and RTN23c1 PING/PONG tests are unimplementable.** The SDK has +no ping/pong handling and sends no `heartbeats` query parameter; the `websockets` +library answers pings inside the protocol and surfaces no event. Record as a Mock +Infrastructure Limitation. RTN23a via `send_to_client(HEARTBEAT_MESSAGE)` works. + ## ably-python traits that catch translations out - **The binary protocol is the default.** `use_binary_protocol` defaults to `True`, so @@ -135,9 +466,9 @@ control the encoding yourself, alongside an explicit `Content-Type`. arrive as msgpack `bin` rather than `str`. The mock's automatic encoding uses `use_bin_type=False`, so encode such a body yourself with an explicit `Content-Type`. -- **`auth_url` requests bypass the injected transport.** `Auth.token_request_from_auth_url` - builds its own `httpx.AsyncClient`, so a spec driving `auth_url` cannot be observed - through the mock and its test has to be skipped outright. +- **`auth_url` requests go through the injected transport.** + `Auth.token_request_from_auth_url` uses the client's own HTTP layer, so a spec driving + `auth_url` is observed through the mock like any other request. - **`PaginatedResult` reads `Content-Type` unguarded**, so anything it paginates over needs one. A native dict or list body gets one automatically. - **The mock enforces the client's read timeout**, so `respond_with_delay` beyond @@ -148,11 +479,18 @@ control the encoding yourself, alongside an explicit `Content-Type`. ## Timers -There is no clock seam. `ably/http/http.py` calls `time.time()` directly. Where a spec calls +The realtime client has a timer seam; the REST client does not. +`ably/http/http.py` calls `time.time()` directly. Where a REST spec calls `enable_fake_timers()` / `ADVANCE_TIME(ms)`, prefer short real timeouts driven by client options (`fallback_retry_timeout=100`), which is what the specs themselves do. The global pytest timeout is 30 seconds, so keep waits well under it. +On a realtime client, prefer a short real interval through a client option +(`realtime_request_timeout`, `disconnected_retry_timeout`, `suspended_retry_timeout`, +`channel_retry_timeout`) or through `connected_message(maxIdleInterval=...)`, and reach +for `FakeClock` only for `connection_state_ttl`, which no option sets and whose default +costs 120 real seconds. See the fake-time section of `test/uts/deviations.md`. + ## Deviations Diagnose per the decision tree in `writing-derived-tests.md`, then apply one of: @@ -180,11 +518,37 @@ cause where known, which tests are affected, and status. A differently spelled API is not a deviation. Record only wrong behaviour. +**A missing public accessor is adapted, never gated.** Where the behaviour is right +and only the public member is absent — `Connection#id`, `RealtimeChannel#properties`, +`ChannelStateChange#event` — assert the equivalent observable however internal it is, +define a reader at the top of the file so the adaptation is in one place, and record +the missing API in its own entry. Gating would take real behavioural coverage out of +the run indefinitely over a question of spelling. `test/uts/deviations.md` has the +ruling in full. + +**Group by root cause, not by test or by spec file.** Five tests failing for one +reason are one entry naming all five. If you are deriving one area of a larger effort, +check whether a neighbouring area has already recorded your finding before writing it +up again — three of the realtime defects were reported two or three times over. + +**Record a refuted claim too.** If you investigate something that looks like a defect +and it turns out the SDK is right, put it under *Investigated and not defects* with +the reasoning. The next reader will otherwise reach the same first conclusion. + ## Checks ```bash -uv run ruff check ably/ test/ -uv run --extra crypto pytest test/uts -q +uv run --frozen --extra crypto --extra dev ruff check ably/ test/ +uv run --frozen --extra crypto --extra dev pytest test/uts -q +RUN_DEVIATIONS=1 uv run --frozen --extra crypto --extra dev pytest test/uts -q ``` -Both must pass. Line length is 115. +`--frozen` is required — without it dependency resolution reaches past the +environment's cutoff — and `--extra dev` carries pytest. Line length is 115. If +`uv.lock` changes, `git checkout -- uv.lock`. + +The first two must pass. The third is the check that the deviations record is still +true: **every gated test must fail when enabled**, so gated + unimplementable under the +third run must equal the skip count under the second, and nothing may pass under both +behaviours. The expected counts are in the header of `test/uts/deviations.md`; update +them from a measured run rather than copying them forward. diff --git a/ably/realtime/channel.py b/ably/realtime/channel.py index 24f21d07..e77aead0 100644 --- a/ably/realtime/channel.py +++ b/ably/realtime/channel.py @@ -21,7 +21,7 @@ from ably.types.presence import PresenceMessage from ably.util.eventemitter import EventEmitter from ably.util.exceptions import AblyException, IncompatibleClientIdException -from ably.util.helper import Timer, is_callable_or_coroutine, validate_message_size +from ably.util.helper import Timer, is_callable_or_coroutine, select_timer, validate_message_size if TYPE_CHECKING: from ably.realtime.realtime import AblyRealtime @@ -58,6 +58,7 @@ def __init__(self, realtime: AblyRealtime, name: str, channel_options: ChannelOp EventEmitter.__init__(self) self.__name = name self.__realtime = realtime + self.__timer_func = select_timer(realtime.options) self.__state = ChannelState.INITIALIZED self.__message_emitter = EventEmitter() self.__state_timer: Timer | None = None @@ -846,7 +847,7 @@ def on_timeout() -> None: self.__state_timer = None self.__timeout_pending_state() - self.__state_timer = Timer(self.__realtime.options.realtime_request_timeout, on_timeout) + self.__state_timer = self.__timer_func(self.__realtime.options.realtime_request_timeout, on_timeout) def __clear_state_timer(self) -> None: if self.__state_timer: @@ -866,7 +867,8 @@ def __start_retry_timer(self) -> None: if self.__retry_timer: return - self.__retry_timer = Timer(self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire) + self.__retry_timer = self.__timer_func( + self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire) def __cancel_retry_timer(self) -> None: if self.__retry_timer: diff --git a/ably/realtime/connectionmanager.py b/ably/realtime/connectionmanager.py index f910dfe6..564c4a8b 100644 --- a/ably/realtime/connectionmanager.py +++ b/ably/realtime/connectionmanager.py @@ -18,7 +18,7 @@ from ably.types.tokendetails import TokenDetails from ably.util.eventemitter import EventEmitter from ably.util.exceptions import AblyException, IncompatibleClientIdException -from ably.util.helper import Timer, get_random_id, is_token_error +from ably.util.helper import Timer, get_random_id, is_token_error, select_timer if TYPE_CHECKING: from ably.realtime.realtime import AblyRealtime @@ -140,6 +140,7 @@ def response_time_ms(self) -> float: class ConnectionManager(EventEmitter): def __init__(self, realtime: AblyRealtime, initial_state): self.options = realtime.options + self.timer_func = select_timer(self.options) self.__ably = realtime self.__state: ConnectionState = initial_state self.__pending_pings: dict[str, PendingPing] = {} @@ -718,7 +719,7 @@ def on_transition_timer_expire(): log.debug(f'ConnectionManager.start_transition_timer(): setting timer for {timeout}ms') - self.transition_timer = Timer(timeout, on_transition_timer_expire) + self.transition_timer = self.timer_func(timeout, on_transition_timer_expire) def cancel_transition_timer(self): log.debug('ConnectionManager.cancel_transition_timer()') @@ -741,7 +742,7 @@ def on_suspend_timer_expire() -> None: ) self.__fail_state = ConnectionState.SUSPENDED - self.suspend_timer = Timer(Defaults.connection_state_ttl, on_suspend_timer_expire) + self.suspend_timer = self.timer_func(Defaults.connection_state_ttl, on_suspend_timer_expire) def check_suspend_timer(self, state: ConnectionState) -> None: if state not in ( @@ -764,7 +765,7 @@ def on_retry_timeout(): self.retry_timer = None self.request_state(ConnectionState.CONNECTING) - self.retry_timer = Timer(interval, on_retry_timeout) + self.retry_timer = self.timer_func(interval, on_retry_timeout) def cancel_retry_timer(self) -> None: if self.retry_timer: diff --git a/ably/transport/websockettransport.py b/ably/transport/websockettransport.py index ad4f2856..3381c974 100644 --- a/ably/transport/websockettransport.py +++ b/ably/transport/websockettransport.py @@ -15,7 +15,7 @@ from ably.types.operations import PublishResult from ably.util.eventemitter import EventEmitter from ably.util.exceptions import AblyException -from ably.util.helper import Timer, unix_time_ms +from ably.util.helper import select_timer, unix_time_ms try: # websockets 15+ preferred imports @@ -67,6 +67,8 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic self.ws_connect_task: asyncio.Task | None = None self.connection_manager = connection_manager self.options = self.connection_manager.options + self.connect_func = self.__select_connect_func(self.options) + self.timer_func = select_timer(self.options) self.is_connected = False self.idle_timer = None self.last_activity = None @@ -77,6 +79,13 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic self.format = params.get('format', 'json') super().__init__() + @staticmethod + def __select_connect_func(options): + test_options = getattr(options, 'test_options', None) + if test_options is not None and test_options.websocket_connect is not None: + return test_options.websocket_connect + return ws_connect + def connect(self): headers = HttpUtils.default_headers() query_params = urllib.parse.urlencode(self.params) @@ -101,11 +110,11 @@ async def ws_connect(self, ws_url, headers): try: # Use additional_headers for websockets 15+, fallback to extra_headers for older versions try: - async with ws_connect(ws_url, additional_headers=headers) as websocket: + async with self.connect_func(ws_url, additional_headers=headers) as websocket: await self._handle_websocket_connection(ws_url, websocket) except TypeError: # Fallback for websockets 14 and earlier - async with ws_connect(ws_url, extra_headers=headers) as websocket: + async with self.connect_func(ws_url, extra_headers=headers) as websocket: await self._handle_websocket_connection(ws_url, websocket) except (WebSocketException, socket.gaierror) as e: exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000) @@ -292,7 +301,7 @@ async def send(self, message: dict): def set_idle_timer(self, timeout: float): if self.idle_timer: self.idle_timer.cancel() - self.idle_timer = Timer(timeout, self.on_idle_timer_expire) + self.idle_timer = self.timer_func(timeout, self.on_idle_timer_expire) async def on_idle_timer_expire(self): self.idle_timer = None diff --git a/ably/types/testoptions.py b/ably/types/testoptions.py index 87ba98ef..84fa2c20 100644 --- a/ably/types/testoptions.py +++ b/ably/types/testoptions.py @@ -6,11 +6,24 @@ class TestOptions: :Parameters: - `http_transport`: an `httpx.AsyncBaseTransport` which handles every HTTP request the client makes, in place of the network. + - `websocket_connect`: a callable which opens every realtime websocket + connection the client makes, in place of `websockets.connect`. It is + called as `websocket_connect(url, additional_headers=headers)`, or with + `extra_headers=headers` if that raises `TypeError`, and returns an async + context manager yielding an object supporting `__aiter__`, `send` and + `close`. + - `timer`: a callable which schedules every delayed callback the realtime + client makes, in place of `ably.util.helper.Timer`. It is called as + `timer(timeout_ms, callback)`, where `callback` is either a coroutine + function or a plain callable, and returns an object with a `cancel()` + method. """ # Excludes the class from pytest collection, which would otherwise treat # any module importing it as declaring a test suite. __test__ = False - def __init__(self, http_transport=None): + def __init__(self, http_transport=None, websocket_connect=None, timer=None): self.http_transport = http_transport + self.websocket_connect = websocket_connect + self.timer = timer diff --git a/ably/util/helper.py b/ably/util/helper.py index a35ebe6e..a24916ae 100644 --- a/ably/util/helper.py +++ b/ably/util/helper.py @@ -75,6 +75,19 @@ async def _job(self): def cancel(self): self._task.cancel() + +def select_timer(options) -> Callable: + """The callable a client schedules its delayed callbacks with. + + `TestOptions.timer` substitutes for the real timer during tests, letting + them drive time-dependent behaviour without waiting for it. Clients which + supply none get `Timer`. + """ + test_options = getattr(options, 'test_options', None) + if test_options is not None and test_options.timer is not None: + return test_options.timer + return Timer + def validate_message_size(encoded_messages: list, use_binary_protocol: bool, max_message_size: int) -> None: """Validate that encoded messages don't exceed the maximum size limit. diff --git a/test/uts/README.md b/test/uts/README.md index 22acd263..2ba372e5 100644 --- a/test/uts/README.md +++ b/test/uts/README.md @@ -7,25 +7,30 @@ carries a `# UTS: ` comment identifying the specification it came from. Read `uts/docs/writing-derived-tests.md` in the specification repository before adding or changing tests here, alongside `.claude/skills/uts-to-python/SKILL.md`, -which covers what is particular to this SDK. Record anything that departs from a -specification in [deviations.md](deviations.md), which also covers how the -specifications are adopted here and why. +which covers what is particular to this SDK and lists every helper below. + +Record anything that departs from a specification in [deviations.md](deviations.md), +which also covers the faults found in the specifications themselves and raised +upstream, and the choices behind how the specifications are adopted here. ## Layout ``` -helpers/ shared infrastructure the specifications assume +helpers/ shared infrastructure the specifications assume, and its own tests rest/ specifications under uts/rest realtime/ specifications under uts/realtime ``` -Unit tests serve every request from a mock and reach no network. Integration -tests run against a sandbox app. +Every directory needs an `__init__.py`, because `test` is a package. + +Unit tests serve every request from a mock and reach no network — neither the REST +suite nor the realtime one. Integration tests run against a sandbox app. -## Installing the mock +## Installing the mocks The specifications express mock installation as a global `install_mock(mock_http)`. -Here a mock is passed to the client it serves: +Here a mock is passed to the client it serves, through `TestOptions`. There are three +seams, all client-scoped; [deviations.md](deviations.md) says why. ```python mock_http = MockHttpClient( @@ -35,11 +40,79 @@ mock_http = MockHttpClient( ably = AblyRest(key=key, test_options=TestOptions(http_transport=mock_http.as_transport())) ``` -The client builds its HTTP client once, so construct the mock first. Teardown is -`await ably.close()`, which stands in for `uninstall_mock()`. +A realtime client takes its websocket mock the same way, through +`TestOptions(websocket_connect=...)`, and its timers through `TestOptions(timer=...)`: + +```python +mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), +) +ably = AblyRealtime(key=key, auto_connect=False, + test_options=TestOptions(websocket_connect=mock_ws.as_connect(), + timer=FakeClock().timer)) +``` + +`rest_client(mock_http, ...)` and `realtime_client(mock_ws, ...)` in +[helpers/client.py](helpers/client.py) wrap all three, defaulting the credentials +and registering the client for teardown: + +```python +realtime_client(mock_websocket=None, mock_http=None, clock=None, **kwargs) +``` + +`mock_http=` gives a realtime client an HTTP mock for the specifications that drive +REST over a realtime client; `clock=` installs a `FakeClock`. `realtime_client` +defaults `auto_connect` to **false** and `fallback_hosts` to **empty** — both +deliberate, and both explained in [deviations.md](deviations.md). + +The client builds its HTTP client once and reads its websocket hook once, so +construct the mocks first. Teardown is automatic: `conftest.py` closes every +registered client after each test, which stands in for `uninstall_mock()`. **Do not +close clients in a test** — the fixture survives every connection state, and a test +that closes its own leaves nothing to clean up if it fails first. + +## What the helpers offer + +| Module | Holds | +|---|---| +| [helpers/mock_http.py](helpers/mock_http.py) | `MockHttpClient`, matching `uts/rest/unit/helpers/mock_http.md`, including the superseded `queue_*` family | +| [helpers/mock_websocket.py](helpers/mock_websocket.py) | `MockWebSocket`, matching `uts/realtime/unit/helpers/mock_websocket.md`, plus the protocol-message templates and builders the specifications assume | +| [helpers/client.py](helpers/client.py) | client constructors, and the `AWAIT_STATE` / `AWAIT UNTIL` equivalents | +| [helpers/clock.py](helpers/clock.py) | `FakeClock`, `settle()` and `advance_to_connection_state()` — `enable_fake_timers()` and `ADVANCE_TIME(ms)` | +| [helpers/presence.py](helpers/presence.py) | the presence-map stubs and wire-message builders the presence specifications share | +| [helpers/deviations.py](helpers/deviations.py) | the `@deviation` and `@spec_error` gates | + +`SKILL.md` lists every name in each. The helpers have their own tests +(`helpers/*_test.py`), which are not derived from a specification and are not counted +in the derived-test totals. ## Running ``` -uv run --extra crypto pytest test/uts +uv run --frozen --extra crypto --extra dev pytest test/uts -q +``` + +`--frozen` is required: without it dependency resolution reaches past the +environment's cutoff. `--extra dev` carries pytest. + +Tests that record a deviation or a specification fault are skipped by default and run +under an environment variable: + +``` +RUN_DEVIATIONS=1 uv run --frozen --extra crypto --extra dev pytest test/uts -q +``` + +Every gated test is confirmed to fail when enabled — none passes under both +behaviours — so the two runs are the check that the record in +[deviations.md](deviations.md) is still true. The counts either run should produce are +in that file's header. + +Both seams are installed per client, so a test that forgets one, or that lets the host +fallback loop run, reaches the real internet; see the fallback host note in +[deviations.md](deviations.md). + +Linting is `ruff`, line length 115: + +``` +uv run --frozen --extra crypto --extra dev ruff check ably/ test/ ``` diff --git a/test/uts/deviations.md b/test/uts/deviations.md index de998f06..309ac227 100644 --- a/test/uts/deviations.md +++ b/test/uts/deviations.md @@ -5,18 +5,37 @@ The closing section covers how the specifications are adopted here; everything before it records behaviour. Entries are grouped by root cause rather than by test, so one entry covers every -test it affects. Headings are fixed and appear even when they hold nothing. +test it affects, whichever specification or tier the tests came from. The four +headings are fixed, appear in order, and appear even when they hold nothing. Three +further headings follow them: **Investigated and not defects**, which records +claims raised during derivation and then refuted, so nobody re-reports them; +**Candidate issues**, which is the "Filing issues from deviations" output that +`writing-derived-tests.md` asks for — the same deviations classified into distinct +issues, ranked, for a maintainer deciding what to file; and **How the specifications +are adopted here**, which records the choices behind the harness rather than the +behaviour. + +Of 1017 derived tests, 811 pass, 191 are gated behind `RUN_DEVIATIONS` and 15 +cannot be run at all. Every gated test has been confirmed to fail when enabled, so +none of them passes under both behaviours. 536 of the derived tests come from +`uts/rest/unit` and 481 from `uts/realtime/unit`; of the gated tests 110 are REST +and 81 realtime. A further 122 tests under `helpers/` cover the mock infrastructure +itself and are not derived from a specification. -Of 581 derived tests, 465 pass, 110 are gated behind `RUN_DEVIATIONS` and 6 cannot be -run at all. Every gated test has been confirmed to fail when enabled, so none of them -passes under both behaviours. +The 181 gated tests that record SDK non-compliance reduce to **65 distinct root +causes** — 25 on the REST side and 40 on the realtime side. Two further realtime +defects are recorded below with no test of their own, because the specification's test +for each cannot discriminate (RTP18a) or has nothing to assert against (the timezone +split on synthesized LEAVE timestamps), so the file carries **67 SDK root causes** in +all. The remaining 10 gated tests are specification faults, and reduce to 7. Entries closed by a fix are removed rather than kept as history; `git log` holds that. -Run the gated tests with: +Run the whole suite, and then the gated tests, with: ``` -RUN_DEVIATIONS=1 uv run --extra crypto pytest test/uts +uv run --frozen --extra crypto --extra dev pytest test/uts -q +RUN_DEVIATIONS=1 uv run --frozen --extra crypto --extra dev pytest test/uts -q ``` ## UTS Spec Errors @@ -31,12 +50,13 @@ from the specification text, so that correcting the specification is all it take it pass, and it is marked `@spec_error` — a skip gated on `RUN_DEVIATIONS`, the same gate `@deviation` uses, with a reason naming the specification rather than the SDK. The suite stays green, a real regression still shows, and the failure is one environment variable -away. Each is filed upstream, in the issues named below. Nine tests are gated +away. Each is filed upstream, in the issues named below. Ten tests are gated this way: | Test | Spec error | |---|---| | `test_rsl1a_publish_message_array` | RSL1c - an object payload asserted to travel unstringified | +| `test_rtl6i1_publish_message_object` | RTL6i1 - the same fault, repeated in the realtime publish spec | | `test_rsl1k_mixed_ids_in_batch` | RSL1k - an absent id in a mixed batch asserted to be generated | | `test_rsa4a2_expired_token_no_renewal` | RSA4a2 - local expiry detection demanded | | `test_rsa4b1_preemptive_renewal` | RSA4b1 - local expiry detection demanded | @@ -49,7 +69,9 @@ this way: Where instead only a specification's *fixture*, *setup* or *label* is at fault, the assertion it carries still stands. Those tests keep the corrected fixture (or the corrected label in a comment), pass, and carry a `# UTS SPEC ERROR:` comment at the -site. The entries below cover both kinds and say which applies. +site. The entries below cover both kinds and say which applies. Almost every realtime +fault is of the second kind, which is why only one realtime test is gated as a spec +error while fourteen realtime entries appear below. The three sections that follow this one record SDK behaviour rather than specification faults. @@ -68,6 +90,16 @@ Raised upstream: | [#530](https://github.com/ably/specification/issues/530) | Token renewal driven through `/time` | | [#531](https://github.com/ably/specification/issues/531) | `RSA10i` asserting that an API key survives `authorize()`, with no assertions | | [#532](https://github.com/ably/specification/issues/532) | Housekeeping: a leaked local path, sections carrying no Test ID, a duplicate, misfiled tests | +| [#542](https://github.com/ably/specification/issues/542) | Two presence specifications contradicting themselves over the wildcard clientId | +| [#543](https://github.com/ably/specification/issues/543) | Tests that cannot detect what they exist to detect | +| [#544](https://github.com/ably/specification/issues/544) | Fixtures that cannot produce the condition they describe | +| [#545](https://github.com/ably/specification/issues/545) | Fixtures written against mock methods the contract does not define | +| [#546](https://github.com/ably/specification/issues/546) | Connection setups crediting a key-authenticated client with an initial token request | + +`#527` also carries a comment on the realtime wire-format assertions, `#532` one on the +same housekeeping categories in `realtime/unit`, and +[#466](https://github.com/ably/specification/issues/466) — which is not ours — one on the +RSA4c3 contradiction, since that issue is what decides it. Not every entry has an issue of its own: the URL-safe base64 alphabet is recorded below and not filed, because ably-python's own encoding settles the tests either way. Line @@ -110,6 +142,8 @@ to let the tests accept both. - `publish.md` RSL1c asserts `body[1]["data"] == {"key": "value"}`. RSL4c3 and RSL4d3 require an object payload to be JSON-stringified with `encoding` set to `"json"`. + `channel_publish.md` RTL6i1 repeats it for the realtime publish, which RTL6a defers + to `RestChannel#publish`, so the two are one fault in two files. - `publish.md` RSL1e asserts exact equality against a whole message body, which cannot hold while idempotent publishing (RSL1k1, on by default) adds an `id`. - `RSL1k/mixed-ids-in-batch-1` expects a generated id for the id-less message in a @@ -120,6 +154,10 @@ to let the tests accept both. `useBinaryProtocol: false` and TO3f defaults it true, so RSL4c1 governs. - `auth_scheme.md` asserts a raw `Bearer `. RSA3b makes Base64 optional, and ably-python and ably-js both encode. +- `channel_delta_decoding.md` asserts a vcdiff-decoded payload equals a *string* + literal, although the same file's transport note applies utf-8 only "if present" + and the messages in question carry no utf-8 step, so the decoded payload is binary. + See the Adapted entry below. ### Token expiry tests demand optional behaviour @@ -192,12 +230,250 @@ claim is the one to revisit. `revoke_tokens.md` has the same internal split: `Content-Type: application/json`; CSV2b templates the version, the sibling spec says ">= 3", and the binary protocol default makes the content type msgpack. +### Two presence specifications contradict themselves over the wildcard clientId + +The same contradiction appears twice, mirrored, and the two should be settled together. + +- `realtime_presence_enter.md`: `RTP15c/enterclient-no-side-effects-0` builds a client + with `clientId: "*"` and expects `presence.enter()` to succeed, while + `RTP8j/enter-wildcard-clientid-errors-1` **in the same file** requires that exact call + to fail. No implementation can satisfy both. ably-python raises `AblyException` 40012 + (`ably/realtime/presence.py:99-104`), which is the RTP8j reading. +- `realtime_presence_reentry.md`: `RTP17g/reentry-publishes-enter-with-data-0` builds a + client with `clientId: "admin"` and calls `enterClient("alice", …)`, noting "Use a + concrete clientId and rely on server-side permission for enterClient". RTP15f requires + an identified client whose `clientId` does not match the argument to indicate an error + locally, so there is nothing for server-side permission to decide. ably-python raises + 40012 (`presence.py:229-234`), which is the RTP15f reading. + +**Tests affected:** `test_rtp15c_enterclient_no_side_effects` makes its "normal" enter as +`enter_client('main-client', 'main-client')`; `test_rtp17g_reentry_publishes_enter_with_data` +holds the wildcard clientId, the only clientId RTP15f permits `enterClient` for another +user from. Both then assert everything the specification is actually about, and both pass. + +### RTP18a's test cannot detect the non-compliance it targets + +**Spec point:** RTP18a, `presence_sync.md`, `realtime/unit/RTP18a/new-sync-discards-previous-1`. + +RTP18a requires that a new sync sequence identifier discard any in-flight sync, so that a +member delivered by the first sync but *absent* from the second is evicted. The test's two +syncs are nested rather than divergent: the map is pre-populated with `alice` and `bob`, +the first sync delivers **alice only**, and the second delivers **alice and bob**. Under +the compliant behaviour the second `startSync()` re-snapshots the residual set as +{alice, bob} and both are seen, so nothing is left over; under the non-compliant behaviour +the first sync's residual set {bob} carries across and bob is seen in the second sync, so +nothing is left over there either. Both paths give `leave_events.length == 0` and a map of +two, which is exactly what the test asserts. An SDK that ignores RTP18a entirely passes it. + +The neighbouring RTP18c test shows what this one needs: its sync **omits** bob, and so it +does discriminate. + +This one is worth fixing, because ably-python does ignore the requirement — +`PresenceMap.start_sync()` is guarded by `if not self._sync_in_progress` +(`ably/realtime/presencemap.py:255-262`), so a second start while a sync runs is a +complete no-op — and the test passes anyway. **To catch it, the second sync must omit a member +the first sync delivered**, and the test must then assert that member is gone. + +**Tests affected:** `test_rtp18a_new_sync_discards_previous`, derived as written and +passing without discriminating. The SDK-side finding is recorded under Failing Tests as a +deviation with no test, because there is no test to gate. + +### RTL2i asserts an attribute the features spec makes optional + +**Spec point:** RTL2i and TH6, `channel_state_events.md`, +`realtime/unit/RTL2i/has-backlog-flag-true-0`. + +Both RTL2i and TH6 word `hasBacklog` as optional — "may optionally expose", "may contain +an attribute" — and the test asserts it unconditionally, so an SDK that takes up the +option not to expose it cannot pass. The sibling `has-backlog-flag-false-1` gets this +right: its assertion is the disjunction "`hasBacklog == false` OR `hasBacklog IS null`", +which a missing attribute satisfies. + +**Tests affected:** `test_rtl2i_has_backlog_flag_true` is gated as a Failing Test, since +exposing the flag is the behaviour worth having; `test_rtl2i_has_backlog_flag_false` is +derived as the disjunction and passes. The specification should either make the assertion +conditional the way its sibling does, or `features.md` should make the attribute mandatory. + +### Sections carrying no Test ID were not derived + +A section with no `**Test ID**` gets no test — there is nothing to name it by, and a +derived test that invents an id cannot be traced back. Three realtime specifications are +in this position: + +| Spec | Sections with no Test ID | +|---|---| +| `client/realtime_client.md` | three runnable pseudocode blocks in its two trailing sections, `## Shared Options (Reference to REST Client Tests)` and `## Connection URL Query Parameters`: `#### TLS Setting (RSC18) in Realtime`, `#### useBinaryProtocol in Realtime` and `### Standard Query Parameters`. The third trailing section, `## Test Infrastructure Notes`, is prose and wants no test | +| `channels/channel_annotations.md` | RTAN3a | +| `presence/realtime_presence_history.md` | RTP12d, which the header's `Spec points` line lists | + +Each has a setup and assertions, so they read as tests that lost their ids rather than as +prose never meant to carry one. `channel_detach.md`'s id-less `## [REMOVED] RTL5 - Detach +clears errorReason` is the one deliberate case, and says so in its body. RTP12d is the +exception worth arguing: `features.md` +describes it as a multi-client test made against the service, which is not a unit test, so +there it is the header reference that should go. The equivalent REST fault is recorded +above — six sections under `uts/rest/unit` also carry no Test ID. + +### `channel_options.md`'s header omits seven of its own spec points + +The header reads `Spec points: TB2, TB3, TB4, RTS3b, RTS3c, RTS3c1, RTS5, RTL16`, and the +file then carries sections for **TB2c, TB2d, RTL16a, RTS5a, RTS5a1, RTS5a2 and DO2a** as +well. No test is affected — the derived module docstring lists all of them — but the header +is what a reader uses to find coverage. Label fault only. The file is also the only one +under `channels/` with no `## Mock Infrastructure` section, which is consistent with the +three setups below that attach on a client that never connected. + +### Fixtures that cannot produce the condition they describe + +Each of these carries a sound assertion on an unreachable premise. The derived test +corrects the fixture, keeps every assertion, and carries a `# UTS SPEC ERROR:` comment at +the site. + +| Spec point | Fixture | Why it cannot hold | +|---|---|---| +| RTN25 (`error_reason_test.md`), RTN14e (`connection_open_failures_test.md`) | `DEFAULT_CONNECTION_STATE_TTL = 5000` | `connectionStateTtl` defaults to 120 s (DF1a) and is otherwise a `connectionDetails` value. Neither test ever connects, so nothing can supply 5000; it is a value the fixture wishes for, and RTN14e's own comment says so — "For this test, we'll use a short default value". Both derived tests advance past the SDK's own default instead, which on notional time costs nothing | +| RTL6c4, RTN7e (`channel_publish.md`) | `ClientOptions(connectionStateTtl: 5000)` | There is no such client option. DF1a makes it a default and CD2f a `ConnectionDetails` field; `features.md` lists it under `Defaults`, not `ClientOptions`. The specification should set it in the CONNECTED `connectionDetails` — though see the RTN21 deviation, which means even that would not shorten it here | +| RTN24 (`update_events_test.md`) | the second CONNECTED changes `clientId` from `client-original` to `client-updated`, then asserts the connection is still CONNECTED | RTN24 names the details it overrides as *operational* parameters; silently re-identifying an already-identified connection is not one of them. ably-python treats it as an incompatible clientId and goes FAILED with 40102, so the fixture's two assertions cannot both hold here. Note the strict citation is RSA7b3, which governs a `connectionDetails.clientId` and mandates no FAILED transition — RSA15c is scoped to auth requests carrying a `TokenDetails` or `TokenRequest`, so whether the SDK is *right* to fail is a separate question from whether the fixture is sound. The derived test holds the clientId and asserts the operational-parameter override the test is actually about | +| RTL13b (`channel_server_initiated_detach.md`) | `realtimeRequestTimeout: 100`, `channelRetryTimeout: 200`, `ADVANCE_TIME(150)`, `ADVANCE_TIME(250)`, then `AWAIT_STATE channel.state == attaching` | The advance windows are too tight for the state they assert, in two different ways. Against ably-python, whose retry timer is the flat `channelRetryTimeout` (no RTB1 backoff or jitter), SUSPENDED is entered at t=100, the retry falls due at t=300 and its attach times out at t=400 — precisely the end of the 250 ms window, so the observed state turns on whether a timer due exactly on the boundary fires. Against a **conforming** SDK the same window is worse: RTB1's backoff and jitter put the second cycle's retry somewhere in 213–267 ms, so `ADVANCE_TIME(250)` clears it only about seven times in ten and the test is flaky by construction. The derived test asserts the specification's own `attach_count == 3` at that point instead. Upstream should widen the gap between the two timeouts, and size the windows for the jittered upper bound | +| RTL4b (`channel_attach.md`) | `channelRetryTimeout: 100` ("short timeout for testing"), then `AWAIT_STATE client.connection.state == suspended` | `channelRetryTimeout` governs channel retries, not the connection's suspend timer, which runs for `connectionStateTtl`. The test does not enable fake timers either, so on real time it would wait out two minutes. Derived with a `FakeClock`, passing the named option through unchanged | +| RTP5f, RTL11 (`realtime_presence_channel_state.md`) | `simulate_disconnect()` then `AWAIT_STATE channel.state == suspended` | A transport drop reaches DISCONNECTED. RTL3c propagates SUSPENDED to channels only from a SUSPENDED *connection*, so the awaited state never arrives. RTP5f's own note ("e.g. connection transitions to SUSPENDED") says as much; the steps do not carry it out | +| RTP5a (`realtime_presence_channel_state.md`) | detach, then `presence.get(waitForSync: false).length == 0` | RTP11e has `get` run the ensure-active-channel procedure for any state but SUSPENDED, so the read-back re-attaches the channel and the specification's own server then repopulates the very map being checked for emptiness. The derived test reads the two maps directly | +| RTS3c1, RTL16a (`channel_options.md`), RTS4a (`channels_collection.md`) | `autoConnect: false`, no mock installed, `connect()` never called, then `AWAIT channel.attach()` and assert ATTACHED | RTL4b requires `attach()` to fail unless the connection is CONNECTING, CONNECTED or DISCONNECTED, and with no mock nothing would answer the ATTACH in any case. Derived with a mock that connects and answers each ATTACH. Upstream should give these three setups a mock, as the sibling sections of the same files do | +| RTS3c1 `error-reattach-modes-1` (`channel_options.md`) | `# Put channel in attaching state (implementation detail)` | The premise the test turns on is the one step it does not give, and the setup has no mock to reach ATTACHING with | + +### Fixtures written against mock methods the contract does not define + +`uts/realtime/unit/helpers/mock_websocket.md` is the contract. These call members it does +not have. + +| Spec point | Written | The contract offers | +|---|---|---| +| RTL5l (`channel_detach.md`) | `conn.respond_with_connected()`, and assigning `mock_ws.active_connection = conn` | `respond_with_success(connected_message: ProtocolMessage)`. `respond_with_connected` appears nowhere else in `uts/`, and is called here with no argument, so no CONNECTED would reach the client even if it existed. Assigning `active_connection` is a second fault — see the note below on members the contract never declares | +| RTN13d (`connection_ping_test.md`) | `mock_ws.active_connection.close_from_server()` | `simulate_disconnect()`, which is the server ending the connection without a protocol message | +| RTN16f, RTN16j (`connection_recovery_test.md`) | `mock_ws.events.filter(e => e.type == "ws_frame" AND e.direction == "client_to_server")` | `MESSAGE_FROM_CLIENT`. There is no `ws_frame` type and no `direction` field, and every other specification in the suite reads `MESSAGE_FROM_CLIENT` | +| RTN17f (`fallback_hosts_test.md`) | `conn.respond_with_error("Host unresolvable")`, under a comment reading "Primary domain: unresolvable (simulated)" | `respond_with_error` *establishes* the connection and has the server send an ERROR `ProtocolMessage`, and takes a message rather than a string. The condition meant is RSC15l's host-unreachable, which the contract spells `respond_with_dns_error()` | + +Four further names are used across the realtime specifications without ever being +declared by the mock contract, which is a gap in `helpers/mock_websocket.md` as much as in +the tests that lean on it: + +- **`mock_ws.active_connection`** is used by dozens of tests and appears in + `mock_websocket.md` only inside two usage examples; `interface MockWebSocket` does not + declare it. `fallback_hosts_test.md` also calls `mock_ws.active_connection.close()`, + which is neither declared nor equivalent to anything that is. +- **`MockEvent` has no `connection` or `message` field** — only `type`, `timestamp` and + `data` — yet `connection_recovery_test.md` reads `…find(e => e.type == CONNECTION_SUCCESS).connection` + and `f.message.action`, and `connection_failures_test.md` does the same. +- **`MockWebSocketClient`** appears once, in `channel_history.md`; every other file uses + `MockWebSocket`. +- **`create_realtime_client(...)`** appears in `connection_open_failures_test.md` and + `channel_detach.md`; every other file writes `Realtime(options: …)`. + +This harness implements `active_connection` and gives `MockEvent` the fields the tests +want, so none of these costs a test. They are recorded because the contract is the thing +a new SDK derives against, and four of its most-used members are not in it. + +### A specification note instructs SDKs to suppress a mandatory check + +`realtime_presence_enter.md`'s header note tells implementers to skip the client-side +RTP15f `enterClient` clientId-mismatch check so that the file's fixtures pass. A UTS +specification asking an SDK to drop a `features.md` requirement in order to be testable is +the wrong way round: the fixtures should change. It is the same wildcard problem as the +RTP15c/RTP8j contradiction above, and settling that settles this. + +### RTN17i names a primary domain REC1 no longer produces + +`RTN17i/prefer-primary-domain-0` asserts `connection_attempts[0].host == "realtime.ably.io" +OR … CONTAINS "realtime.ably"`. REC1 derives the primary domain from the endpoint, which +defaults to `main`, giving `main.realtime.ably.net`. The disjunct saves the assertion, so +the test still means what it should, but the first branch can never hold for an SDK that +implements REC1. `test_rtn17i_prefer_primary_domain` asserts equality with the REC1 domain +and passes. + +### Three connection setups credit a key-authenticated client with an initial token request + +**Spec points:** RTN15h2 (`token-error-renew-success-0`), RTN15c5 +(`token-error-during-resume-0`), RTN14b (`token-error-with-renewal-0`). + +Each sets the client up with `ClientOptions(key: "appId.keyId:keySecret")` alone, stubs +`/keys/…`, and then asserts `token_request_count == 2 # Initial + renewal`. RSA4 has a +client given only a key authenticate with basic auth; token auth is selected by +`useTokenAuth`, a `clientId`, an `authCallback`, an `authUrl` or a supplied token, and none +of the three setups does any of that. No SDK makes an initial token request here, so the +renewal is the first and only one — the assertion contradicts the specification's own +setup, not just ably-python. + +All three derived tests assert `len(token_requests) == 1`, carry a `# UTS SPEC ERROR:` +comment, and pass; the assertion the specification is really making — that the token was +renewed — is preserved. + +RTN14b's sibling `token-renewal-fails-1` has a second fault: its `onConnectionAttempt` +sends an ERROR `ProtocolMessage` without first calling `respond_with_success()`, and the +contract produces a `MockConnection` only once an attempt is answered, so the message can +reach nobody. Every other test in the same file establishes the connection first; the +derived test answers the attempt with `respond_with_error`, which does both. + +### Two specifications assert opposite things about RSA4c3's `errorReason` + +`connection_auth_test.md` (`RSA4c3/callback-error-stays-connected-0`) asserts that an +authCallback failure during an RTN22 reauth leaves `connection.errorReason` set to an +80019 whose `cause` is the callback's error. `auth_callback_errors_test.md` +(`RSA4c3/callback-error-connected-stays-0`) asserts the opposite in its own words — +"errorReason is NOT set … the auth failure is silently swallowed" — citing +[specification#466](https://github.com/ably/specification/issues/466). + +`features.md` as it stands backs the first: RSA4c1 says an ErrorInfo with code 80019 +"should be emitted with the state change if there is one (per RSA4c2/3) **and set as the +connection errorReason**". Both are derived as written. +`test_rsa4c3_callback_error_stays_connected` is gated as a Failing Test, because the +current `features.md` makes it the spec-correct reading, and +`test_rsa4c3_callback_error_connected_stays` passes, because ably-python happens to behave +the way #466 proposes. Neither fails fast: the contradiction is between two UTS specs and +an unlanded features change, not an assertion `features.md` flatly refutes. Whichever way +#466 lands, one of the two has to be regenerated from the corrected spec. + +### RTN19a2's assertion cannot distinguish the behaviours it separates + +`RTN19a2/new-serial-failed-resume-1` publishes two messages, which take `msgSerial` 0 and +1, then asserts that after a **failed** resume the resent messages carry 0 and 1 — the same +values a **successful** resume preserves, and exactly what its paired test +`RTN19a2/same-serial-on-resume-0` asserts. An SDK that ignored RTN15c7's counter reset +entirely would pass both. Publishing a third message after the reconnect and asserting +*its* `msgSerial` is what would separate them. + +The test is derived as written and passes, and is not made fail-fast: the specification is +under-determined rather than contradicted by `features.md`, so there is still a correct +(if weak) assertion to make. The SDK-side finding it would have caught is real and is +recorded under Failing Tests. + +### RTC12 points at a specification file that does not exist + +`realtime_client.md` RTC12 `constructor-string-detection-0` says +"**See:** `uts/test/realtime/unit/client/client_options.md` - RSC1, RSC1a, RSC1c", and +"The same test cases apply". No such path exists — `uts/test/…` is not a directory — and +no RSC1, RSC1a or RSC1c test is declared anywhere under `uts/rest/unit`, so the referenced +cases cannot be reused because they were never written. The same section's second +reference, `RTC12/invalid-arguments-error-1` → `auth_scheme.md` RSC1b, does resolve. + +`test_rtc12_constructor_string_detection` is derived from the three cases the specification +lists in its own body — API key string, token string, empty string — with a `# NOTE:` at +the site recording the broken reference. Either write the RSC1/RSC1a/RSC1c tests and fix +the path, or drop the reference and keep the inline cases as the definition. + +### Duplicated, misfiled and mis-commented realtime tests + +| Spec | Fault | +|---|---| +| `auth_callback_errors_test.md` | `RSA4e/rest-callback-error-40170-0` drives a REST client and a mocked HTTP client but takes a `realtime/unit/` Test ID. The same class of fault as `fallback.md`'s REC3a/REC3b/REC3, which drive a Realtime client from `rest/unit`. Derived where its Test ID puts it, and it passes | +| `connection_auth_test.md`, `auth_callback_errors_test.md` | RSA4c2 is the same test in both files: `callback-error-causes-disconnected-0` and `callback-error-connecting-disconnected-0` have the same authCallback, the same mock and the same four assertions, and the second adds only `useBinaryProtocol: false` and a `state_changes` listener. The closing note of `auth_callback_errors_test.md` acknowledges the overlap without removing it. Both are derived, since each has its own Test ID | +| `channel_properties.md` | `RTL15b/serial-not-updated-irrelevant-3`'s closing comment reads "RTL15b2 clears it on DETACHED/FAILED, then ATTACHED sets it fresh". The DETACHED it injects arrives while the channel is ATTACHED, so RTL13a reattaches and the DETACHED *state* is never entered. Nothing clears the serial; it is simply never written from the DETACHED message. The assertion the comment sits above is still the right one | + ### Smaller faults | Spec | Fault | |---|---| | `options_types.md` | The `TO/endpoint-affects-host-0` "Expected Rest Host" column uses pre-REC1 hostnames (`rest.ably.io`, `test-rest.ably.io`). No assertion reads the column, so the derived test is unaffected | -| `channels_collection.md` | Header claims RSN3b and RSN3c; neither has a test | +| `channels_collection.md` (rest) | Header claims RSN3b and RSN3c; neither has a test | | `stats.md` | Fixture nests counts under `all`, which `Stats.from_dict` never reads | | `rest_client.md` | `RSC17` has two byte-identical tests; header lists RSC7 and RSC7b with no tests | | `rest_client.md` | `RSC18` requires constructor-time failure; RSA1/RSC18 only say "any attempt to use" | @@ -213,21 +489,664 @@ the mark is the only change needed once the SDK behaviour lands. ### Unimplemented features +Nothing to fix here, only something to build. Each row is one feature, and the test +count is the number of gated tests that fall with it. + | Spec points | Missing | Tests | |---|---|---| | RSC22, RSC24, BSP2, BPR2, BPF2, BAR2, BGR2, BGF2 | `batchPublish` and `batchPresence`, and all six result types. `grep -rn batch ably/` finds nothing | 41 | | RSA17, RSA17b–g, BAR2, TRS2, TRF2 | `Auth#revokeTokens`, `TokenRevocationTargetSpecifier`, `BatchResult` | 17 | | RSH7, RSH7a–e, RSH6, RSH8 | `PushChannel`: `channel.push`, `client.device`, `LocalDevice`. The push *admin* surface (RSH1) does exist | 10 | -| RSL7 | `RestChannel#setOptions`. The realtime channel implements it; the REST `options` setter expects the kwargs dict `Channels.get` collected, so a `ChannelOptions` raises `TypeError` | 2 | -| RSP3a2, RSP3a3 | `clientId` and `connectionId` filters on `RestPresence#get`. `Presence.get` takes only `limit`, while `Presence.history` does take its documented params | 3 | -| TP5 | `size` on `PresenceMessage`. The related `maxMessageSize` gap is adapted rather than gated, below; `features.md` TM6 has no UTS test | 1 | -| RSL1i | REST publish never calls `validate_message_size`. The helper exists and is correct, but only `ably/realtime/channel.py:423` calls it, so an oversized REST publish goes out | 1 | +| RTN16, RTN16f–k, RTC1c (TO3i) | Connection recovery, entire. `recover` is in the `Options` signature, stored, and given a property and a setter (`options.py:30,111,193,196`), and read nowhere. No `Connection#createRecoveryKey`, no `recover` connect parameter, no recovery-key decoding | 6 | +| RTL22, RTL22a–d, MFI1, MFI2a–e | `MessageFilter`. `RealtimeChannel.subscribe` (`channel.py:262-273`) accepts only a `str` or a callable, and there is no filter type of any shape to spell. Each test builds its filter through the module's `message_filter()` helper, which is the one place to repoint when the type lands | 5 | +| RTS5, RTS5a, RTS5a1, RTS5a2, DO2a | Derived channels: `DeriveOptions` and `Channels.getDerived`. `grep -r derive ably/` is empty. Each test imports `DeriveOptions` inside its body so the module still loads | 5 | +| RTB1, RTB1a, RTB1b | Retry backoff, jitter and `retryIn`. Retry timers schedule the flat configured timeout (`connectionmanager.py:753`, `channel.py:866-871`); `grep` for jitter/backoff/retry_in returns nothing, and neither `ConnectionStateChange` nor `ChannelStateChange` carries `retryIn` | 4 | +| RTL25, RTL25a, RTL25b | `RealtimeChannel#whenState`. `Connection._when_state` exists (private, awaitable), so this is a gap on the channel rather than a house style; the tests are written against a `channel.when_state(state)` matching the shape the connection already has | 4 | | RSC2, RSC3, RSC4, TO3b, TO3c, TO3c2 | `log_handler` as a client option, and any use of `log_level` — it is stored on `Options` and read by nothing | 4 | -| TI4, TI1/TI5 | `href` anywhere in the SDK, and `cause` when deserialising. `AblyException.from_dict` and `raise_for_response` read only `message`, `statusCode` and `code`, so both fields are dropped from server errors | 2 | +| RSP3a2, RSP3a3 | `clientId` and `connectionId` filters on `RestPresence#get`. `Presence.get` takes only `limit`, while `Presence.history` does take its documented params | 3 | | TP3a, TP3d, TP3g | Presence attributes defaulted from the encapsulating ProtocolMessage. There is no ProtocolMessage type; `ably/realtime/channel.py:751-761` passes the presence array through without context. Matters for synthesized-leave detection and `memberKey` | 3 | +| TB4, RTL7h, RTP6e | `attachOnSubscribe`. `ChannelOptions.__init__` (`channeloptions.py:22-26`) takes only `cipher`, `params` and `modes`, and `subscribe()` on the channel, on presence and on annotations all end unconditionally with `await attach()`. This absence also forces the largest single adaptation in the suite, below | 3 | +| RSL7 | `RestChannel#setOptions`. The realtime channel implements it; the REST `options` setter expects the kwargs dict `Channels.get` collected, so a `ChannelOptions` raises `TypeError` | 2 | +| RTC1a (TO3h), RTL7f | `echoMessages`, in both the forms RTL7f allows. There is no `echo_messages` client option — passing one raises `TypeError` — and no `echo` connect parameter, so every message the server sends is delivered whatever its `connectionId` | 2 | +| RTP12, RTP12a, RTP12c | `RealtimePresence#history`. The realtime *channel* does delegate `history` to the REST implementation; only the presence object is missing it | 2 | +| RTN23c1, RTN23c2 | PING/PONG. `ProtocolMessageAction` stops at `ANNOTATION` (21), so PING (22) and PONG (23) are not modelled and action 22 matches no branch of `on_protocol_message` (`websockettransport.py:37-59`, `:143-199`). The message is counted as activity and discarded | 2 | +| TI4, TI1/TI5 | `href` anywhere in the SDK, and `cause` when deserialising. `AblyException.from_dict` and `raise_for_response` read only `message`, `statusCode` and `code`, so both fields are dropped from server errors | 2 | +| RTN23a | The `heartbeats` connect parameter. The full parameter set is `{key\|accessToken, v, format, resume?, …transport_params}`; `grep -r heartbeats ably/` finds nothing. RTN23a is the branch that binds ably-python, because it cannot observe ping frames | 1 | +| RTL10b | `untilAttach` on `RealtimeChannel#history`. The realtime channel does not override `history`, so the call lands on `Channel.history`, which takes only `direction`, `limit`, `start` and `end`. No `fromSerial` is ever sent, and the attach serial the channel does record is private and read nowhere | 1 | +| TB3 | `ChannelOptions.withCipherKey`. The nearest equivalent, `ably.util.crypto.get_default_params({'key': key})`, is not reachable from `ChannelOptions` | 1 | +| RTL2i, TH6 | `hasBacklog` on `ChannelStateChange`. `Flag.HAS_BACKLOG` is defined (`types/flags.py:7`) but `_on_message` reads only RESUMED and HAS_PRESENCE (`channel.py:715-721`). See the UTS Spec Error above: the features spec makes this optional | 1 | +| RTP6b | Subscribing to an *array* of presence actions. The list reaches `EventEmitter.on` and pyee uses the event as a dict key, so `presence.subscribe([ENTER, LEAVE], listener)` raises `TypeError: unhashable type: 'list'`. A fix has to fan the list out into one registration per action, and `unsubscribe` with it | 1 | +| RSL1i | REST publish never calls `validate_message_size`. The helper exists and is correct, but only `ably/realtime/channel.py:423` calls it, so an oversized REST publish goes out | 1 | +| TP5 | `size` on `PresenceMessage`. The related `maxMessageSize` gap is adapted rather than gated, below; `features.md` TM6 has no UTS test | 1 | + +### Connection + +#### A connection-level ERROR bypasses everything that matters on a failure — 4 tests + +**Spec points:** RTL3a, RTN7e. + +`ConnectionManager.on_error` ends with `enact_state_change(ConnectionState.FAILED, exception)` +(`connectionmanager.py:477`) rather than `notify_state`. Everything a connection failure +has to do lives in `notify_state`: `cancel_transition_timer` (`:664`), +`fail_queued_messages` (`:689`) and `channels._propagate_connection_interruption` +(`:690`). An ERROR `ProtocolMessage` — the commonest way a connection actually fails — +skips all three. + +| Consequence | Spec point | +|---|---| +| ATTACHING and ATTACHED channels are not failed. They stay as they were, `error_reason` stays null, no state change is emitted, and a pending `attach()` never returns | RTL3a | +| Pending publishes neither resolve nor reject. The publish hangs for good | RTN7e | +| The transition and suspend timers are left running | RTN14, RTN21 | + +Every *other* route to FAILED goes through `notify_state` and behaves correctly — an +incompatible `clientId` (`:422`) and the two authorize failures (`:483`, `:487`) — so this +is specific to the ERROR path. Two batches found the two halves independently; **it is one +issue, not two.** + +**Tests affected:** `test_rtl3a_failed_attached_to_failed` and +`test_rtl3a_failed_attaching_to_failed` fail on `assert channel.state == ChannelState.FAILED` +with `attached` and `attaching`; `test_rtn7e_pending_fail_failed` and +`test_rtn7e_error_represents_reason` fail with `asyncio.TimeoutError` from the bounded await +on the publish. `test_rtl3a_other_states_unaffected` passes, but only because nothing happens +at all; it becomes a real test of RTL3a once this is fixed. + +Note that `client.connection.error_reason` *is* populated correctly with the ERROR's +80019/400, so the reason RTN7e asks for is in hand at the point a fix would need it. The +code comment at `connectionmanager.py:411` also justifies omitting a `msgSerial` reset with +"we fail all pending messages on disconnect per RTN7e" — which this defect makes false. + +**Status:** open bug. + +#### The connection id, key and details are cleared on SUSPENDED — 2 tests + +**Spec points:** RTN8d, RTN9d, RTN14h. + +`features.md` makes `Connection#id` and `Connection#key` null "when the SDK is in the +`CLOSED`, `CLOSING`, or `FAILED` states". RTN8c/RTN9c, which also cleared them in +SUSPENDED, were replaced as of specification 6.1.0, because the client always attempts a +resume on reconnecting (RTN14h) and lets the server decide whether continuity survives. + +`ConnectionManager.enact_state_change` (`connectionmanager.py:181-189`) clears the +connection details, the connection id, the connection key and `msg_serial` on entry to +SUSPENDED as well, under a comment citing RTN16d — which is about *recovery keys* being +invalidated, not about suppressing resume: + +```python +if state == ConnectionState.SUSPENDED or state in (ConnectionState.CLOSED, ConnectionState.FAILED): + self.__connection_details = None + self.connection_id = None + self.__connection_key = None + self.msg_serial = 0 +``` + +Because `__get_transport_params` adds `resume` only `if self.connection_details`, the +second consequence is that a suspended connection stops resuming. Measured over 72 attempts +in 150 s of notional time: 60 carried `resume`, and the 12 that did not were every attempt +made after the suspend timer fired. + +**Tests affected:** `test_rtn8d_id_key_retained_in_suspended` (`assert None == 'conn-id-1'`) +and `test_rtn14h_resume_after_ttl` (`KeyError: 'resume'`). One fix — clearing only on CLOSED +and FAILED — closes both. + +**Status:** open bug. + +#### A DISCONNECTED carrying a 5xx with no fallback hosts stalls the connection — 1 test + +**Spec point:** RTN15h3. + +A DISCONNECTED whose error is not a token error must trigger an immediate reconnect with a +resume attempt. Instead nothing happens at all: the connection stays CONNECTED, no further +attempt is made, no state change is emitted, and the client is left believing it is +connected to a socket the server has closed. + +`ConnectionManager.on_disconnected` (`connectionmanager.py:437-450`) routes any +`500 <= status_code <= 504` to RTN17f1's fallback-host path. With `self.__fallback_hosts` +empty it logs "No fallback host to try for disconnected protocol message" and falls out of +the `if`/`elif` chain **without calling `notify_state`**. There is no path back to +DISCONNECTED. Any client with no fallback hosts — a custom endpoint, a local cluster, or +the empty list these unit tests use — is stranded. The specification's own fixture uses +`code: 80003, statusCode: 503`. + +**Tests affected:** `test_rtn15h3_non_token_error_resume` — +`Timed out waiting for connection state connecting; it was connected`. + +**Status:** open bug. This is the most serious connection-level defect found. + +#### The UPDATE event drops the CONNECTED message's error — 1 test + +**Spec point:** RTN24. + +The `Connection` must emit UPDATE with a `ConnectionStateChange` whose `reason` is the +`error` member of the CONNECTED `ProtocolMessage`. The error is parsed off the wire, passed +into `on_connected` as `reason`, and then discarded on the already-connected branch +(`connectionmanager.py:425-428`): + +```python +state_change = ConnectionStateChange(ConnectionState.CONNECTED, ConnectionState.CONNECTED, + ConnectionEvent.UPDATE) +self._emit(ConnectionEvent.UPDATE, state_change) +``` + +The `reason=exception` parameter is used only on the `notify_state` branch below it. + +**Tests affected:** `test_rtn24_update_event_with_error` — `assert None is not None`. It +also leaves `Connection#errorReason` unset for the RTN15c7 failed-resume case, which RTN25 +lists among the errors that must set it. + +**Status:** open bug, and a one-line fix: pass `reason=exception` into the +`ConnectionStateChange`. + +#### `ping()` rejects DISCONNECTED instead of deferring, and charges the wait to the caller — 3 tests + +**Spec points:** RTN13b, RTN13c, RTN13d. + +Two defects in the same method. + +- RTN13b errors only for INITIALIZED, SUSPENDED, CLOSING, CLOSED and FAILED, and RTN13d + defers a ping requested while CONNECTING *or DISCONNECTED* until the connection is + CONNECTED. `ConnectionManager.ping` (`connectionmanager.py:362`) admits only CONNECTED + and CONNECTING and raises `AblyException("Cannot send ping request. Calling ping in + invalid state", 400, 40000)` for DISCONNECTED. Note that + `deferred-ping-error-suspended-5` would otherwise pass for the wrong reason: its only + stated assertion is that an error arrives, and one does — just immediately, rather than + when the connection suspends. +- RTN13c with RTN13d requires a deferred ping's timeout to run "from when the HEARTBEAT is + actually sent, not when `ping()` is called". `ping()` enters + `asyncio.wait_for(pending_ping.future, self.__timeout_in_secs)` (`:375`) as soon as it is + called, so the whole CONNECTING period is charged against it and a ping requested while + connecting can expire before its HEARTBEAT has gone out. + +**Tests affected:** `test_rtn13d_ping_deferred_disconnected` and +`test_rtn13b_deferred_ping_error_suspended` fail with "the ping errored instead of waiting +for the connection"; `test_rtn13c_deferred_ping_timeout` fails with +`assert 0.096… >= (0.4 * 0.9)` — the error arrived 96 ms after CONNECTED where a 400 ms +`realtimeRequestTimeout` should have run from that point. + +**Status:** open bug. + +### Channels + +#### `detach()` never returns when the connection is not CONNECTED — 2 tests + +**Spec point:** RTL5l. + +When the connection is in any state other than CONNECTED and no earlier channel-state +condition applies, the channel must transition immediately to DETACHED. Instead `detach()` +requests DETACHING, `_check_pending_state()` returns without sending anything because the +connection is not CONNECTED, and `detach()` then awaits the internal state emitter +(`channel.py:212`) for a transition nothing will produce. **The coroutine never returns** +and the channel is left in DETACHING. + +**Tests affected:** `test_rtl5l_detach_not_connected_immediate` and +`test_rtl5l_detach_attached_when_disconnected`, each with a one-second `asyncio.wait_for` +so the hang is reported as a failure rather than a stuck run. Both fail with +`asyncio.exceptions.TimeoutError`. In the second, the assertions that set the scene — the +connection settling in DISCONNECTED with the channel still ATTACHED — pass first, so the +failure is unambiguously the detach. + +**Status:** open bug. A hang, not a wrong value. + +#### `set_options()` never returns for an already-ATTACHED channel — 1 test + +**Spec point:** RTL16a. + +When `params` or `modes` are supplied to `setOptions` on an attached channel, the channel +must reattach, pass through ATTACHING, return to ATTACHED, and `setOptions` must resolve. +Instead it hangs, and the root cause is a single missing call. + +`set_options` (`channel.py:93-102`) calls `_attach_impl()` and then awaits +`self.__internal_state_emitter.once_async()`. `_attach_impl()` sends the ATTACH **without +going through `_request_state(ChannelState.ATTACHING)`**, so the channel is still ATTACHED +when the server's ATTACHED arrives. `_on_message` therefore takes the RTL12 branch +(`:722-726`), which emits `update` on the *public* emitter and returns. The internal state +emitter is written only by `_notify_state` (`:821`), which that branch never reaches, so +the await has nothing to wake it. Both halves of the defect — no ATTACHING transition, and +no internal event — follow from the one missing `_request_state`. + +Measured: the second ATTACH is sent, the server's ATTACHED is received, the options *are* +stored, and `asyncio.wait_for(channel.set_options(...), 1.0)` raises `TimeoutError`. + +**Tests affected:** `test_rtl16a_triggers_reattach`. It also constrains +`test_rtl4c1_includes_channel_serial` and `test_rtl4j_attach_resume_flag_not_set`, which run +`set_options` as a task and cancel it — recorded under Adapted Tests. + +**Status:** open bug. A hang, not a wrong value. + +#### A server-initiated DETACHED discards its error, and the pending call then raises `TypeError` — 2 tests + +**Spec points:** RTL24, RTL4c, with RTL13a and RTL13b. + +An attach rejected by a DETACHED carrying an `ErrorInfo` must fail with that error and leave +`channel.errorReason` holding it. Two faults compound: + +1. `_on_message` answers a DETACHED received while ATTACHING with + `self._notify_state(ChannelState.SUSPENDED)` (`channel.py:735`), passing **no reason**, + so the message's error is dropped. The same shape applies to RTL13a's + `_request_state(ATTACHING)` and RTL13b's `_notify_state(SUSPENDED)`. +2. `attach()` then reaches `raise state_change.reason` (`channel.py:150`) with `reason` + `None`, which Python reports as `TypeError: exceptions must derive from BaseException` + rather than as an `AblyException`. The same unguarded `raise` is at `:102` in + `set_options` and `:219` in `detach`. + +**Tests affected:** `test_rtl24_error_reason_attach_failure` and +`test_rtl4c_error_cleared_on_attach`, both `TypeError: exceptions must derive from +BaseException` at `channel.py:150`. The clearing half of RTL4c is still covered by +`test_rtl4c_error_cleared_preserved_detach`, which sets the error with an ERROR message +instead and passes. Two adapted tests — `test_rtl13a_attached_reattach_triggered` and +`test_rtl13b_attaching_detached_to_suspended` — assert `reason is None` and the `TypeError` +respectively, and will need revisiting once the reason is carried through. + +**Status:** open bug, in two parts. The `raise None` is worth fixing on its own even before +the reason is plumbed through: a missing reason should give an `AblyException`, not a +`TypeError`. + +#### An ATTACHED received while DETACHING or DETACHED is ignored — 2 tests + +**Spec point:** RTL5k. + +An ATTACHED arriving while the channel is DETACHING or DETACHED must be answered with a new +DETACH, the channel remaining in or returning to DETACHING. `_on_message` handles ATTACHED +only for the ATTACHED (RTL12) and ATTACHING cases; every other state falls through to +`log.warn("ATTACHED received while not attaching")` and nothing is sent. While DETACHING +that leaves the detach to time out, so `detach()` raises "Channel detach timed out" and the +channel returns to ATTACHED. + +**Tests affected:** `test_rtl5k_attached_while_detaching` +(`AblyException: 90007 408 Channel detach timed out`) and +`test_rtl5k_attached_while_detached` (`Timed out waiting until a second DETACH`). + +**Status:** open bug. + +#### A detach requested while already DETACHING sends a second DETACH — 1 test + +**Spec point:** RTL5i. + +A detach requested while the channel is DETACHING must be performed after the pending +request completes, so only one DETACH reaches the server. `detach()` calls +`_request_state(DETACHING)` unconditionally; `_notify_state` returns early for a state the +channel already holds, but only *after* `__clear_state_timer()`, and `_request_state` then +calls `_check_pending_state()` anyway, which restarts the state timer and re-sends DETACH. + +**Tests affected:** `test_rtl5i_detach_while_detaching` — `assert 2 == 1`. + +**Status:** open bug. + +#### The deleted RTL4j ATTACH_RESUME flag is still set on every reattach — 1 test + +**Spec point:** RTL4j, deleted as of specification 6.1.0. + +The client must not set the ATTACH_RESUME flag (TR3f, bit 5) on any ATTACH, the server +having taken over the resumability decision. `RealtimeChannel._notify_state` sets +`__attach_resume` on every ATTACHED, and `_encode_flags` ORs `Flag.ATTACH_RESUME` into the +flags of every subsequent ATTACH, so the reattach carries `flags: 32`. + +**Tests affected:** `test_rtl4j_attach_resume_flag_not_set` — +`assert not (32 & )`. + +**Status:** open bug. + +#### Channel serial bookkeeping is wrong in four adjacent places — 4 tests + +**Spec points:** RTL15b, RTL15b2, RTL15c. + +Four separate lines in `RealtimeChannel._on_message` and `_notify_state`, all about the same +two fields, all fixable in one pass. + +| Fault | Site | Spec point | Test | +|---|---|---|---| +| `attachSerial` is taken from *every* ATTACHED, resumed or not, because it is assigned at `:708` before `flags` is read and `resumed` computed at `:716` | `channel.py:708` | RTL15c | `test_rtl15c_attach_serial_not_updated_resumed` (`assert 'resumed-serial' == 'initial-serial'`) | +| A PRESENCE message does not update `channelSerial`, unlike MESSAGE (`:743`) and ANNOTATION (`:772`) | `channel.py:751-755` | RTL15b | `test_rtl15b_channel_serial_from_messages` (`assert 'serial-002' == 'serial-003'`) | +| A message with **no** `channelSerial` **clears** the stored one, because `proto_msg.get('channelSerial')` is `None` and the assignment is unconditional. The ATTACHED (`:708-709`) and ANNOTATION (`:772`) branches have the same shape | `channel.py:743` | RTL15b | `test_rtl15b_serial_not_updated_empty` (`assert None == 'serial-001'`) | +| `channelSerial` is cleared on SUSPENDED as well as DETACHED and FAILED, under a comment naming the superseded RTP5a1, so the ATTACH sent after a suspend carries no serial for the server's RTL4c1 continuity decision | `channel.py:810-812` | RTL15b2 | `test_rtl15b2_serial_retained_suspended` (`assert None == 'serial-001'`) | + +RTL15b's requirement is that the serial is set from a protocol message "if and only if that +field is populated"; RTL15b2, as of specification 6.1.0, clears it on DETACHED or FAILED and +explicitly *not* on SUSPENDED. + +**Status:** open bug, four of them, one issue. + +#### Messages are delivered to a channel that is not ATTACHED — 1 test + +**Spec point:** RTL17. + +"No messages should be passed to subscribers if the channel is in any state other than +`ATTACHED`." The MESSAGE branch of `_on_message` (`channel.py:738-750`) decodes the array +and emits every message with no reference to `self.state`, and +`Channels._on_channel_message` (`:1028`) only checks that the channel exists. A message +arriving while the channel is ATTACHING, DETACHING, SUSPENDED or FAILED reaches subscribers +exactly as one arriving while ATTACHED does. + +**Tests affected:** `test_rtl17_no_delivery_when_not_attached` — `assert 1 == 0`. + +**Status:** open bug. + +#### `Channels.release` does not detach the channel — 1 test + +**Spec point:** RTS4a. + +Release "detaches the channel and then releases the channel resource". `Channels.release` +(`channel.py:1012-1026`) is `if name not in self.__all: return` followed by +`del self.__all[name]`, and sends nothing. An attached channel is dropped from the +collection while still attached in the Ably service, and the orphaned object stays in +ATTACHED. It overrides the REST implementation, which is correct for REST, without adding +the detach. + +**Tests affected:** `test_rts4a_release_detaches_attached` — `assert 0 == 1` on the +DETACH-message count. + +**Status:** open bug. + +#### A decode error other than 40018 has no channel-level handling — 1 test + +**Spec point:** PC3. + +A `vcdiff`-encoded message received by a client with no vcdiff plugin must put the channel +in FAILED with `errorReason.code == 40019`. The channel stays where it was and nothing is +reported on it. Two causes, both verified: + +1. `Message.from_encoded` (`message.py:302-305`) compares `extras.delta.from` against the + context's `last_message_id` **before** the decode pipeline runs. The specification's + message is the first the channel receives, so the stored id is null, the comparison + fails, and a **40018** is raised — the RTL18 recovery error, not the missing-plugin + error. The channel goes ATTACHING instead of FAILED. +2. Even reaching the missing-decoder branch, `channel.py:744-748` gives channel-level + handling to 40018 alone; every other decode error takes the `else` arm, which logs + "Message processing error … Skip messages" and skips the batch silently, with no state + change and no `error_reason`. + +**Tests affected:** `test_pc3_no_plugin_fails` — "Timed out waiting until the channel fails +for want of a vcdiff decoder". + +**Status:** open bug. The delta-reference check needs to run after the decoder-availability +check, and a decode error needs to fail the channel whatever its code. + +#### A message with no id in a ProtocolMessage with no id is given the id `"None:0"` — 1 test + +**Spec point:** TM2a. + +The `protocolMsgId:index` derivation applies only when the ProtocolMessage carries an `id`; +otherwise the message is delivered with no `id`. `Message.__update_empty_fields` +(`message.py:369-375`) writes `msg['id'] = f"{proto_msg.get('id')}:{msg_index}"` whenever +the message has no id, with no test for the parent having one, so a missing parent id is +interpolated as the literal string `None`. + +**Tests affected:** `test_tm2a_no_id_without_protocol_id` — `assert 'None:0' is None`. + +**Status:** open, and **already filed as ably-python#706**, which the REST suite raised for +the same line fabricating `"None:0"` for a presence message. This is the same defect reached +through the realtime `messages` array; one fix closes both. The presence-side consequence is +worth knowing: `"None:0"` does not start with the member's `connectionId`, so +`PresenceMessage.is_synthesized()` returns True and `_is_newer` takes the RTP2b1 timestamp +path instead of the RTP2b2 `msgSerial`/`index` path. + +### Presence + +#### `EventEmitter` keys its wrapper registry on the listener alone — 2 tests + +**Spec points:** RTL8b, RTP7b. + +`EventEmitter.on` stores the wrapper it built in `self.__wrapped_listeners[listener]` +(`util/eventemitter.py:85`), keyed on the **listener object alone** rather than on +`(event, listener)`. `once` does the same at `:130`. Registering one listener for a second +event overwrites the first entry, so the wrapper registered for the first event is no longer +reachable. `off(first_event, listener)` (`:166`) then hands pyee the *second* event's +wrapper for the first event, and `pyee.base.EventEmitter._remove_listener` does +`self._events[event].pop(f)`, which raises. + +Two further consequences of the same line: the first registration is left live, so the +listener keeps receiving that event; and `off` sets `self.__wrapped_listeners[listener] = None` +(`:167`) rather than deleting the entry, so any **later** `off` for that listener silently +does nothing. + +Reproducible in six lines with no Ably connection: + +```python +e.on('alpha', listener); e.on('beta', listener); e.off('alpha', listener) +# KeyError: .wrapped_listener> +``` + +This is an `EventEmitter` defect, not a channel or presence one. It affects `connection.on`, +`channel.on`, `channel.subscribe` and `presence.subscribe` equally, and three batches +reported it independently. It also constrains the suite: **no derived test may register one +listener function for two events**, which is why the presence helpers register a separate +function per event name. + +**Tests affected:** `test_rtl8b_unsubscribe_named_listener` and +`test_rtp7b_unsubscribe_for_specific_action`, both `KeyError` out of +`pyee/base.py:262`. + +**Status:** open bug. The registry needs to be keyed on `(event, listener)`, and to hold a +list per key so that a listener registered twice for one event can be removed once. + +#### A LEAVE during a SYNC emits three `leave` events for one member — 1 test + +**Spec points:** RTP2h2a, RTP2h2b. + +A LEAVE received while a SYNC is in progress is stored as ABSENT and nothing is emitted; at +`endSync` the ABSENT entry is deleted silently, and only members never seen during the sync +earn a synthesized LEAVE. Measured instead: +`[('present', 'alice'), ('leave', 'bob'), ('leave', 'bob'), ('leave', 'bob')]`. + +Three separate places: + +1. `PresenceMap.remove()` (`presencemap.py:186-196`) returns `True` for the ABSENT store + exactly as it does for a deletion, and `RealtimePresence.set_presence()` + (`presence.py:552-554`) broadcasts on the strength of that return value with no test of + `sync_in_progress`. That is the first LEAVE, during the sync. +2. `PresenceMap.remove()` does not take the member out of `_residual_members`, so a member + that left during the sync is still a residual at `end_sync` (`presencemap.py:296-305`). + That is the second. +3. `set_presence()` synthesizes a LEAVE for `residual + absent` (`presence.py:575-587`), + where the `absent` list exists so the caller can *delete* those members, not announce + them. That is the third. + +**Tests affected:** `test_rtp2h2a_leave_during_sync_absent_cleanup` — `assert leaves(events) == []` +fails on the first of the three. The ABSENT storage itself is correct and is covered ungated +by `test_rtp2h2a_leave_during_sync_stores_absent` and `test_rtp2h2b_absent_deleted_on_endsync`. + +**Status:** open bug. + +#### The RTP17 map runs the newness check across connectionIds — 1 test + +**Spec points:** RTP17h, with RTP2a. + +The RTP17 map is keyed only by `clientId`, expressly so that "entries associated with old +`connectionId`s would never be removed" cannot happen. An ENTER for `user-1` on `conn-B` +must therefore replace the entry for `user-1` on `conn-A`. It does not: `_my_members` is a +plain `PresenceMap` with `client_id` as its key function (`presence.py:79-81`), so `put()` +runs the full RTP2b newness comparison against whatever is under that key. Both messages are +non-synthesized, so `_is_newer` takes the RTP2b2 path and compares `conn-B:0:0` against +`conn-A:0:0` by `msgSerial` then `index` — 0 against 0 — and the incoming message is +discarded. + +RTP2a scopes the newness check to the *matching* member, meaning the same `connectionId` +**and** `clientId`. An entry under the same key but a different `connectionId` is not a +matching member, and `msgSerial` is ordered only within one connection, so comparing across +connections is meaningless as well as wrong. `PresenceMap.put()` has no knowledge of the key +function it was built with, so it cannot make the distinction — which is the shape of the +fix. + +**Tests affected:** `test_rtp17h_keyed_by_clientid` — `assert 'first' == 'second'`. + +**Status:** open bug. This is the precise failure RTP17h exists to prevent: a dead +connection's entry pinned under a clientId. + +#### A presence action on a DETACHED channel re-attaches instead of failing — 1 test + +**Spec points:** RTL11, RTP8g. + +A presence action on a DETACHED channel must fail immediately with an `ErrorInfo`, sending +nothing. `_enter_or_update_client` groups DETACHED with INITIALIZED (`presence.py:258-264`), +so it starts an implicit `channel.attach()` and queues the message. Measured: the channel +goes back to ATTACHED and one PRESENCE protocol message leaves the client — and because the +specification's server does not ACK a PRESENCE, the `enter()` then never returns at all. +`_leave_client` does not have the same grouping; it raises for INITIALIZED and FAILED and +queues only for ATTACHING (`presence.py:332-348`), so the SDK is inconsistent with itself. + +**Tests affected:** `test_rtl11_queued_presence_fail_detached` — the spec-correct +`pytest.raises(AblyException)` around a 2 s `asyncio.wait_for` fails with +`asyncio.exceptions.TimeoutError`, the enter still pending. + +**Status:** open bug. + +#### A failed automatic re-entry reports the NACK, not the 91004 wrapper — 1 test + +**Spec point:** RTP17e. + +When an automatic presence ENTER is NACKed, the channel must emit an UPDATE with `resumed` +true and a `reason` whose `code` is 91004, whose message names the clientId, and whose +`cause` is the NACK error. `_reenter_member` catches the `AblyException` and emits +`ChannelStateChange(previous=state, current=state, resumed=False, reason=e)` +(`presence.py:667-674`), so `resumed` is False and `reason` is the raw NACK error with no +91004 wrapper, no clientId in the message and no `cause`. + +**Tests affected:** `test_rtp17e_failed_reentry_emits_update_error` — `assert False is True` +on `resumed`, with `auto-reenter failed: 40160 401 Presence denied` in the log. + +**Status:** open bug. `AblyException` already carries a `cause`, so the fix is local to this +one method. + +#### A new sync sequence does not discard the in-flight one — no test + +**Spec point:** RTP18a. + +`PresenceMap.start_sync()` is guarded by `if not self._sync_in_progress:` +(`presencemap.py:255-262`), so a second call while a sync is running is a complete no-op and +the first sync's residual set carries into the second. A member delivered by the first sync +but absent from the second therefore survives, where RTP18a requires it to be evicted. +Nothing else distinguishes one sync sequence from another either: `set_presence` parses the +`channelSerial` only to decide whether the cursor is empty (`presence.py:538-546`) and never +stores the sequence identifier. + +**Tests affected:** none, and that is the point. `realtime/unit/RTP18a/new-sync-discards-previous-1` +delivers both members in the second sync, which empties the residual set under either +behaviour, so `test_rtp18a_new_sync_discards_previous` passes without discriminating. The +test fault is recorded under UTS Spec Errors above; the SDK fault is recorded here so that +correcting the test does not read as a new discovery. + +**Status:** open bug, untested. + +#### A synthesized LEAVE's timestamp is timezone-aware; every other one is naive — no test + +**Spec points:** RTP19, RTP19a, against TP3g. + +`_synthesize_leaves` and `set_presence` build the LEAVE with `datetime.now(timezone.utc)` +(`presence.py:586`, `:720`), while every wire-derived presence message gets a **naive** +`datetime` from `_dt_from_ms_epoch`, which is built on `datetime.utcfromtimestamp(0)` +(`types/presence.py:12-19`, `:182-184`). Comparing the two raises +`TypeError: can't compare offset-naive and offset-aware datetimes`, verified directly. + +Nothing inside the library compares them, because synthesized leaves are emitted rather than +stored, so this bites only application code — but any subscriber that sorts or compares the +timestamps of the messages it receives will hit it. Related to the deprecation work in +PR #656. + +**Tests affected:** none. `test_rtp19_synth_leave_null_id_timestamp` brackets the LEAVE with +two aware `datetime.now(timezone.utc)` readings and passes. + +**Status:** open bug, untested. ### Auth +#### An authCallback error is always rewritten as 401/40170, so RSA4d is unreachable — 4 tests + +**Spec points:** RSA4d, RSA4d1. + +`ably/rest/auth.py:182-187` wraps **every** exception an authCallback raises as +`AblyException("auth_callback raised an exception", 401, 40170, cause=e)`, discarding the +original `statusCode`. `ConnectionManager.on_error_from_authorize` (`connectionmanager.py:479-491`) +then branches on `exception.status_code == 403` to reach FAILED, and that branch can never be +taken for an authCallback: the status is always 401, so a 403 goes to the `__fail_state` +(DISCONNECTED) with an 80019/401 instead. RSA4d requires FAILED with 80019/**403** and +`cause` set to the 403, both during the connect sequence and during an RTN22 reauth. + +| Test | Observed | +|---|---| +| `connection_auth_test.py::test_rsa4d_callback_403_causes_failed` | `Timed out waiting for connection state failed; it was disconnected` | +| `connection_auth_test.py::test_rsa4d_callback_403_reauth_causes_failed` | `Timed out waiting for connection state failed; it was connected` | +| `auth_callback_errors_test.py::test_rsa4d_callback_403_connecting_failed` | `Timed out waiting for connection state failed; it was disconnected` | +| `auth_callback_errors_test.py::test_rsa4d_callback_403_reauth_failed` | `Timed out waiting for connection state failed; it was connected` | + +**Status:** open bug. Preserving the callback error's `statusCode` — or letting an +`AblyException` from the callback through unwrapped — also restores the `cause` chain +recorded under Adapted Tests. + +#### A failed RTN22 reauth leaves no trace on the connection — 1 test + +**Spec points:** RSA4c1, RSA4c3. + +`WebSocketTransport.on_protocol_message` (`websockettransport.py:170-175`) handles a server +AUTH by awaiting `auth.authorize()` inside a bare `except Exception` that only logs. Nothing +reaches `on_error_from_authorize`, so no 80019 is built and `connection.errorReason` stays +as it was. + +**Tests affected:** `test_rsa4c3_callback_error_stays_connected` — +`Timed out waiting for errorReason to be set`. + +**Status:** open bug, but see the UTS Spec Error above: specification#466 would make +ably-python's behaviour the correct one, in which case this entry closes as a spec change +rather than a fix. + +#### TokenParams passed to an authCallback carry no clientId on a realtime client — 1 test + +**Spec points:** RSA12a, RTN2e. + +`Auth.__init__` (`ably/rest/auth.py:36-41`) sets `self.__client_id = None` when +`ably._is_realtime`, deferring the clientId to the CONNECTED `connectionDetails`. +`_ensure_valid_auth_credentials` only adds `token_params['client_id']` when +`self.client_id is not None`, so an authCallback on a realtime client is called with the +clientId missing entirely, even though `ClientOptions.clientId` was set. The REST client does +pass it, so the SDK is inconsistent with itself. The snake_case key is idiomatic translation, +not the deviation; the absent member is. + +**Tests affected:** `test_rtn2e_callback_params_include_clientid` — `KeyError: 'client_id'`. + +**Status:** open bug. + +#### RSA4f invalid-format validation is not implemented — 1 test + +**Spec points:** RSA4f, RSA4c2. + +An object that is not a String, JsonObject, TokenRequest or TokenDetails is an invalid token +format, and must give DISCONNECTED with 80019/401. `Auth.request_token` matches +`TokenDetails`, `dict`, `str` and `None` in turn and then falls through to +`token_path = f"/keys/{token_request.key_name}/requestToken"`. A value of another type — the +specification uses `12345` — raises `AttributeError: 'int' object has no attribute +'key_name'`, which is not an `AblyException`, so `try_host`'s `except AblyException` does not +catch it and `connect_base`'s `except Exception` notifies DISCONNECTED with the raw +`AttributeError` as the reason. `connection.errorReason` is then an `AttributeError` with no +`code`. + +**Tests affected:** `test_rsa4f_callback_invalid_type_format` — +`AttributeError: 'AttributeError' object has no attribute 'code'`. + +**Status:** open bug, in two parts: no RSA4f type check, and a non-`AblyException` reaching +`Connection#errorReason`. + +#### No 40171 log at instantiation with a non-renewable token — 1 test + +**Spec point:** RSA4a1. + +`Auth.__init__` logs "using token auth with supplied token only" at debug level when a client +is built with a token and no key, authCallback or authUrl. RSA4a1 requires an **info**-level +message carrying error code 40171 and, per TI5, the help URL +`https://help.ably.io/error/40171`. Nothing in `ably/` mentions 40171 outside `request_token`'s +raise and `on_error_from_authorize`'s branch, and `grep -rn href ably/` finds no help URLs +anywhere. + +The specification collects the log through a `logHandler` client option, which ably-python +does not have (recorded above under RSC2/RSC3/RSC4/TO3b/TO3c/TO3c2); the test uses pytest's +`caplog` instead, which is idiomatic rendering rather than a second deviation. + +**Tests affected:** `test_rsa4a1_non_renewable_token_logs_warning` — `assert False` on the +"an info record mentions 40171" assertion. The RSA4a2 half of the same spec — a token error +on a non-renewable token giving FAILED with 40171 and no retry — is implemented and both its +tests pass. + +**Status:** open bug. + +#### Auth behaviour on the REST client + | Spec points | Behaviour | |---|---| | RSA4 | With a `key` present, `auth_callback` and `auth_url` are ignored when choosing the auth scheme, so Basic is selected and the callback is never called. `Auth.__init__` considers only `use_token_auth` and `key_secret`. `AblyRest.__init__`'s credential `elif` chain compounds it by discarding `token`/`token_details` when a key is given | @@ -260,12 +1179,347 @@ the mark is the only change needed once the SDK behaviour lands. The test asserts what the SDK does, with the specification's expectation in a comment above. These run, so they guard against regression. +### The house ruling on missing accessors + +Where the SDK's *behaviour* is right but the *public accessor* is missing, the derived +test **adapts** — asserting the equivalent observable, however internal — and the missing +API is recorded here in its own right. It is not gated, because gating would take real +behavioural coverage out of the run indefinitely over a question of spelling. Only wrong +behaviour is gated. The closing section of this file carries the reasoning. + +This is not the same as idiomatic naming, which is not a deviation at all and is recorded +nowhere: in each row below there is no public member to rename, so a caller has to reach +through an internal object to get at a value the specification makes public. + +| Spec points | Missing accessor | What the test reads instead | Tests | +|---|---|---|---| +| RTN3, RTN8, RTN8a, RTN8b, RTN8d, RTN9, RTN9a, RTN9b, RTN9d | `Connection#id` and `Connection#key`. `Connection` exposes `state`, `error_reason`, `connection_manager` and `connection_details` only | `connection.connection_manager.connection_id` and `connection.connection_details.connection_key`, which is `None` whenever the key would be. Each file defines `connection_id(client)` / `connection_key(client)` at the top | 8 in `connection_id_key_test.py`, plus ~11 across the auth, failures and liveness suites | +| RTL15 | `RealtimeChannel#properties`, a `ChannelProperties` holding `attachSerial` and `channelSerial`. There is no `properties` attribute and no such type | the name-mangled `__attach_serial` and `__channel_serial` (`channel.py:66-67`), through `attach_serial(channel)` / `channel_serial(channel)` defined in the file | 10 in `channel_properties_test.py`, of which 4 are gated for behaviour above | +| RTN26, RTN26a, RTN26b | `Connection#whenState(state, listener)` — the SDK has `Connection._when_state(state)`, private, returning an awaitable | the awaitable, driven as a task through a `when_state(connection, state)` helper. Both branches are correct: already in the state it resolves with `None`, otherwise it is a `once` registration that resolves for the first entry only | 6 in `when_state_test.py` | +| RTL5, RTL2, RTL2d, RTL2g, RTL12, TH5 | `ChannelStateChange#event`, and a `ChannelEvent` type. `ChannelStateChange` is `(previous, current, resumed, reason)` | the event is the key a listener is registered against, so each test registers on `ChannelState.ATTACHING` / `ATTACHED` / `'update'` and receiving the change at all *is* the `event` assertion | 7 across `channel_state_events_test.py` and `channel_detach_test.py` | +| RTP17, RTP17h | a distinct `LocalPresenceMap` type keyed by `clientId` | `RealtimePresence._my_members`, the same `PresenceMap` class built with `member_key_fn=lambda msg: msg.client_id` (`presence.py:79-81`). The keying requirement is met; the newness check is not, and is gated above | all of `local_presence_map_test.py` | +| RTP2d1, RTP2h1a, and the `Interface Under Test` blocks of all three presence-map specs | `put(message) -> PresenceMessage?` and `remove(message) -> PresenceMessage?` | both return `bool` (`presencemap.py:111`, `:159`); the message to emit is the caller's own, which `set_presence` appends to `broadcast_messages` when the return is true. `IS NOT null` is read as `is True`. `put` stores a *copy* with the action rewritten to PRESENT and leaves the caller's message untouched, so RTP2d1's "emit the original action" falls out for free | all of `presence_map_test.py` | +| RTP19, and the `Interface Under Test` block of `presence_sync.md` | `endSync() -> List`, the synthesized LEAVEs | `end_sync()` returns `(residual, absent)` of the *stored* members; the synthesis lives one level up in `RealtimePresence.set_presence` (`presence.py:575-587`). Tests reading only counts and clientIds concatenate the two lists exactly as `set_presence` does; tests reading the LEAVE itself drive a `RealtimePresence` and assert on what its subscribers receive | 4 in `presence_sync_test.py` | +| TB2, RTS3b, RTS3c, RTS3c1, RTL16 | `channel.options` as a `ChannelOptions` | a dict keyed by wire names, because `RealtimeChannel` passes `ChannelOptions.to_dict()` to the REST `Channel` constructor (`channel.py:84`). Assertions read `channel.options['params']['rewind']`. On `ChannelOptions` itself the cipher attribute is spelled `cipher`, not `cipherParams`. `set_options_without_reattach` replaces the stored mapping wholesale rather than merging, which `test_rts3c_options_updated_existing` pins | 5 | +| RTS2, RTS4a | `channels.exists(name)`, `channels.names`, and an awaitable `release()` | `name in client.channels` (`Channels.__contains__`); the collection iterates over its channels rather than their names; `release` is synchronous. Genuinely idiomatic spelling rather than an absence — recorded only because of the `__getattr__` hazard noted below | 4 | + +**Status:** open bugs of the missing-API kind, not of the wrong-behaviour kind. Adding the +accessors would leave every assertion above unchanged; only the spelling would move. + +### The clientId is sent on outgoing PresenceMessages where the spec requires it absent + +**Spec points:** RTP8c, RTP9d, RTP10c. + +`enter()`, `update()` and `leave()` use the connection's clientId implicitly, so the +`clientId` attribute of the PresenceMessage **must not be present** — the server infers it +from the connection. ably-python sends it: `_enter_or_update_client` and `_leave_client` +resolve `effective_client_id = _get_client_id(self)` when no clientId was passed +(`presence.py:239`, `:315`), and `PresenceMessage.to_encoded` writes `clientId` whenever it +is set (`types/presence.py:160-161`). The implicit case is not distinguished from the +explicit one; both go through the same `client_id` argument. + +**Tests affected:** `test_rtp8a_enter_sends_presence_enter`, +`test_rtp9a_update_sends_presence_update`, `test_rtp10a_leave_sends_presence_leave`, each +asserting `clientId == 'my-client'` with the spec expectation in a comment. + +**Status:** open bug, and the most substantive protocol-level non-compliance in the suite. +Adapted rather than gated because the behaviour is stable and the rest of each test — +action, channel, payload — is worth running. + +### `AblyException`'s status code and code are transposed at six sites + +The constructor is `AblyException(message, status_code, code)` (`util/exceptions.py:15`). +Six raises put the Ably error code in the status slot and the HTTP status in the code slot, +so the resulting exception reports each as the other: + +| Site | As written | Should be | +|---|---|---| +| `realtime/channel.py:203` | `AblyException("Unable to detach; channel state = failed", 90001, 400)` | `400, 90001` | +| `realtime/channel.py:217` | `AblyException("Detach request superseded by a subsequent attach request", 90000, 409)` | `409, 90000` | +| `realtime/connectionmanager.py:343` | `AblyException("Connection failed", 80000, 500)` | `500, 80000` | +| `types/mixins.py:84` | `AblyException('VCDiff decoder not available', 40019, 40019)` | `400, 40019` | +| `types/mixins.py:88` | `AblyException('VCDiff decode failure', 40018, 40018)` | `400, 40018` | +| `types/mixins.py:111` | `AblyException('VCDiff decode failure', 40018, 40018) from e` | `400, 40018` | + +The two neighbours that get it right — `channel.py:860` (`408, 90007`) and +`message.py:304` (`400, 40018`, for the sibling of the `mixins.py` errors) — make this a +repeated slip rather than a misunderstanding of the constructor. + +**Tests affected:** `test_rtl5b_detach_failed_errors` and `test_rtl4h_attach_while_detaching` +assert on `status_code` with a comment recording the transposition. +`test_rtn7d_fail_disconnected_no_queue`, `test_rtn7e_pending_fail_closed` and +`test_rtn7e_multiple_pending_fail` assert only what the specification asks — that a code is +present — so they pass; the wrong value is recorded here rather than asserted. + +**Status:** open bug. A mechanical fix, and a caller branching on `status_code` today gets a +five-digit number. + +### A refused connection and a connect timeout reach no failure path + +**Spec points:** RTN14d most directly; the same defect shapes RTN14e, RTN14f, RTN14h, +RTN17e, RTN17f, RTN17h, RTN17i, RTN17j, RTN13b, RTN16g3 and both connection tests in +`backoff_jitter_test.md`. + +`WebSocketTransport.ws_connect` (`websockettransport.py:117`) catches only +`(WebSocketException, socket.gaierror)`: + +```python +except (WebSocketException, socket.gaierror) as e: + exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000) + self._emit('failed', exception) +``` + +`ConnectionRefusedError` is an `OSError`, not a `WebSocketException`, and +`asyncio.TimeoutError` is neither, so neither reaches `_emit('failed')`. The future +`ConnectionManager.try_host` awaits is completed only by the `connected` or `failed` events, +so it never completes; the `except` clause in `connect_base` that would enter +`connect_with_fallback_hosts` is never reached; and the attempt is ended only by the +transition timer. Measured, with `fallback_hosts=[]` and `realtime_request_timeout=1000`: + +| injected | state at settle | state change | reason | +|---|---|---|---| +| `ConnectionRefusedError` (`respond_with_refused`) | still CONNECTING | at t=1000 | 50003 / 504 | +| `asyncio.TimeoutError` (`respond_with_timeout`) | still CONNECTING | at t=1000 | 50003 / 504 | +| `socket.gaierror` (`respond_with_dns_error`) | already DISCONNECTED | at t=0 | 40000 / 400, naming the cause | + +Three consequences: + +- A refused connection and a connect timeout are indistinguishable from each other *and* + from a server that accepts the socket and says nothing. All three surface as the + transition timer expiring with "Connection cancelled due to request timeout". +- **The fallback loop is unreachable for the two commonest transport failures.** Measured + with the default fallback hosts in place: one connection attempt and no fallback host + tried for refused and for timeout, against six attempts — primary plus all five + fallbacks — for a DNS error. RTN17d's fallback behaviour therefore cannot happen in + practice. +- **Every refused attempt leaks a task and a future.** `try_a_host`'s future + (`connectionmanager.py:646`) is never settled, so each attempt leaves a + `connect_base()` task awaiting it for good, printing `Task was destroyed but it is + pending!` at interpreter shutdown. A long-lived client reconnecting against a refusing + host leaks one per attempt. + +**Tests affected:** `test_rtn14d_retry_recoverable_failure` is the adapted test that pins +it — it asserts that the refusal moves nothing, that DISCONNECTED arrives only when the +transition timer expires, and that the reason is the timer's 50003 rather than the +refusal's, so it fails if the defect is fixed, which is the point. Around a dozen further +tests substitute `respond_with_dns_error()` for the specification's `respond_with_refused()` +or `respond_with_timeout()`, which is RSC15l's host-unreachable condition and does reach +the fallback loop, each noted at the site: `test_rtn17f_fallback_on_error`, +`test_rtn17h_fallback_domains_from_rec2`, `test_rtn17i_prefer_primary_domain`, +`test_rtn17j_connectivity_check_before_fallback`, `test_rtn17e_http_uses_same_fallback`, +`test_rtn13b_ping_error_suspended`, `test_rtn16g3_recovery_key_null_inactive`, +`test_rtc7_disconnected_retry_timeout`. `test_rtn17g_empty_fallback_set_error` and +`test_rtl6c4_fails_conn_suspended` keep `respond_with_refused()` deliberately — the first +because it asserts that *no* fallback follows, the second because swapping it would silence +the ten `Task was destroyed` lines that are the leak showing. + +**Status:** open bug. Widening the `except` to `(WebSocketException, OSError, +asyncio.TimeoutError)` — or, better, emitting `failed` from a guard no exception type can +escape — fixes all three consequences. + +### The connectivity check bypasses every seam and blocks the event loop + +**Spec points:** RTN17j, REC3a, REC3b, REC3. + +`ConnectionManager.check_connection` (`connectionmanager.py:193`) calls module-level +`httpx.get` **synchronously**, from within the async fallback loop, once per fallback host +tried. It therefore bypasses the client's own HTTP layer entirely — +`TestOptions(http_transport=...)` cannot see it — and blocks the event loop for the duration +of the request. + +**Tests affected:** the three REC3 tests are skipped stubs (below). Every test in +`fallback_hosts_test.py` that leaves the client a fallback set replaces +`ably.realtime.connectionmanager.httpx.get` with an in-process stub through pytest's +`monkeypatch`, and `test_rtn17j_connectivity_check_before_fallback` asserts on the calls +that stub recorded. The whole batch was re-run with `socket.socket.connect`, +`socket.create_connection` and `socket.getaddrinfo` blocked, with identical results, so no +test reaches the network. + +**Status:** open bug — two of them: an HTTP call no client-scoped seam can reach, and a +synchronous call inside the event loop. + +### The server's `connectionStateTtl` is parsed and never used + +**Spec points:** RTN21, and RTN14e, RTN14f, RTN14h, RTL3c, RTL3d, RTP11d, RTL6c4, RTN7e +through their setups. + +`ConnectionDetails.from_dict` parses `connectionStateTtl` (`types/connectiondetails.py:19`) +and nothing ever reads it. `ConnectionManager.start_suspend_timer` +(`connectionmanager.py:745`) uses `Defaults.connection_state_ttl` — 120000 — directly, and +no client option overrides it (`types/options.py:64` accepts the keyword and then discards +it). A server that shortens or lengthens the TTL is ignored. + +**Tests affected:** every test that has to reach a SUSPENDED connection — nine across the +connection, channel and presence suites — sends the specification's `connectionStateTtl` and +then advances the `FakeClock` to the 120000 default instead, either directly or through +`advance_to_connection_state`. Because the clock is notional this costs nothing in wall +time: the three connection tests take 0.06 s, 0.09 s and 0.08 s. Each says so in a comment. + +**Status:** open bug. It is the reason the fake clock exists in this suite at all; see +the fake-time section at the end of this file. + +### An 80019 from a failed auth carries no `cause`, and a slow callback is not attributed + +**Spec points:** RSA4c, RSA4c1, RSA4c2. + +Two adaptations with one underlying theme — an auth failure reaches the right state with the +wrong explanation. + +- `ConnectionManager.on_error_from_authorize` builds + `AblyException('Client configured authentication provider request failed', 401, 80019)` + with no `cause` argument, so the error the authCallback raised survives only in the log. + RSA4c1/RSA4c2 require `cause` to be set to the underlying error. Same root cause as the + RSA4d entry above: `request_token`'s wrapper loses the original error's shape, and + `on_error_from_authorize` then drops what is left. Adapted, not gated, because the state, + code and status are all correct and worth guarding — only the `cause` link is missing. + Tests: `test_rsa4c2_callback_error_causes_disconnected`, + `test_rsa4c2_callback_error_connecting_disconnected`. +- RSA4c treats an auth attempt that outruns `realtimeRequestTimeout` as an auth error, + giving DISCONNECTED with 80019/401. ably-python applies no timeout to the callback — + `await auth_callback(token_params)` is unbounded — and the CONNECTING transition timer + (`connectionmanager.py:699-724`) ends the attempt instead, with the generic 504/50003 it + raises for any connect that does not complete in time. Test: + `test_rsa4c2_callback_timeout_connecting_disconnected`, asserting 50003/504 on a + `FakeClock`. + +**Status:** open bugs, cosmetic in effect — the connection recovers either way, but the +error does not say the auth provider is at fault. + +### A token error with no means to renew reports the renewal failure, not the server's error + +**Spec point:** RTN15h1. + +After a DISCONNECTED carrying 40142/401 that cannot be renewed, the connection is FAILED as +required, but `error_reason` is the error from the *attempted renewal*: 40171/403, "Need a +new token but auth_options does not include a way to request one". +`ConnectionManager.on_token_error` records the server's error as `__error_reason`, then calls +`Auth._ensure_valid_auth_credentials(force=True)`, which raises; `on_error_from_authorize` +then calls `notify_state(FAILED, that exception)` and `enact_state_change` overwrites +`__error_reason` with it. + +**The two specifications disagree here.** `connection_open_failures_test.md`'s own RSA4a test +asserts exactly 40171 for the same situation reached through an ERROR rather than a +DISCONNECTED, citing RSA4a2, and ably-python matches that one — +`test_rsa4a_token_error_no_renewal` passes unmodified. +`test_rtn15h1_token_error_no_renew` asserts 40171/403 with the specification's expectation in +a comment. + +**Status:** arguably correct as it stands; the specifications should be reconciled first. + +### `errorReason` is not cleared by a successful reconnect + +**Spec point:** RTN25, `realtime/unit/RTN25/error-reason-cleared-on-connect-4`. + +`Connection._on_state_update` assigns `__error_reason` only when the incoming change carries +a reason, and the only place that clears it is `Connection.connect()` — which an automatic +retry, driven through `ConnectionManager.request_state`, does not go through. So a +DISCONNECTED error is still readable after the connection comes back. + +The test's primary assertion is `errorReason IS null`, while explicitly sanctioning the +alternative, "errorReason is kept but clearly not relevant to current state +(Implementation-specific behavior)". `features.md` RTN25 says only when `errorReason` is +*set*, never when it is cleared, so neither reading is wrong. + +**Tests affected:** `test_rtn25_error_reason_cleared_on_connect` asserts the retained error — +the specification's option B — with option A in a comment. + +**Status:** intentional, and permitted. Worth raising against the UTS spec instead, which +should pick one reading: a test that accepts either provides no signal. + +### A pending `attach()` resolves, rather than failing, when the connection closes + +**Spec points:** RTL3b, RTL4d. + +RTL3b moves an ATTACHING channel to DETACHED when the connection closes, and RTL4d has the +attach's callback invoked with an `ErrorInfo` "in all other cases" than ATTACHED. +`channel_connection_state.md` spells it `AWAIT attach_future FAILS WITH error`. The RTL3b +transition is correct; the pending `attach()` then returns `None`, because `attach()` ends +with `if state_change.current in (SUSPENDED, FAILED): raise state_change.reason` +(`channel.py:148-150`) and DETACHED is in neither, so the coroutine falls through as a +success. + +**Tests affected:** `test_rtl3b_closed_attaching_to_detached` asserts `await attach_future is +None`, and makes every other assertion the specification does. It fails if the SDK starts +raising, so it does guard the behaviour. + +**Status:** open bug. + +### An attach requested while DETACHING pre-empts the detach + +**Spec point:** RTL4h. + +The specification has the attach performed *after* the pending detach completes, with the +detach completing normally. `attach()` requests ATTACHING straight away, which resolves the +pending detach's wait with an ATTACHING state change, and `detach()` then raises "Detach +request superseded by a subsequent attach request". The end state and the two ATTACH +messages the specification counts are as expected. + +**Tests affected:** `test_rtl4h_attach_while_detaching`, asserting the superseding error. + +**Status:** open bug, minor — the observable outcome is the same, but a caller's `detach()` +raises where the specification has it return. + +### Adaptations forced by the absent `attachOnSubscribe` — 21 tests + +**Spec points:** RTL7a, RTL7b, RTL7f, RTL8a, RTL8b, RTL8c, RTL22a–c, RTAN4a, RTAN4c, RTAN4e, +RTAN4e1, RTAN5a, TB2, RTL16. + +`RealtimeChannelOptions(attachOnSubscribe: false)` is setup scaffolding in twenty-one tests: +it keeps `subscribe` from issuing a second attach while the test counts protocol messages. +The option does not exist (gated above), so each test **attaches explicitly first**, which is +what the specification's own test steps do; on an already-ATTACHED channel `subscribe`'s +attach returns immediately without sending anything (RTL4a, `channel.py:125`). Every +assertion the specification makes is kept. + +Two cases need more than that. `test_rtan4e1_no_warn_unattached` needs the channel to stay +unattached, which it cannot ask for, so it runs `subscribe` as a task against a server that +never confirms the attach and asserts both that the channel is not attached and that no +warning was logged. `test_tb2_channel_options_attributes` and +`test_rtl16_set_options_updates` drop the `attachOnSubscribe` assertion and keep the three +that the SDK can answer, each pointing at the gated `test_tb4_attach_on_subscribe_default` +which holds the spec-correct one — gating these two as well would take four working +assertions out of the run for one missing option. + +**Status:** the adaptation stands until RTL7h is implemented. + +### Channel and presence behaviours asserted as they are + +| Spec points | Specification | ably-python | Tests | +|---|---|---|---| +| RTL13a, RTL13b | the ATTACHING or SUSPENDED state change triggered by a server-initiated DETACHED carries that message's `error` as its `reason` | `_on_message` discards the error and calls `_request_state(ATTACHING)` / `_notify_state(SUSPENDED)` with no reason, so `reason` is null — and a pending `attach()` then hits `raise None`. Gated in its own right under RTL24/RTL4c above; these two tests assert `reason is None` and the `TypeError` | `test_rtl13a_attached_reattach_triggered`, `test_rtl13b_attaching_detached_to_suspended` | +| RTP16c | answering an ATTACH with a DETACHED puts the channel in DETACHED, and a presence operation from there errors | the channel lands in SUSPENDED (`channel.py:735`) and `attach()` raises `TypeError` on the null reason. The presence operation itself does error, with 90001 | `test_rtp16c_presence_errors_other_states` | +| RTL7g | `channel.subscribe(listener)` registers the listener even when the implicit attach is rejected | the listener *is* registered (`channel.py:279-282`), and then `subscribe` awaits `attach()`, which re-raises the failure (`:150`). So `await channel.subscribe(...)` raises where the specification's fire-and-forget call returns. The RTL7g requirement itself holds | `test_rtl7g_listener_registered_attach_fails`, `test_rtl7g_no_attach_when_attaching` | +| RTL10b | an `AblyException` when `untilAttach` is used on an unattached channel | an `AblyException`, but for the wrong reason: `history` takes no `until_attach`, and `catch_all` wraps the `TypeError` as `50000 500 Unexpected exception` whatever the channel's state. The test also asserts that no HTTP request was made | `test_rtl10b_errors_when_not_attached` | +| RTL19b, RTL19c, RTL20, RTL21, PC3 | a vcdiff-decoded payload equals a string literal | a `bytearray`. `EncodeDataMixin.decode` (`mixins.py:106`) leaves the vcdiff result binary because no further encoding step turns it back into text, which is correct — the specification's own `RTL19b/json-wire-form-base-1`, which does carry `utf-8/vcdiff`, receives a string. Five tests assert `b'second message'` where the specification writes the string | 5 in `channel_delta_decoding_test.py` | +| RTB1b | 1000 jitter samples | 40, because there is no jitter generator to sample and each sample costs a whole reconnection cycle, which must finish before the 120000 ms suspend timer moves the retries onto `suspended_retry_timeout`. The standard error of the mean is 0.009 against a ±0.05 allowance, so the test still separates a uniform generator from a degenerate one | `test_rtb1b_jitter_coefficient_range` | +| RTB1 | the channel test provokes its re-attach with a channel `ERROR`, citing RTL13b | RTL14 takes a channel ERROR straight to FAILED, which is correct (see *Investigated and not defects*), so the channel never suspends and no retry timer starts. The derived test provokes the re-attach with a server-initiated DETACHED instead, which RTL13a does answer with `_request_state(ATTACHING)`; from there the scenario runs as written | `test_rtb1_suspended_channel_retry_delay` | + +### Translation notes that are not deviations + +Recorded so the next reader does not rediscover them, and so the difference from the +pseudocode is not mistaken for non-compliance. + +| Subject | Note | +|---|---| +| DISCONNECTED as a restable state | RTN15a retries a drop from CONNECTED through `loop.call_soon` (`connectionmanager.py:668`) with no time passing, so DISCONNECTED is left within the same turn of the event loop and a listener registered afterwards never sees it. The heartbeat specification says so itself, under "Verifying Transient States"; tests record the whole sequence with `connection.on(...)` and assert `CONTAINS_IN_ORDER`. Where a test needs the connection to *rest* in DISCONNECTED it fails the immediate retry and raises `disconnected_retry_timeout`. Correct RTN15a behaviour | +| `Channels.__getattr__` | `ably/rest/channel.py:408` answers **any** unknown attribute with `self.get(name)`, so reading an attribute that does not exist creates a channel named after it and never raises — `hasattr(channels, 'get_derived')` is `True`, and mutates the collection. Consequences: a typo becomes a phantom channel, and `client.channels.get_derived(...)` fails with `TypeError: 'RealtimeChannel' object is not callable` rather than `AttributeError`. No test loses coverage to it, because no derived test names an attribute the collection does not define; it is recorded because it silently changes what a probing test measures. `Channels.__iter__` is annotated `Iterator[str]` but yields `Channel` objects, which is a second, smaller instance | +| `RealtimeChannel.publish()` | positional-only (`*args`, `channel.py:342`). The keyword form the specifications write raises `ValueError`, although `RestChannel.publish()` accepts it. Every test uses the positional form | +| ACK `res` | every ACK in the specifications is written `res: { "serials": [...] }`, a single object. `WebSocketTransport` (`websockettransport.py:191-193`) reads `res` as a list, one entry per acknowledged ProtocolMessage, which matches the protocol definition. The specification's single object is shorthand for the one-message case | +| `subscribe` before `connect` | all eight `message_field_population.md` tests subscribe in their setup, before `client.connect()`. `subscribe` awaits `attach()`, which raises 90001 unless the connection is CONNECTING, CONNECTED or DISCONNECTED, so the derived tests connect first. Nothing they assert depends on the order | +| `EventEmitter` and bound built-ins | `connection.on(state, states.append)` raises `ValueError: EventEmitter.on(): invalid args`, because `is_callable_or_coroutine` accepts only `iscoroutinefunction`/`isfunction`/`ismethod`. Every listener in the suite is a `def` | +| `Connection#whenState`'s registration window | `_when_state`'s deferred branch is an `async def`, so its `once` registration happens when the coroutine *starts*, not when `_when_state` is called. A caller that needs the registration in place before the state can change must schedule it and yield first, which the derived tests do. A literal callback API would have no such window | +| RTP17b's synthesized-LEAVE filter | RTP17b's own implementation note allows the check to live "either inside the presence map's `remove()` method, or at the calling level". ably-python uses the calling level (`presence.py:557-558`). Compliant | +| RTP19a's route | the specification models an ATTACHED without HAS_PRESENCE as `startSync()` then `endSync()`. `on_attached(has_presence=False)` calls `_synthesize_leaves(...)` then `clear()` (`presence.py:611-618`), which is the requirement itself rather than the model of it | + +### REST behaviours asserted as they are + | Spec points | Specification | ably-python | Status | |---|---|---|---| | RSL2 | A space in a channel name is `%20` | `+`, from `parse.quote_plus` in `Channel.__init__`. `quote_plus` is form encoding, and a `+` in a URL *path* is a literal plus, so the name reaching the server is altered | Open bug, and a genuine correctness issue | | RSL8 | `Channel#status` URI-encodes the channel id | `status()` interpolates the name with no escaping at all. `a/b` addresses the wrong resource, `a?b` truncates the name into a query string, `a#b` becomes a fragment | Open bug | | RSL2, RSL11b, RSL15b | `:` is `%3A` | Left literal, from `safe=':'`. RFC 3986 allows `:` in a path segment and Ably uses it for namespaces, so the server receives the same value | Intentional | -| RSC1b | Error code 40106 | A bare `ValueError` from `AblyRest.__init__` with an informative message, not an `AblyException`, so there is no code | Open bug | +| RSC1b | Error code 40106 | A bare `ValueError` from `AblyRest.__init__` with an informative message, not an `AblyException`, so there is no code. The realtime constructor shares it — `test_rtc12_invalid_arguments_error` records the same behaviour | Open bug | +| RTC12 / RSC1, RSC1a, RSC1c | A string constructor argument is an API key when it contains `:` and a token when it does not | `AblyRest.__init__` treats its first positional argument as a key unconditionally and hands it to `AuthOptions.set_key`, which requires exactly two colon-separated parts, so a token string raises 40101/401 "key of not len 2 parameters". A token is supplied through the separate `token` or `token_details` arguments. The empty-string case is compliant | Intentional / SDK-wide: the constructor takes credentials as distinct named arguments and has no string-sniffing path to restore | | RSC18 | The constructor rejects basic auth over HTTP | Construction succeeds; 40103 is raised from `make_request` when a request needing Basic Auth is attempted, and no request goes out. RSA1/RSC18 say only "any attempt to use" | Compliant; the UTS is stricter than its source | | REC1b1, REC1c1 | Code 40000, or a message containing "invalid" or "conflict" | 400/40106 with a specific message. The features spec mandates no code | Cosmetic | | RSAN1a3 | Code 40003 for a missing `Annotation.type` | 400/40000 | Cosmetic; worth aligning cross-SDK | @@ -281,19 +1535,85 @@ comment above. These run, so they guard against regression. | CHM2 | Missing metrics default to 0 | `ChannelMetrics.from_dict` uses a bare `obj.get(name)`, so any omitted metric parses as `None` | Open bug, broader than CHM2g/h | | CHM2g, CHM2h | `objectPublishers` and `objectSubscribers` on `ChannelMetrics` | Neither is modelled, so both are dropped on parsing. The test asserts their absence, and turns red once they are added | Open bug | | TO3l8 | `maxMessageSize` is a client option, default 65536 | Rejected by `Options.__init__`. `ably/realtime/channel.py:422` reads it with `getattr(..., 65536)`, so the default holds but cannot be configured, nor overridden by `connectionDetails` (CD2c) | Open bug | +| RTN15, RTN23 | A DISCONNECTED `ErrorInfo` needs no `statusCode` | `ConnectionManager.on_disconnected` evaluates `exception.status_code >= 500` unguarded, so a DISCONNECTED whose error omits `statusCode` raises `TypeError` in a task whose exception is only logged, and the connection silently stays CONNECTED. `DISCONNECTED_MESSAGE` supplies 400 | Open bug | | TO3l1, TO3l5 | `httpRequestTimeout` and `httpMaxRetryCount` carry their defaults on the options object | Left unset; the effective defaults are applied downstream by `Http` and by `Options.__get_hosts`. The spec's values are milliseconds, while ably-python's `http_request_timeout` is seconds | Intentional | +| RTC7 (TO3l3, TO3l4) | `client.options.httpOpenTimeout == 4000` and `httpRequestTimeout == 10000` | Both `None` on `Options`; `Http.http_open_timeout` / `http_request_timeout` fall back to `CONNECTION_RETRY_DEFAULTS`, which holds 4 and 10 — seconds, because that is what `httpx` takes. The three realtime timeouts the same test checks are defaulted on `Options` and match | Open bug for the default being unreadable from `options`; the unit difference alone is internal | +| RTC17 (RSA7b1) | `client.clientId == client.auth.clientId` | `AblyRealtime.client_id` reads `options.client_id` and returns the configured value, while `Auth.__init__` sets `self.__client_id = None` whenever `ably._is_realtime` (`rest/auth.py:34-41`), deferring it to whatever a CONNECTED confirms. The two disagree on a client that has not connected | Open bug. RSA12b only allows the realtime clientId to be unknown while it has not been *configured* | +| RTC1f | a `transportParams` boolean appears as `"true"` / `"false"` | `True` / `False`, because `WebSocketTransport.connect` builds the query string with `urllib.parse.urlencode`, which renders each value through `str()` (`websockettransport.py:89`). Integers are unaffected | Open bug. A caller can pass the strings directly, but a bool is what the spec's Stringifiable type admits | ## Mock Infrastructure Limitations -Tests that cannot be implemented as written. The first is caused by the SDK, not by -the mock, but it lands here because the effect is the same: no test can observe the -behaviour. +Tests that cannot be implemented as written, kept as skipped stubs carrying their Test +IDs so the specification's coverage is still accounted for. Fifteen in total. Two of the +entries are caused by the SDK rather than by the mock, but they land here because the +effect is the same: no test can observe the behaviour. + +### WebSocket ping frames reach no library hook — 4 tests + +**Spec points:** RTN23b (`ping-frame-resets-timer-2`, `any-message-resets-timer-3`, +`multiple-pings-keep-alive-6`), RTN23c (`heartbeats-bounce-query-param-0`). + +`mock_websocket.md` offers `send_ping_frame()` for platforms whose websocket client +surfaces ping frame events. `WebSocketTransport` has none: the `websockets` library answers +pings inside the protocol and offers no application-level hook, `on_activity` is called only +from `on_protocol_message`, and no `ping_interval` or `ping_handler` is configured. +`send_ping_frame()` is implemented and records a `PING_FRAME` event, but nothing observable +follows — proved by +`mock_websocket_test.py::test_a_ping_frame_is_recorded_but_reaches_no_library_hook`, which +asserts the transport's `last_activity` is unmoved. + +The specification's own platform note says the RTN23b tests do not apply to an SDK in this +position, and ably-python is one, so RTN23a is the branch that binds it — and the six RTN23a +tests are derived and pass, driven by `send_to_client(HEARTBEAT_MESSAGE)`. The two RTN23b +tests that do not depend on ping frames, `idle-timeout-reconnect-1` and +`timeout-triggers-reconnect-4`, plus `reconnect-uses-resume-5` and +`heartbeats-false-query-param-0`, are derived in full and pass. + +`heartbeats=bounce` is the fourth stub: the specification scopes it to a client whose own +code may be suspended while the transport stays alive, which it says means browsers. +ably-python has no browser build and no equivalent environment, so there is no +configuration of it under which `bounce` is the value to send. That it sends no +`heartbeats` parameter at all is a separate matter, gated under RTN23a above. + +### RTN20 has no network connectivity listener to mock — 4 tests + +**Spec points:** RTN20, RTN20a, RTN20b, RTN20c. + +`network_change_test.md` requires an injectable `MockNetworkListener` with +`simulate_network_lost()` and `simulate_network_available()`, installed "via the same +mechanism the SDK uses to receive real network events". There is no network connectivity +abstraction anywhere in `ably/`, no OS-event subscription, and no seam through which a mock +could be installed. `ConnectionManager.check_connection` is a one-shot HTTP probe on the +fallback-host path, not an event source. + +This is **not** an SDK deviation: RTN20 is conditional on the platform, and +`network_change_test.md`'s own platform table lists Python under "Not typically available — +RTN20 may not apply", adding that "SDKs that do not implement network monitoring should skip +these tests entirely". ### The connectivity check bypasses the injected transport — 3 tests -`ConnectionManager.check_connection` is internal, synchronous, and calls -`httpx.get` directly. `REC3a`, `REC3b` and `REC3` are skipped. These specs drive a -Realtime client and belong under `realtime/unit` in any case. +`ConnectionManager.check_connection` is internal, synchronous, and calls `httpx.get` +directly, so `REC3a`, `REC3b` and `REC3` are skipped. These specs drive a Realtime client +and belong under `realtime/unit` in any case. The realtime fallback tests reach the same +call through `monkeypatch` instead, which is recorded under Adapted Tests; these three +assert on `mock_http` and cannot. + +### A token over 128 KiB cannot reach a connection attempt — 1 test + +**Spec point:** RSA4f (`callback-oversized-token-format-1`). + +The authCallback returns a 131073-character token. ably-python accepts it — there is no +RSA4f size check — and puts it in the websocket URL's `accessToken` parameter, which makes +the URL longer than `httpx.URL` accepts. `PendingConnection.__init__` +(`helpers/mock_websocket.py:233`) parses every connection URL through `httpx.URL`, which +raises `InvalidURL: URL too long` above 64 KiB, and it does so inside `_MockConnect.__aenter__` +**before the attempt is recorded**, so the failure is invisible in `events`, `handler_errors` +is empty, and the client simply stays in CONNECTING. + +The SDK deviation behind it is real — no 128 KiB check on a token from an authCallback — but +the mock cannot show it. Letting `RecordedUrl` fall back to a hand-parsed URL when +`httpx.URL` refuses one would make this test derivable. ### `fallbackHostsUseDefault` is not implemented — 3 tests @@ -301,6 +1621,406 @@ Optional per TO3k7, and `REC1b1` and `REC2a1` scope their checks to libraries th support it, so these are skipped as not applicable rather than recorded as deviations. +## Investigated and not defects + +Claims raised during derivation, investigated, and found not to be SDK faults. They are +kept so that nobody reaches the same first conclusion again. + +### A channel ERROR going straight to FAILED is correct — **retracted** + +One batch reported that "a channel ERROR goes straight to FAILED instead of prompting a +re-attach (`channel.py:775`, RTL13b)". A second batch refuted it and the refutation was +verified. **RTL14 requires FAILED.** RTL13b's re-attach is scoped to a *server-initiated +DETACHED* and enumerates its triggers, which do not include ERROR; RTL4e, which might have +been read the other way, was deleted as redundant to RTL14. + +ably-python is right here: `ConnectionManager.on_error` routes a channel-scoped ERROR to the +channel (`connectionmanager.py:469-471`, RTN15i) and `channel.py:775-777` sets FAILED with +the error as both the state change's `reason` and the channel's `error_reason`, leaving +other channels and the connection alone and cancelling the RTL13b retry timer on the way. +All five RTL14 tests in `channel_error_test.py` pass unmodified, with no deviations at all. + +The one real consequence is on the RTB1 channel-retry test, whose fixture uses a channel +ERROR to provoke the re-attach it needs. That fixture is wrong, not the SDK; the derived +test provokes it with a server-initiated DETACHED instead, and is recorded under Adapted +Tests. + +### RTP17b's filter placement is compliant + +RTP17b requires that a synthesized LEAVE not be applied to the RTP17 map, and its own +implementation note allows the check to live "either inside the presence map's `remove()` +method, or at the calling level". ably-python uses the calling level +(`presence.py:557-558`). Within the licence the note gives, this is compliant. + +### `PresenceMap` getting RTP19's residual removal right + +`put()` removes the member from `_residual_members` **before** the newness check +(`presencemap.py:140-142`), which is what RTP19 needs. The three RTP19 cases the +specification distinguishes all come out right. + +### RTL18 delta recovery matches the specification exactly + +Verified end to end: the recovery ATTACH carries the `channelSerial` of the last batch that +*decoded* rather than the failing one, `reason.code == 40018`, the failed message is not +delivered, recovery completes to ATTACHED, and `__decode_failure_recovery_in_progress` +correctly suppresses a second recovery. RTL19b's json-wire-form chaining is right too: +`last_payload` is advanced only by the `base64` and `vcdiff` steps, never by `json` or +`utf-8`, matching ably-js. + +### RTN15a's immediate retry is correct, and is why DISCONNECTED cannot be awaited + +Several specifications write `AWAIT_STATE connection == disconnected` between a drop and the +reconnection. RTN15a requires the retry to be immediate after a drop from CONNECTED, and +ably-python does it with `loop.call_soon` (`connectionmanager.py:668`), so DISCONNECTED is +left within the same turn of the event loop. The awaited state is unreachable because the +SDK is compliant, not because it is not. Recorded under Adapted Tests as a translation note. + +## Candidate issues + +`writing-derived-tests.md` asks for the deviations above to be classified into distinct +issues grouped by root cause, "not one issue per test", each with the spec points, the +spec-versus-actual, and a reproduction command. That is this section. It is a shortlist for +a maintainer, not a work plan: nothing here has been filed unless it says so. + +**Ranking.** Tier 1 stops a running application dead. Tier 2 raises the wrong kind of error, +or an error where none should be raised, so a caller cannot handle it. Tier 3 puts wrong +data on the wire or in front of the application, silently. Tier 4 is absent features. Tier 5 +is missing accessors over correct behaviour. Within a tier the order is blast radius. + +**Already filed, do not re-file:** + +| Issue | Covers | Rows below it answers | +|---|---|---| +| [#706](https://github.com/ably/ably-python/issues/706) | the fabricated `"None:0"` message id | 3.3 | +| [#658](https://github.com/ably/ably-python/issues/658) | presence messages sent on reconnection before reattach | adjacent to 3.14, which is the other half of RTP17 automatic re-entry | +| [#656](https://github.com/ably/ably-python/issues/656) | `utcfromtimestamp` deprecation | adjacent to 2.4 | +| [#709](https://github.com/ably/ably-python/issues/709)–[#712](https://github.com/ably/ably-python/issues/712) | REST request timeout, token-request nonce reuse, single-host retry, `dispose()` teardown | none — filed from the REST derivation, and distinct from everything here | + +Every reproduction below is prefixed by: + +``` +RUN_DEVIATIONS=1 uv run --frozen --extra crypto --extra dev pytest +``` + +### Tier 1 — the application stops + +**1.1 A DISCONNECTED carrying a 5xx strands a client that has no fallback hosts.** +RTN15h3. The spec requires an immediate reconnect with a resume; the SDK does nothing at +all — no state change, no retry, and the client goes on believing it is connected to a +socket the server has closed. `on_disconnected` (`connectionmanager.py:437-450`) routes +500–504 to the fallback path and, with an empty fallback list, logs and falls out of the +`if`/`elif` chain without calling `notify_state`. Any client with a custom endpoint, a local +cluster, or `fallback_hosts=[]` is affected, and there is no way back. +`test/uts/realtime/unit/connection/connection_failures_test.py -k rtn15h3` + +**1.2 A connection-level ERROR skips every failure action.** RTL3a, RTN7e. +`on_error` ends with `enact_state_change(FAILED, …)` (`connectionmanager.py:477`) instead of +`notify_state`, so `cancel_transition_timer` (`:664`), `fail_queued_messages` (`:689`) and +`channels._propagate_connection_interruption` (`:690`) are all skipped. Channels are left +ATTACHED or ATTACHING with a null `error_reason` and no state change; a pending `attach()` +never returns; pending publishes never resolve **or** reject; the transition and suspend +timers keep running. Every other route to FAILED goes through `notify_state` and is correct, +so the fix is one line. Two batches found the two halves independently; it is one issue. +`test/uts/realtime/unit/channels/channel_connection_state_test.py -k rtl3a` +`test/uts/realtime/unit/channels/channel_publish_pending_test.py -k rtn7e` + +**1.3 `detach()` never returns when the connection is not CONNECTED.** RTL5l. +RTL5l requires an immediate transition to DETACHED. `detach()` requests DETACHING, +`_check_pending_state()` sends nothing because the connection is not CONNECTED, and +`detach()` then awaits the internal state emitter (`channel.py:212`) for a transition +nothing will produce. The coroutine never returns and the channel is stuck in DETACHING. +`test/uts/realtime/unit/channels/channel_detach_test.py -k rtl5l` + +**1.4 `set_options()` never returns for an already-ATTACHED channel.** RTL16a. +`_attach_impl()` (`channel.py:99`) sends the ATTACH without going through +`_request_state(ATTACHING)`, so the channel is still ATTACHED when the server's ATTACHED +arrives; `_on_message` takes the RTL12 branch (`:722-726`), which emits `update` on the +*public* emitter, while `set_options` awaits the *internal* one, written only by +`_notify_state`. The options are stored; the call never resolves. Adding the +`_request_state` fixes both halves. +`test/uts/realtime/unit/channels/channel_options_test.py -k rtl16a` + +**1.5 A presence action on a DETACHED channel re-attaches and then hangs.** RTL11, RTP8g. +Both require an immediate error with nothing sent. `_enter_or_update_client` groups DETACHED +with INITIALIZED (`presence.py:258-264`), starts an implicit attach and queues the message, +so a PRESENCE goes out and — with no ACK — `enter()` never returns. `_leave_client` does not +have the grouping, so the SDK is inconsistent with itself. +`test/uts/realtime/unit/presence/realtime_presence_channel_state_test.py -k rtl11_queued_presence_fail_detached` + +### Tier 2 — the wrong error, or an error where there should be none + +**2.1 `raise state_change.reason` raises `TypeError` when the reason is `None`.** RTL24, +RTL4c, RTL13b, RTP16c. Three sites — `channel.py:102` (`set_options`), `:150` (`attach`), +`:219` (`detach`) — raise whatever the state change carries, and the state change often +carries nothing, because a server-initiated DETACHED's error is discarded at `:735`, +`_request_state(ATTACHING)` and `_notify_state(SUSPENDED)` being called with no reason. The +caller gets `TypeError: exceptions must derive from BaseException` instead of an +`AblyException`, and `channel.errorReason` stays null. Two fixes, worth doing separately: +guard the `raise`, and carry the DETACHED's error onto the state change. +`test/uts/realtime/unit/channels/channel_attributes_test.py -k "rtl24 or rtl4c_error_cleared_on_attach"` + +**2.2 `EventEmitter` keys its wrapper registry on the listener alone.** RTL8b, RTP7b. +`util/eventemitter.py:85` (and `:130`, for `once`) stores the wrapper under the listener +object rather than under `(event, listener)`, so registering one listener for a second event +overwrites the first entry and `off` for the first event hands pyee the wrong wrapper — +`KeyError` out of `pyee/base.py:262`. The first registration is also left live, and `off` +writes `None` rather than deleting (`:167`), so every later `off` for that listener silently +no-ops. It affects `connection.on`, `channel.on`, `channel.subscribe` and +`presence.subscribe` equally, and three batches found it independently. Reproducible in six +lines with no Ably connection: +```python +e.on('alpha', listener); e.on('beta', listener); e.off('alpha', listener) +# KeyError: .wrapped_listener> +``` +The registry needs an `(event, listener)` key holding a list per key. +`test/uts/realtime/unit/channels/channel_subscribe_test.py -k rtl8b` +`test/uts/realtime/unit/presence/realtime_presence_subscribe_test.py -k rtp7b` + +**2.3 `Channels.__getattr__` answers any unknown attribute by creating a channel.** +`ably/rest/channel.py:408` is `return self.get(name)`, so `hasattr(client.channels, 'x')` +is always `True` **and creates a channel called `x`**. A typo becomes a phantom channel, +`client.channels.get_derived(...)` raises `TypeError: 'RealtimeChannel' object is not +callable` rather than `AttributeError`, and any code that probes the collection mutates it. +No derived test loses coverage to it, because none names an attribute the collection does +not define — that constraint is itself the evidence. `Channels.__iter__` is annotated +`Iterator[str]` and yields `Channel` objects, which is the same carelessness one size down. +No gated test; reproduce with: +```python +len(client.channels) # 0 +hasattr(client.channels, 'foo') # True +len(client.channels) # 1 +``` + +**2.4 Synthesized LEAVE timestamps are timezone-aware; every other one is naive.** RTP19, +RTP19a, TP3g. `presence.py:586` and `:720` use `datetime.now(timezone.utc)`, while every +wire-derived `PresenceMessage.timestamp` comes from `_dt_from_ms_epoch`, built on +`datetime.utcfromtimestamp(0)` (`types/presence.py:12-19`). Comparing them raises +`TypeError: can't compare offset-naive and offset-aware datetimes`. Nothing inside the +library compares them, so it bites only application code — but any subscriber that sorts the +timestamps of the messages it receives will hit it. Related to #656. No gated test. + +**2.5 `Channels._on_channel_message` subscripts before it checks.** `channel.py:1038-1045` +does `channel = self.__all[channel_name]` and only then `if not channel:`, so a message for +an unknown channel raises `KeyError` inside the protocol task — swallowed and logged — and +the "non-existent channel" branch is dead code. No gated test. + +**2.6 `ws_connect` catches too little, so two of the three commonest transport failures +vanish.** RTN14d, RTN17d, RTN17e. `websockettransport.py:117` catches only +`(WebSocketException, socket.gaierror)`, so a `ConnectionRefusedError` (an `OSError`) and an +`asyncio.TimeoutError` never reach `_emit('failed')`, the future `try_host` awaits is never +settled, and the attempt is ended only by the transition timer with a generic 50003/504. +Three consequences: refused, timed-out and silently-accepted connections are +indistinguishable; **the fallback loop is unreachable** for refused and timeout (measured: +one attempt and no fallback tried, against six for a DNS error); and each attempt leaks a +`connect_base()` task and its future (`connectionmanager.py:646`), printing `Task was +destroyed but it is pending!` at shutdown. Widening the `except`, or emitting `failed` from a +guard no exception can escape, fixes all three. +`test/uts/realtime/unit/connection/connection_failures_test.py -k rtn14d` (this one is +adapted, so it **passes** today and fails when the defect is fixed — read it as the pin, not +the proof) + +**2.7 `check_connection` is a synchronous `httpx.get` inside the event loop.** RTN17j, +REC3a–c. `connectionmanager.py:193` calls module-level `httpx.get` synchronously from the +async fallback loop, once per fallback host, so it blocks the event loop for the duration of +each request and bypasses the client's own HTTP layer entirely — no client-scoped seam can +observe or stub it. Two issues in one line. The derived tests reach it only through +`monkeypatch`; the three REC3 tests are skipped stubs. + +**2.8 An authCallback error is always rewritten as 401/40170, so RSA4d is unreachable.** +RSA4d, RSA4d1. `rest/auth.py:182-187` wraps every callback exception as +`AblyException(…, 401, 40170, cause=e)`, discarding the original status, and +`on_error_from_authorize` branches on `status_code == 403` to reach FAILED — a branch an +authCallback can never take. A 403 from an auth provider therefore gives DISCONNECTED with +80019/401 where the spec requires FAILED with 80019/403. The same wrapper is why the 80019 +carries no `cause`, and why RSA4f's non-`AblyException` (`AttributeError: 'int' object has +no attribute 'key_name'`) reaches `Connection#errorReason` with no `code` at all. One issue, +three symptoms. +`test/uts/realtime/unit/auth -k "rsa4d or rsa4f_callback_invalid_type_format"` + +**2.9 `AblyException`'s status code and code are transposed at six sites.** The constructor +is `(message, status_code, code)`. `channel.py:203` (`90001, 400`), `channel.py:217` +(`90000, 409`), `connectionmanager.py:343` (`80000, 500`), `mixins.py:84` (`40019, 40019`), +`mixins.py:88` and `:111` (`40018, 40018`). `channel.py:860` (`408, 90007`) and +`message.py:304` (`400, 40018`) are right, so it is a repeated slip rather than a +misunderstanding. A caller branching on `status_code` today gets a five-digit number. +Mechanical fix. No gated test — three adapted tests assert the wrong values deliberately +(`channel_detach_test.py -k "rtl5b or rtl4h"`), so they turn red when it is fixed, which is +the signal wanted. + +### Tier 3 — silently wrong data + +**3.1 The clientId is sent on outgoing PresenceMessages where the spec requires it absent.** +RTP8c, RTP9d, RTP10c. `enter()`, `update()` and `leave()` must leave `clientId` off the +message so the connection's own is implied; `presence.py:239`/`:315` resolve +`effective_client_id` and `types/presence.py:160-161` writes it whenever set. The implicit +case is not distinguished from the explicit one. **The most substantive protocol-level +non-compliance in the suite.** Adapted rather than gated, so the three tests pass today: +`test/uts/realtime/unit/presence/realtime_presence_enter_test.py -k "rtp8a_enter_sends or rtp9a_update_sends or rtp10a_leave_sends"` + +**3.2 A LEAVE during a SYNC emits three `leave` events for one member.** RTP2h2a, RTP2h2b. +Measured `[('present','alice'), ('leave','bob'), ('leave','bob'), ('leave','bob')]` where the +spec requires none. Three causes: `remove()` reports the ABSENT store with the same `True` +as a deletion and `set_presence` broadcasts on it without testing `sync_in_progress`; +`remove()` leaves the key in `_residual_members`; and `set_presence` synthesizes a LEAVE for +`residual + absent`, where `absent` exists so the caller can *delete* those members, not +announce them. An application counting members from events gets it wrong. +`test/uts/realtime/unit/presence/presence_sync_test.py -k rtp2h2a_leave_during_sync_absent_cleanup` + +**3.3 A message with no id in a ProtocolMessage with no id is given the id `"None:0"`.** +TM2a. **Already filed as #706**, from the REST side; this is the same line +(`message.py:369-375`) reached through the realtime `messages` array, and one fix closes +both. Worth adding to #706 that the presence consequence is worse than a wrong id: `"None:0"` +does not start with the member's `connectionId`, so `is_synthesized()` returns True and the +RTP2b newness check silently takes the timestamp path instead of the msgSerial path. +`test/uts/realtime/unit/channels/message_field_population_test.py -k tm2a_no_id_without_protocol_id` + +**3.4 Channel serial bookkeeping is wrong in four adjacent places.** RTL15b, RTL15b2, RTL15c. +`attachSerial` is taken from a *resumed* ATTACHED (`:708`, assigned before `resumed` is +computed at `:716`); a PRESENCE does not update `channelSerial` (`:751-755`), unlike MESSAGE +and ANNOTATION; a message with **no** `channelSerial` **clears** the stored one (`:743`, and +the same shape at `:708-709` and `:772`); and `channelSerial` is cleared on SUSPENDED +(`:810-812`) under a comment naming the superseded RTP5a1, so the ATTACH after a suspend +carries no serial for the server's RTL4c1 continuity decision. Four lines, one pass, one +issue. The last one loses message continuity on every channel suspend. +`test/uts/realtime/unit/channels/channel_properties_test.py -k "rtl15b or rtl15c"` + +**3.5 The connection id, key and details are cleared on SUSPENDED, so a suspended connection +stops resuming.** RTN8d, RTN9d, RTN14h. `enact_state_change` (`connectionmanager.py:181-189`) +clears them for SUSPENDED as well as CLOSED and FAILED, citing RTN16d — which is about +recovery keys, not resume. RTN8c/RTN9c were replaced in specification 6.1.0 precisely +because the client should always attempt a resume and let the server decide. Measured over +72 attempts: the 12 that carried no `resume` were every attempt after the suspend timer +fired. Clearing only on CLOSED and FAILED fixes both symptoms. +`test/uts/realtime/unit/connection -k "rtn8d or rtn14h_resume_after_ttl"` + +**3.6 The RTP17 map runs the newness check across connectionIds.** RTP17h. `_my_members` is a +plain `PresenceMap` keyed by `client_id`, so `put()` compares `conn-B:0:0` against +`conn-A:0:0` by `msgSerial` and index and discards the newer entry — pinning a dead +connection's member under a clientId, which is the precise failure RTP17h exists to prevent. +`msgSerial` is ordered only within one connection, so the comparison is meaningless as well +as wrong. `PresenceMap.put()` has no knowledge of its key function, so the fix has to reach +the map's construction. +`test/uts/realtime/unit/presence/local_presence_map_test.py -k rtp17h` + +**3.7 Messages are delivered to a channel that is not ATTACHED.** RTL17. The MESSAGE branch +of `_on_message` (`channel.py:738-750`) has no state guard, and `_on_channel_message` +(`:1028`) only checks the channel exists, so a message arriving while the channel is +ATTACHING, DETACHING, SUSPENDED or FAILED reaches subscribers exactly as one arriving while +ATTACHED does. +`test/uts/realtime/unit/channels/channel_subscribe_test.py -k rtl17` + +**3.8 A duplicate `msgSerial` is issued after a failed resume.** RTN7b, found while deriving +RTN19a2. `msg_serial` is reset to 0 (`connectionmanager.py:411`) when the connectionId +changes, but `_send_protocol_message_on_connected_state` resends each requeued message with +its **original** serial, so after a failed resume two messages go out as 0 and 1 and the next +*new* publish also goes out as 0 — a duplicate serial on one connection, which RTN7b forbids. +The code comment at `:411` justifies omitting the reset with "we fail all pending messages on +disconnect per RTN7e", which issue 1.2 above proves false. No gated test: the UTS test for +this cannot distinguish the behaviours (recorded under UTS Spec Errors), so the finding is +the output. + +**3.9 `Channels.release` does not detach the channel.** RTS4a. `channel.py:1012-1026` deletes +the entry and sends nothing, so the channel is dropped from the collection while still +attached in the Ably service — the application goes on being billed for and delivered to a +channel it believes it released. +`test/uts/realtime/unit/channels/channels_collection_test.py -k rts4a_release_detaches_attached` + +**3.10 The UPDATE event drops the CONNECTED message's error.** RTN24. `on_connected` +(`connectionmanager.py:425-428`) builds the `ConnectionStateChange` without the `reason` it +was handed, so an application never sees the error a CONNECTED carries — including the +RTN15c7 failed-resume error, which RTN25 lists among those that must set +`Connection#errorReason`. A one-line fix. +`test/uts/realtime/unit/connection/update_events_test.py -k rtn24_update_event_with_error` + +**3.11 Detach protocol faults.** RTL5i — a detach requested while already DETACHING sends a +**second** DETACH, because `_request_state` calls `_check_pending_state()` even after +`_notify_state` returns early. RTL5k — an ATTACHED arriving while DETACHING or DETACHED is +ignored instead of answered with a new DETACH, so the detach times out and the channel +returns to ATTACHED. Same method, one issue. +`test/uts/realtime/unit/channels/channel_detach_test.py -k "rtl5i or rtl5k"` + +**3.12 The deleted RTL4j ATTACH_RESUME flag is still set on every reattach.** RTL4j, deleted +in specification 6.1.0. `_notify_state` sets `__attach_resume` on every ATTACHED and +`_encode_flags` ORs it in, so every reattach carries `flags: 32` and tells the server +something the server now decides for itself. +`test/uts/realtime/unit/channels/channel_attach_test.py -k rtl4j` + +**3.13 A decode error other than 40018 never fails the channel.** PC3. `channel.py:744-748` +gives channel-level handling to 40018 alone; every other decode error is logged and the batch +**silently skipped**, with no state change and no `error_reason`, so a vcdiff message with no +decoder is simply lost. Compounding it, `message.py:302-305` checks the delta's `from` id +*before* the decode pipeline, so the first-message case raises 40018 and never reaches the +missing-decoder branch at all. +`test/uts/realtime/unit/channels/channel_delta_decoding_test.py -k pc3_no_plugin_fails` + +**3.14 A failed automatic re-entry reports the NACK, not the 91004 wrapper.** RTP17e. +`_reenter_member` (`presence.py:667-674`) emits the UPDATE with `resumed=False` and the raw +NACK as `reason`, where the spec requires `resumed` true and a 91004 naming the clientId with +the NACK as `cause`. Local to one method. +`test/uts/realtime/unit/presence/realtime_presence_reentry_test.py -k rtp17e` + +**3.15 `ping()` rejects DISCONNECTED, and charges the connect wait to the caller's timeout.** +RTN13b, RTN13c, RTN13d. `ConnectionManager.ping` (`:362`) admits only CONNECTED and +CONNECTING where RTN13d requires a ping from DISCONNECTED to be deferred; and `:375` enters +`asyncio.wait_for` as soon as `ping()` is called, so a ping requested while CONNECTING can +expire before its HEARTBEAT has gone out. +`test/uts/realtime/unit/connection/connection_ping_test.py -k "rtn13b_deferred or rtn13c or rtn13d"` + +**3.16 A failed RTN22 reauth leaves no trace on the connection.** RSA4c1, RSA4c3. +`websockettransport.py:170-175` awaits `auth.authorize()` inside a bare `except Exception` +that only logs, so nothing reaches `on_error_from_authorize` and `errorReason` stays as it +was. **Hold this one**: specification#466 would make the current behaviour correct, and the +two UTS specs disagree about it today. +`test/uts/realtime/unit/auth/connection_auth_test.py -k rsa4c3_callback_error_stays_connected` + +**3.17 TokenParams passed to an authCallback carry no clientId on a realtime client.** +RSA12a, RTN2e. `Auth.__init__` nulls `client_id` for a realtime client (`rest/auth.py:36-41`) +and `_ensure_valid_auth_credentials` only adds it when non-null, so an auth server never +learns the configured clientId. The REST client does pass it. The same nulling is why +`client.client_id` and `client.auth.client_id` disagree before CONNECTED (RTC17). +`test/uts/realtime/unit/auth/connection_auth_test.py -k rtn2e` + +**3.18 No 40171 log at instantiation with a non-renewable token.** RSA4a1. Requires an +info-level record carrying 40171 and the TI5 help URL; the SDK logs at debug with no code, +and `grep -rn href ably/` finds no help URLs at all. Small, and the TI5 half is a gap of its +own. +`test/uts/realtime/unit/auth/token_expiry_non_renewable_test.py -k rsa4a1` + +### Tier 4 — absent features + +Each row is one feature and one issue. None is a bug in existing code. + +| Feature | Spec points | Tests | Reproduction (`-k` against `test/uts/realtime/unit/`) | +|---|---|---|---| +| Connection recovery, entire — `createRecoveryKey`, the `recover` connect parameter, recovery-key decoding. `recover` is stored on `Options` and read nowhere | RTN16, RTN16f–k, RTC1c | 6 | `connection/connection_recovery_test.py`, `client/realtime_client_test.py -k rtc1c` | +| `MessageFilter` and filtered subscriptions | RTL22, RTL22a–d, MFI1, MFI2a–e | 5 | `channels/channel_subscribe_test.py -k rtl22` | +| Derived channels — `DeriveOptions`, `Channels.getDerived` | RTS5, RTS5a, RTS5a1, RTS5a2, DO2a | 5 | `channels/channel_options_test.py -k "rts5 or do2a"` | +| Retry backoff, jitter and `retryIn` on both state-change types | RTB1, RTB1a, RTB1b | 4 | `connection/backoff_jitter_test.py` | +| `RealtimeChannel#whenState` (the connection has a private equivalent) | RTL25, RTL25a, RTL25b | 4 | `channels/channel_when_state_test.py` | +| `attachOnSubscribe` on `ChannelOptions`. Also forces the suite's largest adaptation — 21 tests attach explicitly to work around it | TB4, RTL7h, RTP6e | 3 | `channels/channel_subscribe_test.py -k rtl7h`, `channels/channel_options_test.py -k tb4`, `presence/realtime_presence_subscribe_test.py -k rtp6e` | +| `echoMessages`, in both the client-filter and `echo`-parameter forms | RTC1a, RTL7f | 2 | `client/realtime_client_test.py -k rtc1a`, `channels/channel_subscribe_test.py -k rtl7f` | +| `RealtimePresence#history` (the realtime *channel* does delegate `history`) | RTP12, RTP12a, RTP12c | 2 | `presence/realtime_presence_history_test.py` | +| PING/PONG handling — actions 22 and 23 are not modelled | RTN23c1, RTN23c2 | 2 | `connection/heartbeat_test.py -k rtn23c1` | +| The `heartbeats` connect parameter. Binding on ably-python, which cannot observe ping frames | RTN23a | 1 | `connection/heartbeat_test.py -k rtn23a_heartbeats_true` | +| `untilAttach` on `RealtimeChannel#history` | RTL10b | 1 | `channels/channel_history_test.py -k rtl10b_adds_from_serial` | +| `ChannelOptions.withCipherKey` | TB3 | 1 | `channels/channel_options_test.py -k tb3` | +| `hasBacklog` on `ChannelStateChange`. `Flag.HAS_BACKLOG` exists and is never read — but the features spec makes the attribute optional, so this is a "should we" rather than a "must" | RTL2i, TH6 | 1 | `channels/channel_state_events_test.py -k rtl2i_has_backlog_flag_true` | +| Subscribing to an **array** of presence actions — the list reaches pyee as a dict key and raises `TypeError: unhashable type: 'list'` | RTP6b | 1 | `presence/realtime_presence_subscribe_test.py -k rtp6b` | + +### Tier 5 — missing accessors over correct behaviour + +One issue, or four small ones. In each case the value exists and behaves exactly as the +specification requires; there is simply no public member, so a caller has to reach through +an internal object. None is gated, per the house ruling at the end of this file, so +none of these shows up as a failure — which is why they are easy to lose. + +| Missing | Reachable today as | Spec points | +|---|---|---| +| `Connection#id`, `Connection#key` | `connection.connection_manager.connection_id`, `connection.connection_details.connection_key` | RTN3, RTN8, RTN9 | +| `RealtimeChannel#properties` (`ChannelProperties`, with `attachSerial` and `channelSerial`) | the name-mangled `__attach_serial` / `__channel_serial` | RTL15 | +| `ChannelStateChange#event`, and a `ChannelEvent` type | the key the listener was registered against | RTL2, RTL5, RTL12, TH5 | +| A public `Connection#whenState` | the private `Connection._when_state`, which `test/ably/realtime/realtimepresence_test.py` already reaches for in two places | RTN26 | + ## How the specifications are adopted here Choices about the approach, as against the behaviour recorded above. @@ -334,6 +2054,119 @@ connection and a failed request directly, as httpx raises `ConnectError`, The alternative, replacing the whole client, would stub out the code under test. +### A websocket mock is a connect callable, supplied as a client option + +The seam is `TestOptions(websocket_connect=...)`, read by `WebSocketTransport` +and called in place of `websockets.connect`. It is called as +`connect(url, additional_headers=headers)`, or with `extra_headers=headers` if +that raises `TypeError`, and returns an async context manager yielding an object +supporting `__aiter__`, `send` and `close` — the whole surface the transport +uses. + +Replacing the connect call keeps the URL and query parameter construction, the +host fallback loop, frame decoding, the idle timer and the `ConnectionManager` +state machine in the path. Injecting a replacement transport, the alternative, +would stub out all of it, which is what the specifications assert on. + +A connect callable that raises reaches the library's failure handling exactly +where a real one does, so a refused connection, a DNS error and a timeout are +simulated by the exception the callable raises. + +### Fake time is a timer factory, and a last resort + +Every delayed callback in the realtime library is a `ably.util.helper.Timer`, +constructed at six sites across the transport, the channel and the connection +manager. `TestOptions(timer=...)` replaces it at all six, selected once per +consumer by `select_timer(options)`. `test/uts/helpers/clock.py` is the fake: +`await clock.advance(ms)` fires what has fallen due, in due order, and lets the +event loop settle so the effects have landed when it returns. It is the +`enable_fake_timers()` / `ADVANCE_TIME(ms)` pair of `mock_websocket.md`. + +The option is client-scoped for the same reason the HTTP mock is: a +module-level factory would outlive the test that set it and be shared by every +client the suite builds. + +A derived test reaches for it only where nothing else reaches the behaviour. +`realtime_request_timeout`, `disconnected_retry_timeout`, +`suspended_retry_timeout` and `channel_retry_timeout` are client options, and +`maxIdleInterval` arrives in the CONNECTED message's `connectionDetails` and is +honoured, so a real short value drives those and the test stays free of the +interaction between faked time and the real `await`s around it. What has no +such handle is `connection_state_ttl`: it is not a constructor parameter, and +the suspend timer reads `Defaults.connection_state_ttl` directly, which costs +120 real seconds. That is what the fake clock is for. + +### Injected frames are encoded for the protocol the client asked for + +`send_to_client(CONNECTED_MESSAGE)` leaves the encoding open, and +`use_binary_protocol` defaults to `True`, so a mock that always fed JSON would +make `decode_raw_websocket_frame` raise. `ws_read_loop` catches that with a +broad `except Exception` and logs it, so the test would simply hang to its +timeout with nothing to go on. + +A message given as a dict is therefore msgpack-packed or JSON-encoded to match +the `format` query parameter of the connection it is going to, exactly as the +HTTP mock encodes a native response body to match the request's `Accept` +header. A message given as `bytes` or `str` is passed through untouched, so a +specification that is about the wire format can still pin it. + +A message is deep-copied before it is encoded, so the shared templates survive +being sent. The templates are still plain dictionaries, and +`connected_message(...)` builds a variant rather than mutating one. + +### `realtime_client` defaults `auto_connect` off + +`AblyRealtime` connects from its constructor when `auto_connect` is true, which +is the option's own default. Derived tests get the opposite default, for three +reasons. + +The await-based mock API needs a waiter registered before the event it is +waiting for. A client that connects during construction has already made its +first attempt by the time the test's next statement runs, so +`await_connection_attempt()` could only ever catch a retry. + +A realtime client built for a REST-over-realtime specification is given no +websocket mock, because the specification is about HTTP. With `auto_connect` +true that client opens a real websocket to the internet. + +And the specifications themselves overwhelmingly pass `autoConnect: false` and +call `connect()`. The ones that are about the default — the three in +`connection/auto_connect_test.md` — name `auto_connect=True` explicitly, which +is what a test of a default should do anyway. + +### `realtime_client` defaults the fallback hosts empty + +`ConnectionManager.check_connection` calls `httpx.get` directly, module level +and synchronously, on every host of the fallback loop. No seam reaches it: it +is not the client's HTTP layer, so `TestOptions(http_transport=...)` does not +serve it either. + +Measured, a client with the default fallback hosts and a connect that fails +makes six connection attempts and one real request to +`internet-up.ably-realtime.com` per host, blocking the event loop for each. +With `socket.create_connection` blocked it makes one attempt, because the +connectivity check raises and `connect_with_fallback_hosts` swallows it per +host. Either way a unit test has reached the network. + +`fallback_hosts=[]` keeps the loop out of the path entirely. A specification +that is about the fallback loop passes its own `fallback_hosts`, and has to +accept that the connectivity check goes to the real internet; the three REC3 +tests are skipped for that reason already. + +### A DISCONNECTED template carries a status code the specification omits + +`mock_websocket.md` writes `DISCONNECTED_MESSAGE` with an `ErrorInfo` of +`code` and `message` only. `ConnectionManager.on_disconnected` reads +`exception.status_code` and compares it against 500 without guarding, so a +DISCONNECTED with no `statusCode` raises `TypeError` inside a task whose +exception is only logged — the same silent hang as a frame that will not +decode. + +The template supplies `statusCode: 400`, the status Ably sends with 80003. The +unguarded comparison is recorded above; a +specification that is about a DISCONNECTED without a status code sends its own +message rather than the template. + ### A mock serves one client rather than being installed globally The specifications write `install_mock(mock_http)` and warn against passing a @@ -387,8 +2220,8 @@ renaming; the function name makes failures readable without it. is no clock seam — `ably/http/http.py` calls `time.time()` directly. Where a specification advances time, the derived test shortens the interval through a client option instead, which is what the specifications themselves do for -`fallback_retry_timeout`. Adding a clock seam is left until the realtime specs, -which need one for reconnection timing. +`fallback_retry_timeout`. The realtime specifications do get a timer seam, +for the one interval no option reaches; see the fake time section above. ### A TokenDetails payload is recognised by its `token`, not only by `issued` @@ -404,3 +2237,64 @@ widens what is accepted, and the existing `issued` branch is untouched. It is a deliberate divergence from ably-js, whose derived suite avoids the question by returning a `text/plain` token string in place of the specification's JSON body. + +### A missing public accessor is adapted around, never gated + +`writing-derived-tests.md` separates three situations, and only the last two are +deviations: a differently *spelled* public API is ordinary translation and is +recorded nowhere; a different public *value or effect* is a deviation; and an +internal API whose *shape* differs is a deviation at the unit tier only, to be +adapted while preserving the coverage. + +A fourth situation came up repeatedly in the realtime derivation and sits between +them: the behaviour is exactly what the specification requires, the value exists and +has the right lifecycle, but there is **no public member at all** — not a +differently named one, none. `Connection#id` and `Connection#key`, +`RealtimeChannel#properties`, `ChannelStateChange#event`, a public +`Connection#whenState`, a distinct `LocalPresenceMap` type. + +The ruling is: **adapt, and record the missing API separately.** The test asserts +the equivalent observable, however internal — `connection.connection_manager.connection_id`, +the name-mangled `__channel_serial`, the event key a listener was registered +against — with a comment at the first site and the reason in the module docstring. +Each file defines a small reader at the top (`connection_id(client)`, +`attach_serial(channel)`) so the adaptation is in one place and the assertions read +as the specification writes them. + +It is not gated, for a reason worth stating plainly: gating would take real +behavioural coverage out of the run indefinitely over a question of spelling. The +RTL15 serial tests are the clearest case — ten tests about when a channel serial is +written and cleared, four of which found genuine defects. Gating all ten because +`properties` does not exist would have found none of them. + +The cost is that a missing accessor never shows up as a failure, so it is easy to +lose. That is what the *missing accessor* table +above is for, and why the candidate-issue list gives them a +tier of their own rather than folding them into the features that are absent +outright. + +Only wrong behaviour is gated. + +### Deviation records are consolidated, not accumulated + +Fourteen specification areas were derived in parallel, each writing its own +`deviations-.md`. Those files are scaffolding and are not kept: +`writing-derived-tests.md` requires one entry per **root cause**, and a per-area file +cannot see that two areas found the same defect. Three defects were in fact reported +by more than one area — the `on_error` bypass, the transposed `AblyException` +arguments, and the `EventEmitter` wrapper registry — and one was reported as a defect +and then refuted. + +So the per-area files were merged into this file and deleted, and the comments in +the tests that pointed at them now point here. +A refuted claim is kept, under *Investigated and not defects*, because the reason a +reader needs it is precisely that it looks like a defect. + +### This file carries its own counts, and they are measured + +The header states how many derived tests there are, how many pass, how many are +gated and how many cannot run. Those numbers are the check that the file is still +true: the gated count must equal the number of failures under `RUN_DEVIATIONS=1`, +and the sum must equal the number of skips without it. Anyone changing the suite +should re-run both and update the header, rather than copying the previous numbers +forward. diff --git a/test/uts/helpers/client.py b/test/uts/helpers/client.py index a20014f8..20f6e0bd 100644 --- a/test/uts/helpers/client.py +++ b/test/uts/helpers/client.py @@ -1,12 +1,25 @@ """Construction and teardown for the clients that derived tests drive.""" -from ably import AblyRest +import asyncio +import logging + +from ably import AblyRealtime, AblyRest from ably.types.testoptions import TestOptions +from test.uts.helpers.clock import settle + +log = logging.getLogger(__name__) DEFAULT_KEY = 'app.key:secret' CREDENTIAL_OPTIONS = ('key', 'token', 'token_details', 'auth_callback', 'auth_url', 'key_name') +# How long teardown gives a client to reach CLOSED, so that one left in a state +# it cannot leave fails its own test rather than hanging the suite. +CLOSE_TIMEOUT = 5.0 + +# The wait the specifications quote for a connection state change. +STATE_TIMEOUT = 5.0 + __open_clients = [] @@ -24,6 +37,195 @@ def rest_client(mock_http, **kwargs): return client +def realtime_client(mock_websocket=None, mock_http=None, clock=None, **kwargs): + """A realtime client whose I/O the mocks passed to it serve. + + Stands in for the specifications' `install_mock(mock_ws)` followed by + `Realtime(options: ...)`. `mock_websocket` serves its websocket + connections, `mock_http` its HTTP calls and `clock` its delayed callbacks; + each is left to the real implementation when omitted. + + Credentials default to a key where a specification does not name any, + `auto_connect` to false, and the fallback hosts to none. See + [deviations.md](../deviations.md) for those last two. The client is closed + when the test ends. + """ + if not any(option in kwargs for option in CREDENTIAL_OPTIONS): + kwargs['key'] = DEFAULT_KEY + kwargs.setdefault('auto_connect', False) + kwargs.setdefault('fallback_hosts', []) + client = AblyRealtime(test_options=TestOptions( + http_transport=mock_http.as_transport() if mock_http is not None else None, + websocket_connect=mock_websocket.as_connect() if mock_websocket is not None else None, + timer=clock.timer if clock is not None else None, + ), **kwargs) + __open_clients.append(client) + return client + + +async def await_connection_state(client, state, timeout=STATE_TIMEOUT): + """Waits for `client`'s connection to reach `state`. + + This is the specifications' `AWAIT_STATE`. The listener is registered + before the wait begins, so a state reached in the meantime is not missed. + + Only a state the client reaches on its own can be waited for this way: + with a `FakeClock` installed, nothing moves time but `advance()`, so a + state that a timer produces has to be recorded with `connection.on(...)` + and asserted afterwards, or waited for from a task started first. + """ + connection = client.connection + if connection.state == state: + return + reached = asyncio.get_running_loop().create_future() + + def on_state(change): + if not reached.done(): + reached.set_result(change) + + connection.once(state, on_state) + try: + await asyncio.wait_for(reached, timeout) + except asyncio.TimeoutError: + raise AssertionError( + f'Timed out waiting for connection state {state}; it was {connection.state}') from None + + +async def next_connection_state(client, state, timeout=STATE_TIMEOUT): + """Waits for `client`'s connection to enter `state` afresh. + + Where `await_connection_state` is satisfied by the state the connection is + already in, this one always waits for the next entry into it, which is what + a specification means by reconnecting to a state it has held before. + """ + connection = client.connection + reached = asyncio.get_running_loop().create_future() + + def on_state(change): + if not reached.done(): + reached.set_result(change) + + connection.once(state, on_state) + try: + return await asyncio.wait_for(reached, timeout) + except asyncio.TimeoutError: + raise AssertionError( + f'Timed out waiting for the next {state}; it was {connection.state}') from None + + async def close_open_clients(): + """Closes the clients a test built, whatever state they are in. + + A realtime client which never connected, or whose connect failed, reaches + CLOSED by the same path as a connected one. Teardown carries on through a + client that cannot get there, closing its HTTP layer directly so the rest + of the suite is not left holding it. + """ while __open_clients: - await __open_clients.pop().close() + client = __open_clients.pop() + try: + await asyncio.wait_for(client.close(), CLOSE_TIMEOUT) + except Exception as error: + log.warning(f'close_open_clients(): {type(client).__name__} did not close: {error!r}') + try: + await client.http.close() + except Exception: + pass + + +async def await_channel_state(channel, state, timeout=STATE_TIMEOUT): + """Waits for `channel` to reach `state`, returning at once if it holds it.""" + if channel.state == state: + return + reached = asyncio.get_running_loop().create_future() + + def on_state(change): + if not reached.done(): + reached.set_result(change) + + channel.once(state, on_state) + try: + await asyncio.wait_for(reached, timeout) + except asyncio.TimeoutError: + raise AssertionError( + f'Timed out waiting for channel state {state}; it was {channel.state}') from None + + +async def poll_until(condition, timeout=STATE_TIMEOUT, description='condition'): + """Yields to the event loop until `condition()` holds. + + This is the specifications' `AWAIT UNTIL`. It suits a premise a state does + not capture, such as an attempt being in flight: `client.connect()` sets + CONNECTING before the attempt is scheduled, so waiting on the state is + satisfied before anything has happened. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not condition(): + if loop.time() >= deadline: + raise AssertionError(f'Timed out waiting until {description}') + await asyncio.sleep(0) + + +async def connected_client(mock_websocket, **kwargs): + """A realtime client already CONNECTED through `mock_websocket`. + + Most channel specifications open this way, since a channel cannot attach + until the connection carrying it is up. + """ + from ably.realtime.connection import ConnectionState + from test.uts.helpers.mock_websocket import CONNECTED_MESSAGE + + if mock_websocket.on_connection_attempt is None: + mock_websocket.on_connection_attempt = lambda conn: conn.respond_with_success(CONNECTED_MESSAGE) + client = realtime_client(mock_websocket, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +async def drop_transport(client, mock_websocket): + """Drops the transport under `client` and leaves the connection DISCONNECTED. + + The connection retries a drop from CONNECTED immediately, so the attempt + handler is stalled first, leaving the connection settled where a + specification expects to find it rather than reconnecting behind the + assertions. Returns the connection states recorded along the way. + """ + from ably.realtime.connection import ConnectionState + + states = [] + + def record(change): + states.append(change.current) + + client.connection.on(record) + mock_websocket.on_connection_attempt = lambda conn: None + mock_websocket.simulate_disconnect() + await poll_until( + lambda: ConnectionState.DISCONNECTED in states, + description='the connection to report DISCONNECTED') + await settle() + return states + + +async def reconnect_transport(client, mock_websocket, connected_message=None): + """Drops the transport and waits for `client` to reach CONNECTED again. + + Waiting on the connection state alone would be satisfied by the CONNECTED + the client already holds, so this counts a fresh arrival. + """ + from ably.realtime.connection import ConnectionState + from test.uts.helpers.mock_websocket import CONNECTED_MESSAGE + + message = CONNECTED_MESSAGE if connected_message is None else connected_message + reconnected = [] + + def record(change): + reconnected.append(change) + + client.connection.on(ConnectionState.CONNECTED, record) + mock_websocket.on_connection_attempt = lambda conn: conn.respond_with_success(message) + mock_websocket.simulate_disconnect() + await poll_until(lambda: len(reconnected) > 0, description='the connection to be re-established') + return reconnected[0] diff --git a/test/uts/helpers/clock.py b/test/uts/helpers/clock.py new file mode 100644 index 00000000..3af3200f --- /dev/null +++ b/test/uts/helpers/clock.py @@ -0,0 +1,153 @@ +"""A fake clock, implementing the fake timers that the Universal Test +Specifications are written against. + +The pseudocode convention lives in +``uts/realtime/unit/helpers/mock_websocket.md`` in the ably/specification +repository: ``enable_fake_timers()`` followed by ``ADVANCE_TIME(ms)``. Here a +test builds a `FakeClock`, hands `clock.timer` to the client as +`TestOptions(timer=clock.timer)`, and calls `await clock.advance(ms)`. + +Prefer a real short interval where a client option or `connectionDetails` +reaches the behaviour under test; see the fake-time section of +[deviations.md](../deviations.md). +""" + +import asyncio +import inspect + +# How many times `advance` yields to the event loop after firing a timer, so +# that tasks the callback started run to completion before the next timer fires +# or `advance` returns. +SETTLE_PASSES = 20 + +# A ceiling on how many timers one `advance` fires, so that a callback which +# reschedules itself with no delay fails the test instead of hanging it. +MAX_TIMERS_PER_ADVANCE = 1000 + + +async def settle(passes=SETTLE_PASSES): + """Yields to the event loop until the tasks already queued have run. + + This is the `process_pending_events()` convention of ``uts/README.md``, + with no notional or real time passing. One yield is rarely enough on the + realtime paths, which chain `create_task` several levels deep. + """ + for _ in range(passes): + await asyncio.sleep(0) + + +class FakeTimer: + """A scheduled callback which fires when the clock reaches its due time.""" + + def __init__(self, clock, due, callback): + self.due = due + self.callback = callback + self.cancelled = False + self.fired = False + self.__clock = clock + + def cancel(self): + self.cancelled = True + self.__clock._discard(self) + + def __repr__(self): + return f'FakeTimer(due={self.due}, callback={self.callback!r})' + + +class FakeClock: + """Records timers against a notional time which only `advance` moves. + + `clock.timer` has the signature of `ably.util.helper.Timer`, so it can be + passed straight to `TestOptions(timer=...)`. Nothing fires until a test + asks for it. + """ + + def __init__(self, settle_passes=SETTLE_PASSES): + self.now = 0.0 + self.fired = [] + self.__pending = [] + self.__settle_passes = settle_passes + + def timer(self, timeout, callback): + """Schedules `callback` for `timeout` milliseconds from the clock's now.""" + timer = FakeTimer(self, self.now + timeout, callback) + self.__pending.append(timer) + return timer + + @property + def pending(self): + """The timers still waiting to fire, in the order they were scheduled.""" + return list(self.__pending) + + def _discard(self, timer): + if timer in self.__pending: + self.__pending.remove(timer) + + async def advance(self, ms): + """Moves the clock forward by `ms`, firing every timer that falls due. + + Timers fire in due order, earliest first, and the clock reads each + timer's due time while its callback runs, so a timer scheduled from + within a callback is due relative to the moment it was scheduled. The + due set is recomputed after every callback rather than taken once at + the start, so a timer scheduled into the remainder of the window fires + within the same `advance` — the window runs to a fixed point. + + Between callbacks, and once more before returning, the event loop is + given a chance to settle, so the work a callback started has landed by + the time `advance` returns or the next timer fires. + """ + target = self.now + ms + for _ in range(MAX_TIMERS_PER_ADVANCE): + due = self.__next_due(target) + if due is None: + break + self.now = due.due + self.__pending.remove(due) + due.fired = True + self.fired.append(due) + await self.__invoke(due.callback) + await self.settle() + else: + raise RuntimeError( + f'advance({ms}) fired {MAX_TIMERS_PER_ADVANCE} timers without emptying the ' + 'window; a callback is most likely rescheduling itself with no delay' + ) + self.now = target + await self.settle() + + async def settle(self): + """Yields to the event loop until the tasks already queued have run.""" + await settle(self.__settle_passes) + + def __next_due(self, target): + candidates = [t for t in self.__pending if not t.cancelled and t.due <= target] + if not candidates: + return None + # min() is stable, so timers sharing a due time fire in schedule order + return min(candidates, key=lambda t: t.due) + + @staticmethod + async def __invoke(callback): + if asyncio.iscoroutinefunction(callback): + await callback() + else: + result = callback() + if inspect.isawaitable(result): + await result + + +async def advance_to_connection_state(client, clock, state, step, limit=60): + """Advances `clock` in `step` increments until the connection reaches `state`. + + This is the specifications' `LOOP up to N: ADVANCE_TIME(x)`, for a state + several timers away — reaching SUSPENDED means turning the whole retry + cycle. Raises if `limit` steps pass without arriving. + """ + for _ in range(limit): + if client.connection.state == state: + return + await clock.advance(step) + raise AssertionError( + f'Connection did not reach {state} within {limit} advances of {step} ms; ' + f'it was {client.connection.state}') diff --git a/test/uts/helpers/clock_test.py b/test/uts/helpers/clock_test.py new file mode 100644 index 00000000..3fab256c --- /dev/null +++ b/test/uts/helpers/clock_test.py @@ -0,0 +1,300 @@ +"""Tests for the `FakeClock` helper.""" + +import asyncio + +import pytest + +from test.uts.helpers.clock import FakeClock + + +def recorder(): + """A list, and a callable which appends a label to it.""" + fired = [] + + def record(label): + return lambda: fired.append(label) + + return fired, record + + +async def test_advance_fires_a_timer_that_falls_due(): + clock = FakeClock() + fired, record = recorder() + clock.timer(1000, record('a')) + + await clock.advance(1000) + + assert fired == ['a'] + + +async def test_advance_does_not_fire_a_timer_early(): + clock = FakeClock() + fired, record = recorder() + clock.timer(1000, record('a')) + + await clock.advance(999) + assert fired == [] + + await clock.advance(1) + assert fired == ['a'] + + +async def test_advance_fires_in_due_order_not_schedule_order(): + clock = FakeClock() + fired, record = recorder() + clock.timer(3000, record('third')) + clock.timer(1000, record('first')) + clock.timer(2000, record('second')) + + await clock.advance(5000) + + assert fired == ['first', 'second', 'third'] + + +async def test_timers_sharing_a_due_time_fire_in_schedule_order(): + clock = FakeClock() + fired, record = recorder() + clock.timer(1000, record('a')) + clock.timer(1000, record('b')) + clock.timer(1000, record('c')) + + await clock.advance(1000) + + assert fired == ['a', 'b', 'c'] + + +async def test_a_timer_fires_only_once(): + clock = FakeClock() + fired, record = recorder() + clock.timer(1000, record('a')) + + await clock.advance(1000) + await clock.advance(1000) + + assert fired == ['a'] + + +async def test_now_advances_by_the_full_window(): + clock = FakeClock() + clock.timer(1000, lambda: None) + + await clock.advance(2500) + + assert clock.now == 2500 + + +async def test_a_callback_sees_the_clock_at_its_own_due_time(): + clock = FakeClock() + observed = [] + clock.timer(1000, lambda: observed.append(clock.now)) + clock.timer(2000, lambda: observed.append(clock.now)) + + await clock.advance(5000) + + assert observed == [1000, 2000] + + +async def test_cancel_stops_a_timer_firing(): + clock = FakeClock() + fired, record = recorder() + timer = clock.timer(1000, record('a')) + + timer.cancel() + await clock.advance(5000) + + assert fired == [] + assert clock.pending == [] + + +async def test_cancel_leaves_the_other_timers_alone(): + clock = FakeClock() + fired, record = recorder() + clock.timer(1000, record('a')) + clock.timer(2000, record('b')).cancel() + clock.timer(3000, record('c')) + + await clock.advance(5000) + + assert fired == ['a', 'c'] + + +async def test_cancelling_twice_is_harmless(): + clock = FakeClock() + fired, record = recorder() + timer = clock.timer(1000, record('a')) + + timer.cancel() + timer.cancel() + await clock.advance(5000) + + assert fired == [] + + +async def test_a_callback_can_schedule_a_timer_due_inside_the_same_window(): + clock = FakeClock() + fired, record = recorder() + + def schedule_next(): + fired.append('first') + clock.timer(1000, record('second')) + + clock.timer(1000, schedule_next) + + await clock.advance(5000) + + assert fired == ['first', 'second'] + + +async def test_a_callback_can_schedule_a_timer_due_beyond_the_window(): + clock = FakeClock() + fired, record = recorder() + + def schedule_next(): + fired.append('first') + clock.timer(1000, record('second')) + + clock.timer(1000, schedule_next) + + await clock.advance(1000) + assert fired == ['first'] + + await clock.advance(1000) + assert fired == ['first', 'second'] + + +async def test_a_callback_can_cancel_a_timer_that_has_not_fired(): + clock = FakeClock() + fired, record = recorder() + victim = clock.timer(2000, record('victim')) + + def cancel_victim(): + fired.append('first') + victim.cancel() + + clock.timer(1000, cancel_victim) + + await clock.advance(5000) + + assert fired == ['first'] + + +async def test_a_callback_can_cancel_a_timer_due_at_the_same_moment(): + clock = FakeClock() + fired, record = recorder() + + def cancel_victim(): + fired.append('first') + victim.cancel() + + clock.timer(1000, cancel_victim) + victim = clock.timer(1000, record('victim')) + + await clock.advance(1000) + + assert fired == ['first'] + + +async def test_an_async_callback_is_awaited(): + clock = FakeClock() + fired = [] + + async def callback(): + await asyncio.sleep(0) + fired.append('a') + + clock.timer(1000, callback) + + await clock.advance(1000) + + assert fired == ['a'] + + +async def test_an_async_callback_completes_before_the_next_timer_fires(): + clock = FakeClock() + fired = [] + + async def slow(): + for _ in range(5): + await asyncio.sleep(0) + fired.append('slow') + + clock.timer(1000, slow) + clock.timer(2000, lambda: fired.append('later')) + + await clock.advance(5000) + + assert fired == ['slow', 'later'] + + +async def test_a_callback_returning_a_coroutine_is_awaited(): + clock = FakeClock() + fired = [] + + async def work(): + fired.append('a') + + clock.timer(1000, lambda: work()) + + await clock.advance(1000) + + assert fired == ['a'] + + +async def test_a_task_a_callback_starts_has_run_by_the_time_advance_returns(): + clock = FakeClock() + fired = [] + + async def background(): + await asyncio.sleep(0) + fired.append('background') + + clock.timer(1000, lambda: asyncio.ensure_future(background())) + + await clock.advance(1000) + + assert fired == ['background'] + + +async def test_pending_lists_the_timers_still_waiting(): + clock = FakeClock() + early = clock.timer(1000, lambda: None) + late = clock.timer(9000, lambda: None) + + assert clock.pending == [early, late] + + await clock.advance(1000) + + assert clock.pending == [late] + + +async def test_fired_records_the_timers_that_have_gone_off(): + clock = FakeClock() + timer = clock.timer(1000, lambda: None) + + await clock.advance(1000) + + assert clock.fired == [timer] + assert timer.fired + + +async def test_a_callback_that_reschedules_with_no_delay_raises_rather_than_hanging(): + clock = FakeClock() + + def reschedule(): + clock.timer(0, reschedule) + + clock.timer(0, reschedule) + + with pytest.raises(RuntimeError, match='rescheduling itself'): + await clock.advance(1) + + +async def test_settle_passes_no_time(): + clock = FakeClock() + fired, record = recorder() + clock.timer(0, record('a')) + + await clock.settle() + + assert fired == [] + assert clock.now == 0 diff --git a/test/uts/helpers/mock_http.py b/test/uts/helpers/mock_http.py index 3e7e5611..66701ac3 100644 --- a/test/uts/helpers/mock_http.py +++ b/test/uts/helpers/mock_http.py @@ -17,7 +17,7 @@ import httpx import msgpack -DEFAULT_PORTS = {'https': 443, 'http': 80} +DEFAULT_PORTS = {'https': 443, 'http': 80, 'wss': 443, 'ws': 80} MSGPACK_CONTENT_TYPE = 'application/x-msgpack' JSON_CONTENT_TYPE = 'application/json' diff --git a/test/uts/helpers/mock_websocket.py b/test/uts/helpers/mock_websocket.py new file mode 100644 index 00000000..5612f158 --- /dev/null +++ b/test/uts/helpers/mock_websocket.py @@ -0,0 +1,712 @@ +"""A stand-in for the websocket layer, implementing the ``mock_websocket`` +helper that the Universal Test Specifications are written against. + +The contract lives in ``uts/realtime/unit/helpers/mock_websocket.md`` in the +ably/specification repository. Names here match the pseudocode, which reserves +snake_case for test-harness constructs. + +A test builds a `MockWebSocket`, hands it to `realtime_client()`, and drives +the client either from handlers set on the mock or from the `await_*` family, +which return an awaitable registered at call time so the next event can be +waited for before the current one is answered. + +Every connection attempt surfaces as a `PendingConnection` the test responds +to. A successful response yields a `MockConnection`, which is the object the +library holds as its websocket: frames the test injects reach the client's read +loop, and frames the client sends reach the test. +""" + +import asyncio +import copy +import json +import socket +import time +from enum import Enum + +import httpx +import msgpack +from websockets.exceptions import WebSocketException + +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.mock_http import RecordedUrl + +MSGPACK_PROTOCOL = 'application/x-msgpack' +JSON_PROTOCOL = 'application/json' + +# How long an `await_*` call waits before failing the test. The specifications +# quote timeouts in seconds; so does `mock_http`. +DEFAULT_AWAIT_TIMEOUT = 5.0 + +# PING and PONG are protocol.md actions 22 and 23. `ProtocolMessageAction` stops +# at ANNOTATION, so the values are named here. +PING_ACTION = 22 +PONG_ACTION = 23 + +# The close code the library's own close path produces, which `websockets` +# defaults to and the specifications quote as "1000 for normal closure". +NORMAL_CLOSURE = 1000 + + +class MockEventType(Enum): + """The kinds of event the unified timeline records.""" + + CONNECTION_ATTEMPT = 'connection_attempt' + CONNECTION_SUCCESS = 'connection_success' + CONNECTION_FAILURE = 'connection_failure' + MESSAGE_FROM_CLIENT = 'message_from_client' + MESSAGE_TO_CLIENT = 'message_to_client' + PING_FRAME = 'ping_frame' + SERVER_DISCONNECT = 'server_disconnect' + CLIENT_CLOSE = 'client_close' + + +class MockEvent: + """One entry in the timeline, carrying whatever the event is about.""" + + def __init__(self, type, data=None): + self.type = type + self.timestamp = time.time() + self.data = data + + def __repr__(self): + return f'MockEvent({self.type.name}, {self.data!r})' + + +class ClientCloseEvent: + """The close the library asked the websocket for.""" + + def __init__(self, code=None, reason=None): + self.code = code + self.reason = reason + + def __repr__(self): + return f'ClientCloseEvent(code={self.code!r}, reason={self.reason!r})' + + +class MockWebSocketClosed(WebSocketException): + """An abnormal close of the transport, as `websockets` would report one. + + Subclassing `WebSocketException` is what puts it on the library's transport + failure path; anything else escapes `ws_read_loop` uncaught. + """ + + def __init__(self, message, code=None, reason=None, error=None): + super().__init__(message) + self.code = code + self.reason = reason + self.error = error + + +class _ServerClose: + """The sentinel a server-side close puts on a connection's inbox.""" + + def __init__(self, failure=None): + self.failure = failure + + +class MockConnection: + """An established connection, from both sides. + + The test's side injects frames with `send_to_client` and ends the + connection with `send_to_client_and_close` or `simulate_disconnect`. The + library's side is `__aiter__`, `send` and `close` — the whole surface + `WebSocketTransport` uses. + + Frames the test injects are queued and only drained once the library starts + its read loop, so a message injected from a connection handler arrives + after the library has stored the connection. + """ + + def __init__(self, mock, protocol): + self.protocol = protocol + self.timestamp = time.time() + self.closed = False + self.close_event = None + self.__mock = mock + self.__inbox = asyncio.Queue() + + # Server side: what the test drives + + def send_to_client(self, message): + """Delivers `message` to the client, leaving the connection open.""" + self.__mock._record(MockEventType.MESSAGE_TO_CLIENT, message) + self.__inbox.put_nowait(self.__encode(message)) + + def send_to_client_and_close(self, message): + """Delivers `message` and then closes the connection, as the server + does whenever it sends DISCONNECTED or a connection-level ERROR.""" + self.send_to_client(message) + self.__server_close(None) + + def simulate_disconnect(self, error=None): + """Ends the connection without a protocol message. + + With no `error` the transport closes normally, which the library reads + as the server going away. With one, the read loop fails, and `error` + becomes the reason on the resulting state change. + """ + failure = None + if error is not None: + failure = MockWebSocketClosed(f'Connection closed: {error}', error=error) + self.__server_close(failure) + + def send_ping_frame(self): + """Records a websocket ping frame from the server. + + `WebSocketTransport` has no ping hook, so nothing observable follows; + see the mock infrastructure limitation in + [deviations.md](../deviations.md). + """ + self.__mock._record(MockEventType.PING_FRAME, self) + + # Library side: what `WebSocketTransport` calls + + async def __aiter__(self): + while True: + frame = await self.__inbox.get() + if isinstance(frame, _ServerClose): + if frame.failure is not None: + raise frame.failure + return + yield frame + + async def send(self, raw): + """Receives a frame the client sent. + + A frame sent after the connection closed is still recorded rather than + raising, so that a test can assert on the CLOSE message the library + sends while tearing the connection down. + """ + self.__mock._on_frame_from_client(self, raw) + + async def close(self, code=NORMAL_CLOSURE, reason=None): + """Closes the connection at the library's request. + + The close notification reaches the read loop on a later turn of the + event loop, never inline, matching a real websocket where iteration + ends from the stream rather than from the `close()` call. + """ + if self.closed: + return + self.closed = True + self.close_event = ClientCloseEvent(code, reason) + self.__mock._on_client_close(self, self.close_event) + self.__inbox.put_nowait(_ServerClose(None)) + + # Internals + + def _decode(self, raw): + if self.protocol == MSGPACK_PROTOCOL: + return msgpack.unpackb(raw, raw=False) + return json.loads(raw) + + def __encode(self, message): + # A message given already encoded is passed through as the test wrote + # it, so a specification can pin the wire format itself + if isinstance(message, (bytes, str)): + return message + message = copy.deepcopy(message) + if self.protocol == MSGPACK_PROTOCOL: + return msgpack.packb(message, use_bin_type=True) + return json.dumps(message) + + def __server_close(self, failure): + if self.closed: + return + self.closed = True + self.__mock._record(MockEventType.SERVER_DISCONNECT, failure) + self.__inbox.put_nowait(_ServerClose(failure)) + + def __repr__(self): + return f'MockConnection(protocol={self.protocol!r}, closed={self.closed})' + + +class PendingConnection: + """A connection attempt awaiting an outcome from the test. + + The server-side methods of `MockConnection` are also reachable here, so a + handler can call `respond_with_success()` and then `send_to_client(...)` on + the same object, as the specifications write it. + """ + + def __init__(self, mock, url, headers=None): + self.url = RecordedUrl(httpx.URL(url)) + self.protocol = (MSGPACK_PROTOCOL if self.url.query_params.get('format') == 'msgpack' + else JSON_PROTOCOL) + # httpx.Headers looks up case-insensitively, as the specifications expect + self.headers = httpx.Headers(headers or {}) + self.timestamp = time.time() + self.connection = MockConnection(mock, self.protocol) + self.__mock = mock + self.__outcome = asyncio.get_running_loop().create_future() + + def respond_with_success(self, connected_message=None): + """Establishes the connection, then delivers `connected_message`. + + The connection completes first and the message is queued behind it, so + the library has stored the connection before the message is processed. + """ + if self.__settle(None) and connected_message is not None: + self.connection.send_to_client(connected_message) + + def respond_with_refused(self): + """Fails the attempt the way a refused TCP connection does. + + `websockets` lets `ConnectionRefusedError` through unwrapped, so this + is what the library sees. + """ + self.__settle(ConnectionRefusedError( + f'Connection refused to {self.url.host}:{self.url.port}')) + + def respond_with_timeout(self): + """Fails the attempt the way an unresponsive server does.""" + self.__settle(asyncio.TimeoutError( + f'Connection to {self.url.host}:{self.url.port} timed out')) + + def respond_with_dns_error(self): + """Fails the attempt the way an unresolvable host does.""" + self.__settle(socket.gaierror( + socket.EAI_NONAME, f'Name resolution failed for {self.url.host}')) + + def respond_with_error(self, error_message, then_close=True): + """Establishes the connection and has the server send an ERROR. + + `then_close` closes the transport behind the message, which is what the + server does for a connection-level error. + """ + if not self.__settle(None): + return + if then_close: + self.connection.send_to_client_and_close(error_message) + else: + self.connection.send_to_client(error_message) + + # The server side of the connection this attempt produces + + def send_to_client(self, message): + self.connection.send_to_client(message) + + def send_to_client_and_close(self, message): + self.connection.send_to_client_and_close(message) + + def simulate_disconnect(self, error=None): + self.connection.simulate_disconnect(error) + + def send_ping_frame(self): + self.connection.send_ping_frame() + + def _outcome(self): + return self.__outcome + + def _fail_with(self, exception): + self.__settle(exception) + + def __settle(self, failure): + if self.__outcome.done(): + return False + self.__mock._record( + MockEventType.CONNECTION_FAILURE if failure is not None + else MockEventType.CONNECTION_SUCCESS, failure if failure is not None else self) + self.__outcome.set_result(failure) + return True + + def __repr__(self): + return f'PendingConnection({str(self.url)!r})' + + +class MockWebSocket: + """Serves the websocket connections a realtime client opens, in place of + the network. + + Events reach the test one of two ways, in order of precedence: a waiter + registered by an `await_*` call, or the matching handler. A connection + attempt that neither covers succeeds with no messages, leaving the client + connecting against a silent server. + """ + + def __init__(self, on_connection_attempt=None, on_message_from_client=None, + on_text_data_frame=None, on_binary_data_frame=None): + # Handlers are reassignable, as some specifications set them per-phase + self.on_connection_attempt = on_connection_attempt + self.on_message_from_client = on_message_from_client + self.on_text_data_frame = on_text_data_frame + self.on_binary_data_frame = on_binary_data_frame + self.events = [] + # Exceptions a test's handler raised, kept here rather than allowed to + # escape into the library, where a `TypeError` would silently make + # `ws_connect` retry with different keyword arguments + self.handler_errors = [] + self.connections = [] + self.active_connection = None + self.__connection_waiters = [] + self.__message_waiters = [] + self.__close_waiters = [] + + def as_connect(self): + """The callable to pass as `TestOptions(websocket_connect=...)`.""" + # `**kwargs` rather than a named `additional_headers`, because + # `ws_connect` treats a `TypeError` from the call as a signal to retry + # with `extra_headers` instead + def connect(url, **kwargs): + headers = kwargs.get('additional_headers') or kwargs.get('extra_headers') + return _MockConnect(self, url, headers) + return connect + + # The unified timeline, and the views onto it the specifications assert on + + @property + def connection_attempts(self): + """Every attempt the client made, in order.""" + return [event.data for event in self.events + if event.type is MockEventType.CONNECTION_ATTEMPT] + + @property + def messages_from_client(self): + """Every decoded protocol message the client sent, in order.""" + return [event.data for event in self.events + if event.type is MockEventType.MESSAGE_FROM_CLIENT] + + def events_of_type(self, type): + return [event for event in self.events if event.type is type] + + # Message injection, against the connection most recently established + + def send_to_client(self, message): + self.__require_connection().send_to_client(message) + + def send_to_client_and_close(self, message): + self.__require_connection().send_to_client_and_close(message) + + def simulate_disconnect(self, error=None): + self.__require_connection().simulate_disconnect(error) + + def send_ping_frame(self): + self.__require_connection().send_ping_frame() + + # Awaitable event triggers. Each registers its waiter when called, so a + # test can set up the next await before answering the current event. + + def await_connection_attempt(self, timeout=DEFAULT_AWAIT_TIMEOUT): + return self.__await_event( + self.__connection_waiters, timeout, 'Timeout waiting for connection attempt') + + def await_next_message_from_client(self, timeout=DEFAULT_AWAIT_TIMEOUT): + return self.__await_event( + self.__message_waiters, timeout, 'Timeout waiting for message from client') + + def await_client_close(self, timeout=DEFAULT_AWAIT_TIMEOUT): + return self.__await_event( + self.__close_waiters, timeout, 'Timeout waiting for client close') + + def reset(self): + """Clears the timeline, the waiters and the recorded connections. + + A connection already established stays open; a test that wants it shut + closes it first. + """ + self.events.clear() + self.handler_errors.clear() + self.connections.clear() + self.active_connection = None + self.__connection_waiters.clear() + self.__message_waiters.clear() + self.__close_waiters.clear() + + # Internals + + def _record(self, type, data=None): + event = MockEvent(type, data) + self.events.append(event) + return event + + def _on_connection_attempt(self, pending): + self._record(MockEventType.CONNECTION_ATTEMPT, pending) + waiter = self.__take_waiter(self.__connection_waiters) + if waiter is not None: + waiter.set_result(pending) + elif self.on_connection_attempt is not None: + if not self.__invoke(self.on_connection_attempt, pending): + # A handler that raised has not answered the attempt. Failing + # it here as a transport error reaches the library's failure + # path at once, rather than leaving the test to hang out. + pending._fail_with(MockWebSocketClosed( + f'mock_websocket handler raised {self.handler_errors[-1]!r}')) + else: + pending.respond_with_success() + + def _on_established(self, connection): + self.connections.append(connection) + self.active_connection = connection + + def _on_frame_from_client(self, connection, raw): + message = connection._decode(raw) + self._record(MockEventType.MESSAGE_FROM_CLIENT, message) + # The raw frame hooks run in addition to the decoded handler, and + # before it, as they see the frame before it is decoded + if isinstance(raw, (bytes, bytearray)): + if self.on_binary_data_frame is not None: + self.__invoke(self.on_binary_data_frame, bytes(raw)) + elif self.on_text_data_frame is not None: + self.__invoke(self.on_text_data_frame, raw) + waiter = self.__take_waiter(self.__message_waiters) + if waiter is not None: + waiter.set_result(message) + if self.on_message_from_client is not None: + self.__invoke(self.on_message_from_client, message) + + def _on_client_close(self, connection, close_event): + self._record(MockEventType.CLIENT_CLOSE, close_event) + if self.active_connection is connection: + self.active_connection = None + waiter = self.__take_waiter(self.__close_waiters) + if waiter is not None: + waiter.set_result(close_event) + + def __require_connection(self): + if self.active_connection is None: + raise AssertionError('No connection has been established') + return self.active_connection + + def __invoke(self, handler, argument): + """Calls a test's handler, keeping whatever it raises out of the + library's path. Returns whether it ran cleanly.""" + try: + handler(argument) + except Exception as error: + self.handler_errors.append(error) + return False + return True + + def __await_event(self, waiters, timeout, message): + waiter = asyncio.get_running_loop().create_future() + waiters.append(waiter) + + async def wait(): + try: + return await asyncio.wait_for(waiter, timeout) + except asyncio.TimeoutError: + raise AssertionError(self.__timeout_message(message)) from None + + return wait() + + def __timeout_message(self, message): + if self.handler_errors: + return f'{message}; a handler raised {self.handler_errors[0]!r}' + return message + + @staticmethod + def __take_waiter(waiters): + while waiters: + waiter = waiters.pop(0) + if not waiter.done(): + return waiter + return None + + +class _MockConnect: + """The async context manager `websockets.connect` stands in for.""" + + def __init__(self, mock, url, headers=None): + self.__mock = mock + self.__url = url + self.__headers = headers + self.__connection = None + + async def __aenter__(self): + pending = PendingConnection(self.__mock, self.__url, self.__headers) + self.__mock._on_connection_attempt(pending) + failure = await pending._outcome() + if failure is not None: + raise failure + self.__connection = pending.connection + self.__mock._on_established(self.__connection) + return self.__connection + + async def __aexit__(self, exc_type, exc, traceback): + return False + + +# Protocol message templates, as named in the specification + +CONNECTED_MESSAGE = { + 'action': int(ProtocolMessageAction.CONNECTED), + 'connectionId': 'test-connection-id', + 'connectionDetails': { + 'connectionKey': 'test-connection-key', + 'clientId': None, + 'connectionStateTtl': 120000, + 'maxIdleInterval': 15000, + }, +} + +CLOSED_MESSAGE = { + 'action': int(ProtocolMessageAction.CLOSED), +} + +# The specification's template carries no statusCode; `on_disconnected` +# compares it against 500 unguarded, so one is supplied here. See +# [deviations.md](../deviations.md). +DISCONNECTED_MESSAGE = { + 'action': int(ProtocolMessageAction.DISCONNECTED), + 'error': {'code': 80003, 'statusCode': 400, 'message': 'Connection disconnected'}, +} + +HEARTBEAT_MESSAGE = { + 'action': int(ProtocolMessageAction.HEARTBEAT), +} + + +def connected_message(connection_id='test-connection-id', **connection_details): + """A CONNECTED message, with `connection_details` overriding the template's. + + The templates are shared dictionaries, so a test that needs its own + `connectionDetails` — a small `maxIdleInterval`, say — builds one here + rather than mutating `CONNECTED_MESSAGE`. + """ + message = copy.deepcopy(CONNECTED_MESSAGE) + message['connectionId'] = connection_id + message['connectionDetails'].update(connection_details) + return message + + +def ERROR_MESSAGE(code, message, status_code=None): # noqa: N802 - the specification's name + """An ERROR protocol message carrying `code`. + + The specification derives the status code as `code / 100`, which holds for + the 4xxxx and 5xxxx ranges. The 8xxxx connection errors would yield 800, so + they fall back to 500 unless `status_code` names one. + """ + if status_code is None: + derived = code // 100 + status_code = derived if derived < 600 else 500 + return { + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def PING_MESSAGE(id): # noqa: N802 - the specification's name + return {'action': PING_ACTION, 'id': id} + + +CONNECTED_MESSAGE_NO_IDLE = connected_message(maxIdleInterval=0) +"""A CONNECTED message which leaves the transport's idle timer unscheduled. + +The transport only sets the timer for a non-zero `maxIdleInterval`, so this is +what a test driving time with a `FakeClock` connects with: the idle timer +compares against the real clock and would otherwise fire on every advance. +""" + + +def attached_message(channel, **fields): + """An ATTACHED message for `channel`.""" + return {'action': int(ProtocolMessageAction.ATTACHED), 'channel': channel, **fields} + + +def detached_message(channel, **fields): + """A DETACHED message for `channel`.""" + return {'action': int(ProtocolMessageAction.DETACHED), 'channel': channel, **fields} + + +def server_detached_message(channel, code, message, status_code=None): + """A DETACHED message carrying the error a server sends when it detaches a channel.""" + if status_code is None: + derived = code // 100 + status_code = derived if derived < 600 else 500 + return detached_message(channel, error={'code': code, 'statusCode': status_code, 'message': message}) + + +def contains_in_order(observed, expected): + """Whether `expected` appears in `observed` in order, other entries allowed between. + + This is the specifications' `CONTAINS_IN_ORDER`, which they prefer to an + equality check because a transient state may be passed through more than once. + """ + remaining = list(expected) + for item in observed: + if remaining and item == remaining[0]: + remaining.pop(0) + return not remaining + + +async def await_protocol_messages(mock_websocket, action, count=1, timeout=5.0): + """Waits until `count` protocol messages carrying `action` have left the client. + + An operation the server acknowledges is awaited on the client side, so a + test driving one has nothing to wait on until it answers. This waits for the + message to reach the mock so that the answer can be sent. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + wanted = int(action) + while True: + sent = [m for m in mock_websocket.messages_from_client if m.get('action') == wanted] + if len(sent) >= count: + return sent + if loop.time() >= deadline: + raise AssertionError( + f'Timed out waiting for {count} messages with action {wanted}; {len(sent)} were sent') + await asyncio.sleep(0) + + +async def await_published(mock_websocket, count=1, timeout=5.0): + """Waits until `count` MESSAGE protocol messages have left the client.""" + return await await_protocol_messages(mock_websocket, ProtocolMessageAction.MESSAGE, count, timeout) + + +async def await_presence_sent(mock_websocket, count=1, timeout=5.0): + """Waits until `count` PRESENCE protocol messages have left the client.""" + return await await_protocol_messages(mock_websocket, ProtocolMessageAction.PRESENCE, count, timeout) + + +def message_protocol_message(channel, messages, **fields): + """A MESSAGE protocol message carrying `messages` on `channel`.""" + return { + 'action': int(ProtocolMessageAction.MESSAGE), + 'channel': channel, + 'messages': messages, + **fields, + } + + +def channel_error_message(channel, code, message, status_code=None): + """An ERROR message scoped to `channel`, which the connection routes to it.""" + if status_code is None: + derived = code // 100 + status_code = derived if derived < 600 else 500 + return { + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def annotation_protocol_message(channel, annotations, **fields): + """An ANNOTATION protocol message carrying `annotations` on `channel`.""" + return { + 'action': int(ProtocolMessageAction.ANNOTATION), + 'channel': channel, + 'annotations': annotations, + **fields, + } + + +def ack(message, serials=None, count=1): + """An ACK answering `message`, which a client awaits before its publish returns.""" + acknowledgement = {'action': int(ProtocolMessageAction.ACK), 'msgSerial': message['msgSerial'], 'count': count} + if serials is not None: + acknowledgement['res'] = [{'serials': serials}] + return acknowledgement + + +def nack(message, code, description, status_code=None, count=1): + """A NACK rejecting `message`, which surfaces as an AblyException carrying `code`.""" + if status_code is None: + derived = code // 100 + status_code = derived if derived < 600 else 500 + return { + 'action': int(ProtocolMessageAction.NACK), + 'msgSerial': message['msgSerial'], + 'count': count, + 'error': {'code': code, 'statusCode': status_code, 'message': description}, + } diff --git a/test/uts/helpers/mock_websocket_test.py b/test/uts/helpers/mock_websocket_test.py new file mode 100644 index 00000000..dc2866f2 --- /dev/null +++ b/test/uts/helpers/mock_websocket_test.py @@ -0,0 +1,796 @@ +"""Tests for the `mock_websocket` helper, driven through the realtime client it +serves. + +Where a test proves something about the mock's own contract rather than about +the client's behaviour, it drives `as_connect()` directly, standing in for +`WebSocketTransport` and using only the surface the transport uses. +""" + +import asyncio +import json + +import msgpack +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import ( + CLOSED_MESSAGE, + CONNECTED_MESSAGE, + DISCONNECTED_MESSAGE, + ERROR_MESSAGE, + HEARTBEAT_MESSAGE, + JSON_PROTOCOL, + MSGPACK_PROTOCOL, + PING_MESSAGE, + MockEventType, + MockWebSocket, + connected_message, +) + +CONNECTED = int(ProtocolMessageAction.CONNECTED) +CLOSE = int(ProtocolMessageAction.CLOSE) + +# Short enough that a test which waits out a connect timeout stays quick, and +# long enough that it never pre-empts a response the test means to give. +SHORT_REQUEST_TIMEOUT = 300 + +# Long enough that no test reconnects behind its own assertions. +NO_RETRY = 60000 + + +def succeeding(message=CONNECTED_MESSAGE): + """A connection handler which accepts every attempt and sends `message`.""" + return lambda connection: connection.respond_with_success(message) + + +async def wait_for(predicate, timeout=3.0): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + assert loop.time() < deadline, 'Timed out waiting for the client' + await asyncio.sleep(0.005) + + +async def connected(mock, **kwargs): + """A client which has reached CONNECTED through `mock`.""" + kwargs.setdefault('disconnected_retry_timeout', NO_RETRY) + client = realtime_client(mock, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +def event_types(mock): + return [event.type for event in mock.events] + + +# Connection outcomes + +async def test_respond_with_success_connects_the_client(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + + client = await connected(mock) + + assert client.connection.connection_manager.connection_id == 'test-connection-id' + assert client.connection.connection_details.connection_key == 'test-connection-key' + + +async def test_respond_with_success_alone_leaves_the_client_connecting(): + # A connection that opens and then says nothing is a silent server, which + # the client waits out rather than treating as a failure + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_success()) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + client.connect() + await wait_for(lambda: len(mock.connections) == 1) + + assert client.connection.state == ConnectionState.CONNECTING + + +async def test_an_unanswered_attempt_succeeds_with_no_messages(): + mock = MockWebSocket() + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + client.connect() + await wait_for(lambda: len(mock.connections) == 1) + + assert event_types(mock) == [MockEventType.CONNECTION_ATTEMPT, MockEventType.CONNECTION_SUCCESS] + assert client.connection.state == ConnectionState.CONNECTING + + +async def test_respond_with_refused_is_indistinguishable_from_a_silent_server(): + # `ws_connect` catches only WebSocketException and socket.gaierror, so the + # ConnectionRefusedError a refused TCP connection raises never reaches the + # transport's failure path; the connect timeout is what ends the attempt + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_refused()) + client = realtime_client(mock, realtime_request_timeout=SHORT_REQUEST_TIMEOUT, + disconnected_retry_timeout=NO_RETRY) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert event_types(mock) == [MockEventType.CONNECTION_ATTEMPT, MockEventType.CONNECTION_FAILURE] + assert client.connection.error_reason.code == 50003 + assert client.connection.error_reason.status_code == 504 + + +async def test_respond_with_timeout_reaches_the_same_generic_timeout(): + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_timeout()) + client = realtime_client(mock, realtime_request_timeout=SHORT_REQUEST_TIMEOUT, + disconnected_retry_timeout=NO_RETRY) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.error_reason.code == 50003 + + +async def test_respond_with_dns_error_fails_the_attempt_with_its_cause(): + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_dns_error()) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY, + disconnected_retry_timeout=NO_RETRY) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + reason = client.connection.error_reason + assert reason.code == 40000 + assert reason.status_code == 400 + assert 'Name resolution failed for main.realtime.ably.net' in str(reason) + + +async def test_respond_with_error_fails_the_connection(): + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_error( + ERROR_MESSAGE(40000, 'Bad request'))) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason.code == 40000 + assert MockEventType.SERVER_DISCONNECT in event_types(mock) + + +async def test_respond_with_error_can_leave_the_connection_open(): + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_error( + ERROR_MESSAGE(40000, 'Bad request'), then_close=False)) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert MockEventType.SERVER_DISCONNECT not in event_types(mock) + + +# The connection attempt as the test sees it + +async def test_a_pending_connection_carries_the_url_the_client_built(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + + await connected(mock) + + attempt = mock.connection_attempts[0] + assert attempt.url.scheme == 'wss' + assert attempt.url.host == 'main.realtime.ably.net' + assert attempt.url.port == 443 + assert attempt.url.query_params['key'] == 'app.key:secret' + assert attempt.url.query_params['format'] == 'msgpack' + assert attempt.timestamp > 0 + + +async def test_a_pending_connection_carries_the_headers_the_client_sent(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + + await connected(mock) + + headers = mock.connection_attempts[0].headers + assert 'ably-agent' in headers + assert 'ably-python' in headers['Ably-Agent'] + + +async def test_the_protocol_follows_use_binary_protocol(): + binary = MockWebSocket(on_connection_attempt=succeeding()) + text = MockWebSocket(on_connection_attempt=succeeding()) + + await connected(binary) + await connected(text, use_binary_protocol=False) + + assert binary.connection_attempts[0].protocol == MSGPACK_PROTOCOL + assert text.connection_attempts[0].protocol == JSON_PROTOCOL + assert 'format' not in text.connection_attempts[0].url.query_params + + +# The events timeline + +async def test_the_timeline_records_a_connection_and_a_client_close_in_order(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + await client.close() + + assert event_types(mock) == [ + MockEventType.CONNECTION_ATTEMPT, + MockEventType.CONNECTION_SUCCESS, + MockEventType.MESSAGE_TO_CLIENT, + MockEventType.MESSAGE_FROM_CLIENT, + MockEventType.CLIENT_CLOSE, + ] + assert mock.events[0].data is mock.connection_attempts[0] + assert mock.events[2].data == CONNECTED_MESSAGE + assert mock.events[3].data == {'action': CLOSE} + assert mock.events[4].data.code == 1000 + + +async def test_the_timeline_orders_a_full_disconnect_and_reconnect(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + states = [] + client.connection.on(lambda change: states.append(change.current)) + + mock.send_to_client_and_close(DISCONNECTED_MESSAGE) + await wait_for(lambda: len(mock.connections) == 2) + await await_connection_state(client, ConnectionState.CONNECTED) + + # RTN15a retries immediately after a drop from CONNECTED, so no time passes + assert states == [ + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ] + assert event_types(mock)[2:] == [ + MockEventType.MESSAGE_TO_CLIENT, + MockEventType.MESSAGE_TO_CLIENT, + MockEventType.SERVER_DISCONNECT, + MockEventType.CONNECTION_ATTEMPT, + MockEventType.CONNECTION_SUCCESS, + MockEventType.MESSAGE_TO_CLIENT, + ] + assert mock.connection_attempts[1].url.query_params['resume'] == 'test-connection-key' + + +async def test_messages_from_client_collects_what_the_client_sent(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + await client.close() + + assert mock.messages_from_client == [{'action': CLOSE}] + + +async def test_events_of_type_filters_the_timeline(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + await client.close() + + assert len(mock.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + assert mock.events_of_type(MockEventType.PING_FRAME) == [] + + +# Server-initiated closes + +async def test_simulate_disconnect_drops_the_transport(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock, disconnected_retry_timeout=NO_RETRY) + states = [] + client.connection.on(lambda change: states.append(change.current)) + + mock.simulate_disconnect() + await wait_for(lambda: len(mock.connections) == 2) + + # RTN15a retries immediately from CONNECTED, so DISCONNECTED is transient + # and has to be read off the recorded sequence + assert MockEventType.SERVER_DISCONNECT in event_types(mock) + assert states[0] == ConnectionState.DISCONNECTED + + +async def test_simulate_disconnect_carries_its_error_to_the_state_change(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + reasons = [] + client.connection.on(lambda change: reasons.append(change.reason)) + + mock.simulate_disconnect('the server went away') + await wait_for(lambda: reasons and reasons[0] is not None) + + assert 'the server went away' in str(reasons[0]) + + +async def test_send_to_client_and_close_delivers_the_message_before_the_close(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock, disconnected_retry_timeout=NO_RETRY) + + mock.send_to_client_and_close(ERROR_MESSAGE(40000, 'Bad request')) + await await_connection_state(client, ConnectionState.FAILED) + + assert event_types(mock)[-2:] == [ + MockEventType.MESSAGE_TO_CLIENT, + MockEventType.SERVER_DISCONNECT, + ] + assert client.connection.error_reason.code == 40000 + + +async def test_a_server_sent_closed_message_leaves_the_connection_state_alone(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock, disconnected_retry_timeout=NO_RETRY) + + mock.send_to_client_and_close(CLOSED_MESSAGE) + await wait_for(lambda: mock.connections[0].closed) + await asyncio.sleep(0.05) + + # `on_closed` disposes the transport without notifying a state, so CLOSED + # is only ever reached through the client's own close path + assert client.connection.state == ConnectionState.CONNECTED + + +async def test_a_closed_connection_ignores_a_second_server_close(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock, disconnected_retry_timeout=NO_RETRY) + connection = mock.active_connection + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + connection.simulate_disconnect() + connection.simulate_disconnect() + await wait_for(lambda: len(mock.connections) == 2) + + assert len(mock.events_of_type(MockEventType.SERVER_DISCONNECT)) == 1 + assert states[0] == ConnectionState.DISCONNECTED + + +# Client-initiated close + +async def test_await_client_close_returns_the_close_the_library_asked_for(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + waiting = mock.await_client_close(timeout=2) + await client.close() + close_event = await waiting + + assert close_event.code == 1000 + assert close_event.reason is None + assert client.connection.state == ConnectionState.CLOSED + + +async def test_a_client_close_clears_the_active_connection(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + await client.close() + + assert mock.active_connection is None + assert len(mock.connections) == 1 + + +# The await-based API + +async def test_await_connection_attempt_hands_the_attempt_to_the_test(): + mock = MockWebSocket() + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + waiting = mock.await_connection_attempt(timeout=2) + client.connect() + attempt = await waiting + attempt.respond_with_success(CONNECTED_MESSAGE) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert attempt.protocol == MSGPACK_PROTOCOL + + +async def test_a_waiting_test_takes_precedence_over_the_connection_handler(): + handled = [] + mock = MockWebSocket(on_connection_attempt=handled.append) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + waiting = mock.await_connection_attempt(timeout=2) + client.connect() + attempt = await waiting + attempt.respond_with_success(CONNECTED_MESSAGE) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert handled == [] + + +async def test_a_second_attempt_is_caught_by_awaiting_before_responding(): + mock = MockWebSocket() + client = realtime_client(mock, realtime_request_timeout=NO_RETRY, + disconnected_retry_timeout=50) + + first_attempt = mock.await_connection_attempt(timeout=2) + client.connect() + first = await first_attempt + # The retry follows the response immediately, so its waiter is registered + # before the response rather than after it + second_attempt = mock.await_connection_attempt(timeout=2) + first.respond_with_dns_error() + second = await second_attempt + second.respond_with_success(CONNECTED_MESSAGE) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(mock.connection_attempts) == 2 + + +async def test_await_next_message_from_client_returns_the_decoded_message(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + waiting = mock.await_next_message_from_client(timeout=2) + close = asyncio.ensure_future(client.close()) + message = await waiting + + assert message == {'action': CLOSE} + await close + + +async def test_an_await_that_is_never_answered_fails_the_test(): + mock = MockWebSocket() + + with pytest.raises(AssertionError, match='^Timeout waiting for connection attempt$'): + await mock.await_connection_attempt(timeout=0.05) + + +async def test_an_await_reports_a_handler_that_raised(): + def broken(connection): + raise TypeError('a handler bug') + + mock = MockWebSocket(on_connection_attempt=broken) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY, + disconnected_retry_timeout=NO_RETRY) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + # A TypeError escaping into `ws_connect` would silently make it retry with + # different keyword arguments, so the mock keeps it and fails the attempt + assert len(mock.connection_attempts) == 1 + assert isinstance(mock.handler_errors[0], TypeError) + with pytest.raises(AssertionError, match='a handler bug'): + await mock.await_next_message_from_client(timeout=0.05) + + +# The handler-based API + +async def test_the_connection_handler_can_branch_on_the_attempt_count(): + attempts = [] + + def connect(connection): + attempts.append(connection) + if len(attempts) == 1: + connection.respond_with_dns_error() + else: + connection.respond_with_success(CONNECTED_MESSAGE) + + mock = MockWebSocket(on_connection_attempt=connect) + client = realtime_client(mock, realtime_request_timeout=NO_RETRY, + disconnected_retry_timeout=50) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 2 + + +async def test_the_message_handler_receives_every_message_from_the_client(): + captured = [] + mock = MockWebSocket(on_connection_attempt=succeeding(), on_message_from_client=captured.append) + client = await connected(mock) + + await client.close() + + assert captured == [{'action': CLOSE}] + + +async def test_the_handlers_can_be_assigned_after_construction(): + mock = MockWebSocket() + client = realtime_client(mock, realtime_request_timeout=NO_RETRY) + + mock.on_connection_attempt = succeeding() + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(mock.connections) == 1 + + +# Raw data frame hooks + +async def test_the_binary_frame_hook_runs_alongside_the_decoded_handler(): + frames = [] + decoded = [] + mock = MockWebSocket(on_connection_attempt=succeeding(), + on_message_from_client=decoded.append, + on_binary_data_frame=frames.append) + client = await connected(mock) + + await client.close() + + assert decoded == [{'action': CLOSE}] + assert len(frames) == 1 + assert isinstance(frames[0], bytes) + assert msgpack.unpackb(frames[0], raw=False) == {'action': CLOSE} + + +async def test_the_text_frame_hook_runs_alongside_the_decoded_handler(): + frames = [] + decoded = [] + mock = MockWebSocket(on_connection_attempt=succeeding(), + on_message_from_client=decoded.append, + on_text_data_frame=frames.append) + client = await connected(mock, use_binary_protocol=False) + + await client.close() + + assert decoded == [{'action': CLOSE}] + assert len(frames) == 1 + assert isinstance(frames[0], str) + assert json.loads(frames[0]) == {'action': CLOSE} + + +async def test_the_text_hook_is_silent_under_the_binary_protocol(): + text = [] + binary = [] + mock = MockWebSocket(on_connection_attempt=succeeding(), + on_text_data_frame=text.append, + on_binary_data_frame=binary.append) + client = await connected(mock) + + await client.close() + + assert text == [] + assert len(binary) == 1 + + +# Encoding + +async def test_a_message_given_as_a_dict_is_encoded_for_the_clients_protocol(): + binary = MockWebSocket(on_connection_attempt=succeeding()) + text = MockWebSocket(on_connection_attempt=succeeding()) + + await connected(binary) + await connected(text, use_binary_protocol=False) + + assert binary.connections[0].protocol == MSGPACK_PROTOCOL + assert text.connections[0].protocol == JSON_PROTOCOL + + +async def test_a_message_given_already_encoded_is_passed_through(): + packed = msgpack.packb(CONNECTED_MESSAGE, use_bin_type=True) + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_success(packed)) + + client = await connected(mock) + + assert client.connection.connection_manager.connection_id == 'test-connection-id' + + +async def test_a_json_string_reaches_a_client_on_the_text_protocol(): + encoded = json.dumps(CONNECTED_MESSAGE) + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_success(encoded)) + + client = await connected(mock, use_binary_protocol=False) + + assert client.connection.connection_manager.connection_id == 'test-connection-id' + + +async def test_sending_a_template_leaves_it_untouched(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + before = json.dumps(CONNECTED_MESSAGE, sort_keys=True) + + await connected(mock) + + assert json.dumps(CONNECTED_MESSAGE, sort_keys=True) == before + + +async def test_connected_message_overrides_the_templates_connection_details(): + message = connected_message('other-id', maxIdleInterval=1000) + mock = MockWebSocket(on_connection_attempt=succeeding(message)) + + client = await connected(mock) + + assert client.connection.connection_manager.connection_id == 'other-id' + assert client.connection.connection_details.max_idle_interval == 1000 + assert CONNECTED_MESSAGE['connectionDetails']['maxIdleInterval'] == 15000 + + +# Protocol message templates + +async def test_a_heartbeat_message_answers_the_clients_ping(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + waiting = mock.await_next_message_from_client(timeout=2) + ping = asyncio.ensure_future(client.connection.ping()) + heartbeat = await waiting + mock.send_to_client({**HEARTBEAT_MESSAGE, 'id': heartbeat['id']}) + + assert heartbeat['action'] == int(ProtocolMessageAction.HEARTBEAT) + assert await ping >= 0 + + +async def test_error_message_derives_its_status_code_from_its_code(): + assert ERROR_MESSAGE(40142, 'Token expired')['error'] == { + 'code': 40142, 'statusCode': 401, 'message': 'Token expired'} + + +async def test_a_ping_message_is_recorded_but_draws_no_pong(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + + mock.send_to_client(PING_MESSAGE('ping-id')) + await asyncio.sleep(0.05) + + # RTN23c1 is unimplemented: action 22 matches no branch of + # `on_protocol_message`, so no PONG goes back + assert mock.messages_from_client == [] + assert client.connection.state == ConnectionState.CONNECTED + + +# Ping frames + +async def test_a_ping_frame_is_recorded_but_reaches_no_library_hook(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + transport = client.connection.connection_manager.transport + before = transport.last_activity + + mock.send_ping_frame() + await asyncio.sleep(0.05) + + # `WebSocketTransport` has no ping hook, so a ping frame is not activity + assert len(mock.events_of_type(MockEventType.PING_FRAME)) == 1 + assert transport.last_activity == before + assert client.connection.state == ConnectionState.CONNECTED + + +async def test_the_client_asks_for_no_heartbeat_mode(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + + await connected(mock) + + # RTN23b: omitting `heartbeats` leaves the server free to use ping frames + assert 'heartbeats' not in mock.connection_attempts[0].url.query_params + + +# Ordering requirements + +async def test_respond_with_success_establishes_the_connection_before_the_message(): + mock = MockWebSocket() + connect = mock.as_connect() + seen = [] + + async def transport(): + async with connect('wss://host?format=json') as websocket: + seen.append('established') + async for frame in websocket: + seen.append(json.loads(frame)['action']) + return + + reading = asyncio.ensure_future(transport()) + attempt = await mock.await_connection_attempt(timeout=2) + attempt.respond_with_success(CONNECTED_MESSAGE) + await reading + + assert seen == ['established', CONNECTED] + + +async def test_close_delivers_its_notification_asynchronously(): + mock = MockWebSocket(on_connection_attempt=lambda connection: connection.respond_with_success()) + connect = mock.as_connect() + ended = [] + + async def transport(): + async with connect('wss://host?format=json') as websocket: + async for _ in websocket: + pass + ended.append('read loop over') + + reading = asyncio.ensure_future(transport()) + await wait_for(lambda: mock.active_connection is not None) + + await mock.connections[0].close() + + # The close reaches the read loop from the stream, never inline + assert ended == [] + await reading + assert ended == ['read loop over'] + + +async def test_a_server_close_reaches_the_client_on_a_later_turn(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock, disconnected_retry_timeout=NO_RETRY) + states = [] + client.connection.on(lambda change: states.append(change.current)) + + mock.simulate_disconnect() + + # Nothing has reached the client's read loop yet + assert client.connection.state == ConnectionState.CONNECTED + assert states == [] + await wait_for(lambda: states and states[0] == ConnectionState.DISCONNECTED) + + +# Test management + +async def test_reset_clears_the_timeline_and_the_connections(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + await connected(mock) + + mock.reset() + + assert mock.events == [] + assert mock.connections == [] + assert mock.active_connection is None + + +async def test_reset_drops_a_waiting_test(): + mock = MockWebSocket() + waiting = asyncio.ensure_future(mock.await_connection_attempt(timeout=0.1)) + await asyncio.sleep(0) + + mock.reset() + + with pytest.raises(AssertionError, match='^Timeout waiting for connection attempt$'): + await waiting + + +async def test_reset_leaves_the_handlers_in_place(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = await connected(mock) + await client.close() + + mock.reset() + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(mock.connections) == 1 + + +# Composition with the other helpers + +async def test_a_realtime_client_can_take_the_http_mock_as_well(): + mock_ws = MockWebSocket(on_connection_attempt=succeeding()) + mock_http = MockHttpClient(on_request=lambda request: request.respond_with(200, [4321])) + client = realtime_client(mock_ws, mock_http=mock_http) + + assert await client.time() == 4321 + + +async def test_a_realtime_client_can_take_the_fake_clock(): + clock = FakeClock() + mock = MockWebSocket(on_connection_attempt=succeeding()) + client = realtime_client(mock, clock=clock) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # The idle timer the CONNECTED message starts is scheduled on the fake clock + assert clock.pending + + +async def test_a_client_which_never_connected_records_nothing(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + + client = realtime_client(mock) + + assert client.connection.state == ConnectionState.INITIALIZED + assert mock.events == [] + + +async def test_auto_connect_can_be_asked_for_explicitly(): + mock = MockWebSocket(on_connection_attempt=succeeding()) + + client = realtime_client(mock, auto_connect=True) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(mock.connection_attempts) == 1 + + +async def test_send_to_client_without_a_connection_fails_the_test(): + mock = MockWebSocket() + + with pytest.raises(AssertionError, match='^No connection has been established$'): + mock.send_to_client(HEARTBEAT_MESSAGE) diff --git a/test/uts/helpers/presence.py b/test/uts/helpers/presence.py new file mode 100644 index 00000000..4d493bdc --- /dev/null +++ b/test/uts/helpers/presence.py @@ -0,0 +1,84 @@ +"""Presence fixtures the presence specifications build their test steps from. + +The three white-box specifications (`presence_map.md`, `presence_sync.md`, +`local_presence_map.md`) drive a `PresenceMap` and a `RealtimePresence` directly, and +the channel-state and `get` specifications build presence wire messages by hand. Both +shapes were written the same way in every file that needed them, so they live here. +""" + +from types import SimpleNamespace + +from ably.realtime.presence import RealtimePresence +from ably.realtime.presencemap import PresenceMap +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.presence import PresenceAction, PresenceMessage + +#: The lowercase action names `RealtimePresence` emits its events under, which come +#: from `PresenceAction._action_name` and exist only once `presence.py` is imported. +PRESENCE_EVENT_NAMES = ('absent', 'present', 'enter', 'leave', 'update') + + +def presence_map(): + """The specification's `PresenceMap()`: the map keyed by memberKey (TP3h).""" + return PresenceMap(member_key_fn=lambda msg: msg.member_key) + + +def presence_message(action, client_id, connection_id, id, timestamp, data=None): + """A `PresenceMessage` as the specification's test steps construct one.""" + return PresenceMessage( + action=action, + client_id=client_id, + connection_id=connection_id, + id=id, + timestamp=timestamp, + data=data, + ) + + +def subscribed_presence(connection_id='conn-1', name='presence-test'): + """A `RealtimePresence` over a stub channel, with every presence event recorded. + + Returns the presence object and the list of `(event_name, message)` pairs its + subscribers receive. One listener is registered per event name because + `EventEmitter` keys its wrappers on the listener alone. + """ + channel = SimpleNamespace( + name=name, + ably=SimpleNamespace( + connection=SimpleNamespace( + connection_manager=SimpleNamespace(connection_id=connection_id), + ), + ), + ) + presence = RealtimePresence(channel) + events = [] + + for event_name in PRESENCE_EVENT_NAMES: + def listener(message, event_name=event_name): + events.append((event_name, message)) + + presence._subscriptions.on(event_name, listener) + + return presence, events + + +def present_member(client_id, connection_id, id, **fields): + """One entry of a PRESENCE or SYNC protocol message's `presence` array.""" + return { + 'action': PresenceAction.PRESENT, + 'clientId': client_id, + 'connectionId': connection_id, + 'id': id, + 'timestamp': 100, + **fields, + } + + +def sync_message(channel_name, channel_serial, presence): + """A SYNC protocol message carrying `presence` under `channel_serial`.""" + return { + 'action': int(ProtocolMessageAction.SYNC), + 'channel': channel_name, + 'channelSerial': channel_serial, + 'presence': presence, + } diff --git a/test/uts/realtime/__init__.py b/test/uts/realtime/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/__init__.py b/test/uts/realtime/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/auth/__init__.py b/test/uts/realtime/unit/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/auth/auth_callback_errors_test.py b/test/uts/realtime/unit/auth/auth_callback_errors_test.py new file mode 100644 index 00000000..8be7afb0 --- /dev/null +++ b/test/uts/realtime/unit/auth/auth_callback_errors_test.py @@ -0,0 +1,300 @@ +"""Derived from uts/realtime/unit/auth/auth_callback_errors_test.md in ably/specification. + +Spec points: RSA4c, RSA4c2, RSA4c3, RSA4d, RSA4e, RSA4f +""" + +import asyncio +import time + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.tokendetails import TokenDetails +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client, rest_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +AUTH_MESSAGE = {'action': int(ProtocolMessageAction.AUTH)} + +# A retry far enough out that a DISCONNECTED connection does not reconnect +# behind the assertions. +NO_RECONNECT = 60000 + +# The specification's 128KiB limit, exceeded by one character. +OVERSIZED_TOKEN = 'x' * 131073 + +CHANNEL_DETAILS_BODY = { + 'channelId': 'test-channel', + 'status': {'isActive': True, 'occupancy': {'metrics': {'connections': 0}}}, +} + + +def now(): + return int(time.time() * 1000) + + +def connect_successfully(): + """A handler that completes the attempt and sends CONNECTED behind it.""" + message = connected_message('connection-id', connectionKey='connection-key') + + def on_connection_attempt(conn): + conn.respond_with_success(message) + + return on_connection_attempt + + +async def poll_until(condition, timeout=5.0, description='condition'): + """Waits for `condition` to hold, yielding to the event loop between checks. + + This is the specifications' `AWAIT UNTIL`. The auth paths chain tasks + several levels deep, so a single yield is not enough to see the result of + one; the deadline is a safety net, not a delay. + """ + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + raise AssertionError(f'Timed out waiting for {description}') + await settle(passes=5) + + +# UTS: realtime/unit/RSA4c2/callback-error-connecting-disconnected-0 +async def test_rsa4c2_callback_error_connecting_disconnected(): + auth_callback_count = 0 + + async def auth_callback(params): + nonlocal auth_callback_count + auth_callback_count += 1 + if auth_callback_count == 1: + raise AblyException('Auth server unavailable', 500, 50000) + return TokenDetails(token=f'valid-token-{auth_callback_count}', expires=now() + 3600000) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, use_binary_protocol=False, + disconnected_retry_timeout=NO_RECONNECT) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.state == ConnectionState.DISCONNECTED + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 401 + # RSA4c2 requires `cause` to carry the underlying 50000 error. + # `ConnectionManager.on_error_from_authorize` builds the 80019 without one, + # so the callback's own error is only in the log. + assert client.connection.error_reason.cause is None + + disconnected_changes = [c for c in state_changes if c.current == ConnectionState.DISCONNECTED] + assert len(disconnected_changes) >= 1 + assert disconnected_changes[0].reason is not None + assert disconnected_changes[0].reason.code == 80019 + + +# UTS: realtime/unit/RSA4c2/callback-timeout-connecting-disconnected-1 +async def test_rsa4c2_callback_timeout_connecting_disconnected(): + never_returns = asyncio.Event() + + async def auth_callback(params): + await never_returns.wait() + + clock = FakeClock() + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, clock=clock, auth_callback=auth_callback, + realtime_request_timeout=10000, use_binary_protocol=False, + disconnected_retry_timeout=NO_RECONNECT) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + client.connect() + # With the fake clock installed only `advance` moves time, so the state the + # timeout produces is recorded and asserted rather than awaited + await clock.advance(11000) + + assert client.connection.state == ConnectionState.DISCONNECTED + + # RSA4c requires a callback that outruns `realtimeRequestTimeout` to be + # treated as an auth error, giving 80019/401. ably-python applies no + # timeout to the callback itself: the CONNECTING transition timer ends the + # attempt instead, with the generic "request timeout" error it raises for + # any connect that does not complete in time. + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 50003 + assert client.connection.error_reason.status_code == 504 + + never_returns.set() + + +# UTS: realtime/unit/RSA4c3/callback-error-connected-stays-0 +async def test_rsa4c3_callback_error_connected_stays(): + auth_callback_count = 0 + + async def auth_callback(params): + nonlocal auth_callback_count + auth_callback_count += 1 + if auth_callback_count == 1: + return TokenDetails(token='initial-token', expires=now() + 3600000) + raise AblyException('Auth server unavailable', 500, 50000) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, use_binary_protocol=False) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + mock_ws.send_to_client(AUTH_MESSAGE) + + await poll_until(lambda: auth_callback_count >= 2, description='the reauth callback to run') + await settle() + + assert client.connection.state == ConnectionState.CONNECTED + assert state_changes == [] + assert client.connection.error_reason is None + + +# UTS: realtime/unit/RSA4d/callback-403-connecting-failed-0 +@deviation +async def test_rsa4d_callback_403_connecting_failed(): + connection_attempted = False + + async def auth_callback(params): + raise AblyException('Account disabled', 403, 40300) + + def on_connection_attempt(conn): + nonlocal connection_attempted + connection_attempted = True + conn.respond_with_success(connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=auth_callback, use_binary_protocol=False, + disconnected_retry_timeout=NO_RECONNECT) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.state == ConnectionState.FAILED + assert connection_attempted is False + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 403 + assert client.connection.error_reason.cause is not None + assert client.connection.error_reason.cause.code == 40300 + assert client.connection.error_reason.cause.status_code == 403 + + failed_changes = [c for c in state_changes if c.current == ConnectionState.FAILED] + assert len(failed_changes) == 1 + assert failed_changes[0].reason is not None + assert failed_changes[0].reason.code == 80019 + assert failed_changes[0].reason.status_code == 403 + + disconnected_changes = [c for c in state_changes if c.current == ConnectionState.DISCONNECTED] + assert disconnected_changes == [] + + +# UTS: realtime/unit/RSA4d/callback-403-reauth-failed-1 +@deviation +async def test_rsa4d_callback_403_reauth_failed(): + auth_callback_count = 0 + + async def auth_callback(params): + nonlocal auth_callback_count + auth_callback_count += 1 + if auth_callback_count == 1: + return TokenDetails(token='initial-token', expires=now() + 3600000) + raise AblyException('Account suspended', 403, 40300) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, use_binary_protocol=False, + disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.send_to_client(AUTH_MESSAGE) + + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 403 + assert client.connection.error_reason.cause is not None + assert client.connection.error_reason.cause.code == 40300 + + +# UTS: realtime/unit/RSA4f/callback-invalid-type-format-0 +@deviation +async def test_rsa4f_callback_invalid_type_format(): + async def auth_callback(params): + return 12345 + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, use_binary_protocol=False, + disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.state == ConnectionState.DISCONNECTED + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 401 + + +# UTS: realtime/unit/RSA4f/callback-oversized-token-format-1 +@pytest.mark.skip( + reason='A token over 128KiB makes the websocket URL longer than httpx.URL accepts, and ' + 'the mock parses every connection URL through it, so the attempt raises InvalidURL ' + 'inside the mock before it is recorded and nothing about the SDK can be observed.') +async def test_rsa4f_callback_oversized_token_format(): + async def auth_callback(params): + return OVERSIZED_TOKEN + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, use_binary_protocol=False, + disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.state == ConnectionState.DISCONNECTED + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 401 + + +# UTS: realtime/unit/RSA4e/rest-callback-error-40170-0 +async def test_rsa4e_rest_callback_error_40170(): + async def auth_callback(params): + raise Exception('Network failure connecting to auth server') + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=lambda request: request.respond_with(200, CHANNEL_DETAILS_BODY), + ) + client = rest_client(mock_http, auth_callback=auth_callback, use_binary_protocol=False) + + channel = client.channels.get('test-channel') + with pytest.raises(AblyException) as excinfo: + await channel.status() + + assert excinfo.value.code == 40170 + assert excinfo.value.status_code == 401 + assert excinfo.value.message is not None + assert len(excinfo.value.message) > 0 diff --git a/test/uts/realtime/unit/auth/connection_auth_test.py b/test/uts/realtime/unit/auth/connection_auth_test.py new file mode 100644 index 00000000..8e73aefd --- /dev/null +++ b/test/uts/realtime/unit/auth/connection_auth_test.py @@ -0,0 +1,266 @@ +"""Derived from uts/realtime/unit/auth/connection_auth_test.md in ably/specification. + +Spec points: RTN2e, RTN27b, RSA4, RSA4c, RSA4c1, RSA4c2, RSA4c3, RSA4d, RSA8d, RSA12a +""" + +import time + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.tokendetails import TokenDetails +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +AUTH_MESSAGE = {'action': int(ProtocolMessageAction.AUTH)} + +# A retry far enough out that a DISCONNECTED connection does not reconnect +# behind the assertions. +NO_RECONNECT = 60000 + + +def now(): + return int(time.time() * 1000) + + +def connect_successfully(connection_id='connection-id', connection_key='connection-key'): + """A handler that completes the attempt and sends CONNECTED behind it.""" + message = connected_message(connection_id, connectionKey=connection_key) + + def on_connection_attempt(conn): + conn.respond_with_success(message) + + return on_connection_attempt + + +async def poll_until(condition, timeout=5.0, description='condition'): + """Waits for `condition` to hold, yielding to the event loop between checks. + + This is the specifications' `AWAIT UNTIL`. The auth paths chain tasks + several levels deep, so a single yield is not enough to see the result of + one; the deadline is a safety net, not a delay. + """ + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + raise AssertionError(f'Timed out waiting for {description}') + await settle(passes=5) + + +# UTS: realtime/unit/RTN2e/token-before-websocket-0 +async def test_rtn2e_token_before_websocket(): + callback_invoked = False + callback_invoked_time = None + connection_attempt_time = None + captured_ws_url = None + + async def auth_callback(params): + nonlocal callback_invoked, callback_invoked_time + callback_invoked = True + callback_invoked_time = time.monotonic() + return TokenDetails(token='callback-provided-token', expires=now() + 3600000) + + def on_connection_attempt(conn): + nonlocal connection_attempt_time, captured_ws_url + connection_attempt_time = time.monotonic() + captured_ws_url = conn.url + conn.respond_with_success(connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=auth_callback) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert callback_invoked is True + assert callback_invoked_time < connection_attempt_time + assert captured_ws_url.query_params['accessToken'] == 'callback-provided-token' + assert captured_ws_url.query_params.get('key') is None + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTN2e/callback-error-prevents-connect-1 +async def test_rtn2e_callback_error_prevents_connect(): + connection_attempted = False + + async def auth_callback(params): + raise Exception('Auth callback failed') + + def on_connection_attempt(conn): + nonlocal connection_attempted + connection_attempted = True + conn.respond_with_success(connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=auth_callback, disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert connection_attempted is False + assert client.connection.error_reason is not None + assert (client.connection.error_reason.status_code == 401 + or client.connection.error_reason.code == 40170) + + +# UTS: realtime/unit/RTN2e/callback-params-include-clientid-2 +@deviation +async def test_rtn2e_callback_params_include_clientid(): + received_params = None + + async def auth_callback(params): + nonlocal received_params + received_params = params + return TokenDetails(token='token-for-client', expires=now() + 3600000, client_id='my-client-id') + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, client_id='my-client-id') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert received_params is not None + # `TokenParams` is a plain dict here, and ably-python spells its members + # snake_case throughout, so `client_id` is the idiomatic rendering of the + # specification's `clientId`. The deviation is that the member is absent: + # `Auth.__init__` leaves `client_id` unset on a realtime client, so + # `_ensure_valid_auth_credentials` never puts it in the token params. + assert received_params['client_id'] == 'my-client-id' + + +# UTS: realtime/unit/RTN2e/reuse-valid-token-3 +async def test_rtn2e_reuse_valid_token(): + callback_count = 0 + + async def auth_callback(params): + nonlocal callback_count + callback_count += 1 + return TokenDetails(token='reusable-token', expires=now() + 3600000) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await client.close() + assert client.connection.state == ConnectionState.CLOSED + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert callback_count == 1 + + +# UTS: realtime/unit/RSA4c2/callback-error-causes-disconnected-0 +async def test_rsa4c2_callback_error_causes_disconnected(): + auth_callback_count = 0 + + async def auth_callback(params): + nonlocal auth_callback_count + auth_callback_count += 1 + if auth_callback_count == 1: + raise AblyException('Auth server unavailable', 500, 50000) + return TokenDetails(token=f'valid-token-{auth_callback_count}', expires=now() + 3600000) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 401 + # RSA4c1 requires `cause` to carry the underlying 50000 error. + # `ConnectionManager.on_error_from_authorize` builds the 80019 without one, + # so the callback's own error is only in the log. + assert client.connection.error_reason.cause is None + + +# UTS: realtime/unit/RSA4c3/callback-error-stays-connected-0 +@deviation +async def test_rsa4c3_callback_error_stays_connected(): + auth_callback_count = 0 + + async def auth_callback(params): + nonlocal auth_callback_count + auth_callback_count += 1 + if auth_callback_count == 1: + return TokenDetails(token='initial-token', expires=now() + 3600000) + raise AblyException('Auth server unavailable', 500, 50000) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + mock_ws.send_to_client(AUTH_MESSAGE) + + await poll_until(lambda: client.connection.error_reason is not None, + description='errorReason to be set') + + assert client.connection.state == ConnectionState.CONNECTED + + non_connected_changes = [c for c in state_changes if c.current != ConnectionState.CONNECTED] + assert non_connected_changes == [] + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 401 + assert client.connection.error_reason.cause is not None + assert client.connection.error_reason.cause.code == 50000 + + +# UTS: realtime/unit/RSA4d/callback-403-causes-failed-0 +@deviation +async def test_rsa4d_callback_403_causes_failed(): + async def auth_callback(params): + raise AblyException('Account disabled', 403, 40300) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 403 + assert client.connection.error_reason.cause is not None + assert client.connection.error_reason.cause.code == 40300 + + +# UTS: realtime/unit/RSA4d/callback-403-reauth-causes-failed-1 +@deviation +async def test_rsa4d_callback_403_reauth_causes_failed(): + auth_callback_count = 0 + + async def auth_callback(params): + nonlocal auth_callback_count + auth_callback_count += 1 + if auth_callback_count == 1: + return TokenDetails(token='initial-token', expires=now() + 3600000) + raise AblyException('Account suspended', 403, 40300) + + mock_ws = MockWebSocket(on_connection_attempt=connect_successfully()) + client = realtime_client(mock_ws, auth_callback=auth_callback, disconnected_retry_timeout=NO_RECONNECT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.send_to_client(AUTH_MESSAGE) + + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + assert client.connection.error_reason.status_code == 403 + assert client.connection.error_reason.cause is not None + assert client.connection.error_reason.cause.code == 40300 diff --git a/test/uts/realtime/unit/auth/realtime_authorize_test.py b/test/uts/realtime/unit/auth/realtime_authorize_test.py new file mode 100644 index 00000000..3dd76a26 --- /dev/null +++ b/test/uts/realtime/unit/auth/realtime_authorize_test.py @@ -0,0 +1,452 @@ +"""Derived from uts/realtime/unit/auth/realtime_authorize.md in ably/specification. + +Spec points: RTC8, RTC8a, RTC8a1, RTC8a2, RTC8a3, RTC8b, RTC8b1, RTC8c +""" + +import asyncio +import time + +import pytest + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionEvent, ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.tokendetails import TokenDetails +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +AUTH_ACTION = int(ProtocolMessageAction.AUTH) +ATTACH_ACTION = int(ProtocolMessageAction.ATTACH) + + +def now(): + return int(time.time() * 1000) + + +def numbered_token_callback(counter): + """The specifications' authCallback: `token-1`, `token-2`, ... in turn. + + `counter` is a single-element list so that the caller can read how many + times the callback ran. + """ + async def auth_callback(params): + counter[0] += 1 + return TokenDetails(token=f'token-{counter[0]}', expires=now() + 3600000) + + return auth_callback + + +def error_message(code, status_code, message): + return { + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def channel_error_message(channel, code, status_code, message): + return { + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def attached_message(channel): + return {'action': int(ProtocolMessageAction.ATTACHED), 'channel': channel, 'flags': 0} + + +async def poll_until(condition, timeout=5.0, description='condition'): + """Waits for `condition` to hold, yielding to the event loop between checks. + + The connect path chains tasks several levels deep, so a single yield is not + enough to see the result of one; the deadline is a safety net, not a delay. + """ + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + raise AssertionError(f'Timed out waiting for {description}') + await settle(passes=5) + + +async def await_channel_state(channel, state, timeout=5.0): + """Waits for `channel` to reach `state`. + + This is the specifications' `AWAIT_STATE` for a channel; the connection has + `await_connection_state` in `test.uts.helpers.client`. + """ + deadline = time.monotonic() + timeout + while channel.state != state: + if time.monotonic() > deadline: + raise AssertionError( + f'Timed out waiting for channel state {state}; it was {channel.state}') + await settle(passes=5) + + +# UTS: realtime/unit/RTC8a/authorize-connected-sends-auth-0 +async def test_rtc8a_authorize_connected_sends_auth(): + auth_callback_count = [0] + captured_auth_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + def on_message_from_client(message): + if message['action'] == AUTH_ACTION: + captured_auth_messages.append(message) + mock_ws.send_to_client( + connected_message('connection-id', connectionKey='connection-key-2')) + + mock_ws.on_message_from_client = on_message_from_client + + token_details = await client.auth.authorize() + + assert auth_callback_count[0] == 2 + + assert len(captured_auth_messages) == 1 + assert captured_auth_messages[0].get('auth') is not None + assert captured_auth_messages[0]['auth']['accessToken'] == 'token-2' + + assert token_details.token == 'token-2' + + state_transitions = [c for c in state_changes if c.current != c.previous] + assert state_transitions == [] + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTC8a1/successful-reauth-update-event-0 +async def test_rtc8a1_successful_reauth_update_event(): + auth_callback_count = [0] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id-1', connectionKey='connection-key-1')), + ) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + update_events = [] + connected_events = [] + state_changes = [] + + client.connection.on(ConnectionEvent.UPDATE, lambda change: update_events.append(change)) + client.connection.on(ConnectionState.CONNECTED, lambda change: connected_events.append(change)) + client.connection.on(lambda change: state_changes.append(change)) + + def on_message_from_client(message): + if message['action'] == AUTH_ACTION: + mock_ws.send_to_client(connected_message( + 'connection-id-2', connectionKey='connection-key-2', + maxIdleInterval=20000, connectionStateTtl=180000)) + + mock_ws.on_message_from_client = on_message_from_client + + await client.auth.authorize() + await settle() + + assert len(update_events) == 1 + assert update_events[0].previous == ConnectionState.CONNECTED + assert update_events[0].current == ConnectionState.CONNECTED + + assert connected_events == [] + + state_transitions = [c for c in state_changes if c.current != c.previous] + assert state_transitions == [] + + # RTN21: the reauth CONNECTED overrides the connection details. The + # specification reads `client.connection.id` and `client.connection.key`; + # ably-python exposes neither, carrying the id on the connection manager + # and the key on the connection details. + assert client.connection.connection_manager.connection_id == 'connection-id-2' + assert client.connection.connection_details.connection_key == 'connection-key-2' + + +# UTS: realtime/unit/RTC8a1/capability-downgrade-channel-failed-1 +async def test_rtc8a1_capability_downgrade_channel_failed(): + auth_callback_count = [0] + channel_name = 'private-channel' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel = client.channels.get(channel_name) + + def on_message_from_client(message): + if message['action'] == ATTACH_ACTION and message.get('channel') == channel_name: + mock_ws.send_to_client(attached_message(channel_name)) + if message['action'] == AUTH_ACTION: + # The reauth succeeds at the connection level + mock_ws.send_to_client( + connected_message('connection-id', connectionKey='connection-key-2')) + # and is followed by the channel-level ERROR the downgrade produces. + # The two are queued in order on the same connection, so the ERROR + # reaches the client behind the CONNECTED. + mock_ws.send_to_client(channel_error_message( + channel_name, 40160, 401, 'Channel denied access based on given capability')) + + mock_ws.on_message_from_client = on_message_from_client + + await channel.attach() + await await_channel_state(channel, ChannelState.ATTACHED) + + channel_state_changes = [] + channel.on(lambda change: channel_state_changes.append(change)) + + await client.auth.authorize() + await await_channel_state(channel, ChannelState.FAILED) + + assert channel.state == ChannelState.FAILED + + failed_changes = [c for c in channel_state_changes if c.current == ChannelState.FAILED] + assert len(failed_changes) == 1 + assert failed_changes[0].reason is not None + assert failed_changes[0].reason.code == 40160 + assert failed_changes[0].reason.status_code == 401 + + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTC8a2/failed-reauth-connection-failed-0 +async def test_rtc8a2_failed_reauth_connection_failed(): + auth_callback_count = [0] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + def on_message_from_client(message): + if message['action'] == AUTH_ACTION: + mock_ws.send_to_client_and_close( + error_message(40012, 400, 'Incompatible clientId')) + + mock_ws.on_message_from_client = on_message_from_client + + with pytest.raises(AblyException) as excinfo: + await client.auth.authorize() + + assert excinfo.value.code == 40012 + + assert client.connection.state == ConnectionState.FAILED + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40012 + + assert ConnectionState.FAILED in [c.current for c in state_changes] + + +# UTS: realtime/unit/RTC8a3/authorize-completes-after-response-0 +async def test_rtc8a3_authorize_completes_after_response(): + auth_callback_count = [0] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # The waiter is registered before authorize starts, so the AUTH message is + # not missed while the task is being scheduled + next_message = mock_ws.await_next_message_from_client() + authorize_future = asyncio.create_task(client.auth.authorize()) + + auth_msg = await next_message + assert auth_msg['action'] == AUTH_ACTION + + # The server has not responded, so authorize has not completed + await settle() + assert authorize_future.done() is False + + mock_ws.send_to_client(connected_message('connection-id', connectionKey='connection-key-2')) + + token_details = await authorize_future + + assert authorize_future.done() is True + assert token_details.token == 'token-2' + + +# UTS: realtime/unit/RTC8b/authorize-connecting-halts-attempt-0 +async def test_rtc8b_authorize_connecting_halts_attempt(): + auth_callback_count = [0] + captured_ws_urls = [] + connection_attempt_count = [0] + + def on_connection_attempt(conn): + connection_attempt_count[0] += 1 + captured_ws_urls.append(conn.url) + if connection_attempt_count[0] == 1: + # The transport opens but no CONNECTED arrives, so the client stays + # in CONNECTING + conn.respond_with_success() + else: + conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTING) + # The attempt has to be in flight for authorize to have one to halt + await poll_until(lambda: connection_attempt_count[0] == 1, + description='the first connection attempt') + + token_details = await client.auth.authorize() + + assert token_details.token == 'token-2' + assert client.connection.state == ConnectionState.CONNECTED + assert auth_callback_count[0] == 2 + assert connection_attempt_count[0] == 2 + assert captured_ws_urls[1].query_params['accessToken'] == 'token-2' + + +# UTS: realtime/unit/RTC8b1/authorize-connecting-fails-on-failed-0 +async def test_rtc8b1_authorize_connecting_fails_on_failed(): + auth_callback_count = [0] + connection_attempt_count = [0] + + def on_connection_attempt(conn): + connection_attempt_count[0] += 1 + if connection_attempt_count[0] == 1: + conn.respond_with_success() + else: + conn.respond_with_success() + conn.send_to_client_and_close(error_message(40101, 401, 'Invalid credentials')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTING) + # The attempt has to be in flight for authorize to have one to halt + await poll_until(lambda: connection_attempt_count[0] == 1, + description='the first connection attempt') + + with pytest.raises(AblyException) as excinfo: + await client.auth.authorize() + + assert excinfo.value.code == 40101 + assert client.connection.state == ConnectionState.FAILED + + +# UTS: realtime/unit/RTC8c/authorize-disconnected-initiates-connection-0 +async def test_rtc8c_authorize_disconnected_initiates_connection(): + auth_callback_count = [0] + captured_ws_urls = [] + connection_attempt_count = [0] + + def on_connection_attempt(conn): + connection_attempt_count[0] += 1 + captured_ws_urls.append(conn.url) + conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + assert client.connection.state == ConnectionState.INITIALIZED + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change.current)) + + token_details = await client.auth.authorize() + + assert token_details.token == 'token-1' + assert client.connection.state == ConnectionState.CONNECTED + + connecting_at = state_changes.index(ConnectionState.CONNECTING) + connected_at = state_changes.index(ConnectionState.CONNECTED) + assert connecting_at < connected_at + + assert captured_ws_urls[0].query_params['accessToken'] == 'token-1' + + +# UTS: realtime/unit/RTC8c/authorize-failed-initiates-connection-1 +async def test_rtc8c_authorize_failed_initiates_connection(): + auth_callback_count = [0] + captured_ws_urls = [] + connection_attempt_count = [0] + + def on_connection_attempt(conn): + connection_attempt_count[0] += 1 + captured_ws_urls.append(conn.url) + conn.respond_with_success() + if connection_attempt_count[0] == 1: + conn.send_to_client_and_close(error_message(40101, 401, 'Invalid credentials')) + else: + conn.send_to_client( + connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change.current)) + + token_details = await client.auth.authorize() + + assert token_details.token == 'token-2' + assert client.connection.state == ConnectionState.CONNECTED + + connecting_at = state_changes.index(ConnectionState.CONNECTING) + connected_at = state_changes.index(ConnectionState.CONNECTED) + assert connecting_at < connected_at + + assert captured_ws_urls[1].query_params['accessToken'] == 'token-2' + + +# UTS: realtime/unit/RTC8c/authorize-closed-initiates-connection-2 +async def test_rtc8c_authorize_closed_initiates_connection(): + auth_callback_count = [0] + connection_attempt_count = [0] + + def on_connection_attempt(conn): + connection_attempt_count[0] += 1 + conn.respond_with_success(connected_message( + f'connection-id-{connection_attempt_count[0]}', + connectionKey=f'connection-key-{connection_attempt_count[0]}')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auth_callback=numbered_token_callback(auth_callback_count)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await client.close() + assert client.connection.state == ConnectionState.CLOSED + + token_details = await client.auth.authorize() + + assert token_details.token == 'token-2' + assert client.connection.state == ConnectionState.CONNECTED diff --git a/test/uts/realtime/unit/auth/token_expiry_non_renewable_test.py b/test/uts/realtime/unit/auth/token_expiry_non_renewable_test.py new file mode 100644 index 00000000..2c36c07e --- /dev/null +++ b/test/uts/realtime/unit/auth/token_expiry_non_renewable_test.py @@ -0,0 +1,83 @@ +"""Derived from uts/realtime/unit/auth/token_expiry_non_renewable_test.md in ably/specification. + +Spec points: RSA4a, RSA4a1, RSA4a2 +""" + +import logging + +from ably.realtime.connection import ConnectionState +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ERROR_MESSAGE, MockWebSocket, connected_message + +CONNECTED = connected_message('connection-id', connectionKey='connection-key') + + +# UTS: realtime/unit/RSA4a1/non-renewable-token-logs-warning-0 +@deviation +async def test_rsa4a1_non_renewable_token_logs_warning(caplog): + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED)) + + # The specification collects the library's log through a `logHandler` client + # option. ably-python logs through the standard `logging` module and has no + # such option, so the records are captured from the `ably` logger instead. + with caplog.at_level(logging.INFO, logger='ably'): + realtime_client(mock_ws, token='non-renewable-token', use_binary_protocol=False) + + info_messages = [record.getMessage() for record in caplog.records + if record.levelno == logging.INFO] + + assert any('40171' in message + or ('no means' in message and 'renew' in message) + for message in info_messages) + + # TI5: the log entry carries the help URL for the error + assert any('https://help.ably.io/error/40171' in message for message in info_messages) + + +# UTS: realtime/unit/RSA4a2/token-error-non-renewable-failed-0 +async def test_rsa4a2_token_error_non_renewable_failed(): + def on_connection_attempt(conn): + conn.respond_with_success() + conn.send_to_client_and_close(ERROR_MESSAGE(40142, 'Token expired')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, token='expired-token', use_binary_protocol=False) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.state == ConnectionState.FAILED + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40171 + + failed_changes = [c for c in state_changes if c.current == ConnectionState.FAILED] + assert len(failed_changes) == 1 + assert failed_changes[0].reason is not None + assert failed_changes[0].reason.code == 40171 + + +# UTS: realtime/unit/RSA4a2/token-error-non-renewable-no-retry-1 +async def test_rsa4a2_token_error_non_renewable_no_retry(): + connection_attempt_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_attempt_count + connection_attempt_count += 1 + conn.respond_with_success() + conn.send_to_client_and_close(ERROR_MESSAGE(40140, 'Token error')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, token='non-renewable-token', use_binary_protocol=False) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert connection_attempt_count == 1 + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40171 diff --git a/test/uts/realtime/unit/channels/__init__.py b/test/uts/realtime/unit/channels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/channels/channel_additional_attached_test.py b/test/uts/realtime/unit/channels/channel_additional_attached_test.py new file mode 100644 index 00000000..eb9b64e9 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_additional_attached_test.py @@ -0,0 +1,128 @@ +"""Derived from uts/realtime/unit/channels/channel_additional_attached.md in ably/specification. + +Spec points: RTL12 +""" + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def attach_responder(mock_ws, channel_name): + """Answers every ATTACH for `channel_name` with a bare ATTACHED.""" + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({'action': ProtocolMessageAction.ATTACHED, 'channel': channel_name}) + + return on_message_from_client + + +# UTS: realtime/unit/RTL12/update-emits-with-error-0 +async def test_rtl12_update_emits_with_error(): + channel_name = 'test-RTL12-update' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, channel_name) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + + update_events = [] + + def record(change): + update_events.append(change) + + channel.on('update', record) + + mock_ws.send_to_client({ + 'action': ProtocolMessageAction.ATTACHED, + 'channel': channel_name, + 'error': {'code': 50000, 'statusCode': 500, 'message': 'generic serverside failure'}, + }) + await settle() + + assert channel.state == ChannelState.ATTACHED + assert len(update_events) == 1 + # The specification asserts `event == ChannelEvent.update`; ably-python has + # no ChannelStateChange.event, the event being the key the listener is + # registered against + assert update_events[0].current == ChannelState.ATTACHED + assert update_events[0].previous == ChannelState.ATTACHED + assert update_events[0].resumed is False + assert update_events[0].reason.code == 50000 + + +# UTS: realtime/unit/RTL12/resumed-no-update-1 +async def test_rtl12_resumed_no_update(): + channel_name = 'test-RTL12-no-update' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, channel_name) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + + update_events = [] + + def record(change): + update_events.append(change) + + channel.on('update', record) + + mock_ws.send_to_client({ + 'action': ProtocolMessageAction.ATTACHED, + 'channel': channel_name, + 'flags': Flag.RESUMED, + }) + await settle() + + assert channel.state == ChannelState.ATTACHED + assert update_events == [] + + +# UTS: realtime/unit/RTL12/no-error-null-reason-2 +async def test_rtl12_no_error_null_reason(): + channel_name = 'test-RTL12-no-error' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, channel_name) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + + update_events = [] + + def record(change): + update_events.append(change) + + channel.on('update', record) + + mock_ws.send_to_client({'action': ProtocolMessageAction.ATTACHED, 'channel': channel_name}) + await settle() + + assert channel.state == ChannelState.ATTACHED + assert len(update_events) == 1 + assert update_events[0].resumed is False + assert update_events[0].reason is None diff --git a/test/uts/realtime/unit/channels/channel_annotations_test.py b/test/uts/realtime/unit/channels/channel_annotations_test.py new file mode 100644 index 00000000..61fa13f5 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_annotations_test.py @@ -0,0 +1,498 @@ +"""Derived from uts/realtime/unit/channels/channel_annotations.md in ably/specification. + +Spec points: RTL26, RTAN1, RTAN1a, RTAN1b, RTAN1c, RTAN1d, RTAN2, RTAN2a, RTAN3, RTAN3a, +RTAN4, RTAN4a, RTAN4b, RTAN4c, RTAN4d, RTAN4e, RTAN4e1, RTAN5, RTAN5a + +An annotation is an `ack_required` protocol message, so a publish or delete is awaited +until the server answers it; the mocks below ACK each one, as the specification's own +handlers do. + +The specification's RTAN3a section carries no Test ID and so gets no test, as the +suite derives one test per Test ID. + +`RealtimeChannelOptions(attachOnSubscribe: false)` does not exist in ably-python and +`annotations.subscribe` always attaches, so the tests that use it attach first instead; +see [deviations.md](../../../deviations.md). +""" + +import asyncio +import json +import uuid + +import pytest + +from ably.realtime.annotations import RealtimeAnnotations +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.annotation import Annotation, AnnotationAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, poll_until, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +ACK_ACTION = 1 +NACK_ACTION = 2 + +PUBLISH_FLAGS = Flag.PUBLISH | Flag.ANNOTATION_PUBLISH +SUBSCRIBE_FLAGS = Flag.PUBLISH | Flag.ANNOTATION_PUBLISH | Flag.ANNOTATION_SUBSCRIBE + +# How long a call the specification expects to settle is given before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def ack(msg): + return {'action': ACK_ACTION, 'msgSerial': msg['msgSerial'], 'count': 1} + + +def nack(msg, code, message): + return { + 'action': NACK_ACTION, + 'msgSerial': msg['msgSerial'], + 'count': 1, + 'error': {'code': code, 'statusCode': code // 100, 'message': message}, + } + + +def annotation_protocol_message(channel_name, annotations, **fields): + """An ANNOTATION protocol message carrying `annotations` on `channel_name`.""" + return { + 'action': int(ProtocolMessageAction.ANNOTATION), + 'channel': channel_name, + 'annotations': annotations, + **fields, + } + + +def annotating_mock(channel_name, flags=PUBLISH_FLAGS, captured_messages=None, answer=ack): + """A mock which connects, attaches `channel_name` and answers each ANNOTATION. + + `flags` are the channel modes the ATTACHED confirms, and `answer` builds the ACK + or NACK the mock replies to an annotation with. + """ + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if captured_messages is not None: + captured_messages.append(msg) + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(flags))) + elif msg.get('action') == ProtocolMessageAction.ANNOTATION: + mock_ws.send_to_client(answer(msg)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def connected_client(mock_ws, **kwargs): + """A client connected over `mock_ws`.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +async def attached_channel(mock_ws, channel_name, **kwargs): + """A channel attached over `mock_ws`, which most of these tests start from.""" + client = await connected_client(mock_ws, **kwargs) + channel = client.channels.get(channel_name) + await channel.attach() + return channel + + +def recorder(received): + """An annotation listener appending each annotation it is given to `received`.""" + def record(annotation): + received.append(annotation) + return record + + +def sent_annotations(captured_messages): + """The ANNOTATION protocol messages among those the client sent.""" + return [msg for msg in captured_messages if msg.get('action') == ProtocolMessageAction.ANNOTATION] + + +# UTS: realtime/unit/RTL26/annotations-attribute-type-0 +async def test_rtl26_annotations_attribute_type(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + channel = client.channels.get('test-RTL26') + + assert isinstance(channel.annotations, RealtimeAnnotations) + + +# UTS: realtime/unit/RTAN1a/publish-sends-annotation-0 +async def test_rtan1a_publish_sends_annotation(): + channel_name = f'test-RTAN1-publish-{random_id()}' + captured_messages = [] + + mock_ws = annotating_mock(channel_name, captured_messages=captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.annotations.publish( + 'msg-serial-1', Annotation(type='com.example.reaction', name='like')) + + annotation_pms = sent_annotations(captured_messages) + assert len(annotation_pms) == 1 + + annotation_pm = annotation_pms[0] + assert annotation_pm['channel'] == channel_name + assert len(annotation_pm['annotations']) == 1 + + ann = annotation_pm['annotations'][0] + assert ann['action'] == AnnotationAction.ANNOTATION_CREATE + assert ann['messageSerial'] == 'msg-serial-1' + assert ann['type'] == 'com.example.reaction' + assert ann['name'] == 'like' + + +# UTS: realtime/unit/RTAN1a/validates-type-required-1 +async def test_rtan1a_validates_type_required(): + channel_name = f'test-RTAN1a-validate-{random_id()}' + + mock_ws = annotating_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name) + + # RSAN1a3 does not mandate a code for the missing type + with pytest.raises(AblyException): + await channel.annotations.publish('msg-serial-1', Annotation(name='like')) + + +# UTS: realtime/unit/RTAN1a/encodes-data-json-2 +async def test_rtan1a_encodes_data_json(): + channel_name = f'test-RTAN1a-encode-{random_id()}' + captured_messages = [] + data = {'key': 'value', 'nested': {'a': 1}} + + mock_ws = annotating_mock(channel_name, captured_messages=captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.annotations.publish( + 'msg-serial-1', Annotation(type='com.example.data', data=data)) + + ann = sent_annotations(captured_messages)[0]['annotations'][0] + assert isinstance(ann['data'], str) + assert ann['encoding'] == 'json' + assert json.loads(ann['data']) == data + + +# UTS: realtime/unit/RTAN1b/publish-channel-state-0 +async def test_rtan1b_publish_channel_state(): + channel_name = f'test-RTAN1b-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel_name, + 'error': {'code': 40160, 'statusCode': 401, 'message': 'Not permitted'}, + }) + + mock_ws.on_message_from_client = on_message_from_client + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await channel.attach() + + assert channel.state == ChannelState.FAILED + + with pytest.raises(AblyException): + await channel.annotations.publish( + 'msg-serial-1', Annotation(type='com.example.reaction', name='like')) + + +# UTS: realtime/unit/RTAN1d/publish-ack-nack-0 +async def test_rtan1d_publish_ack_nack(): + # ACK case: the publish resolves + ack_channel_name = f'test-RTAN1d-ack-{random_id()}' + ack_mock = annotating_mock(ack_channel_name) + ack_channel = await attached_channel(ack_mock, ack_channel_name) + + await ack_channel.annotations.publish( + 'msg-serial-1', Annotation(type='com.example.reaction', name='like')) + + # NACK case: the publish rejects with the error the NACK carried + nack_channel_name = f'test-RTAN1d-nack-{random_id()}' + nack_mock = annotating_mock( + nack_channel_name, answer=lambda msg: nack(msg, 40160, 'Not permitted')) + nack_channel = await attached_channel(nack_mock, nack_channel_name) + + with pytest.raises(AblyException) as excinfo: + await nack_channel.annotations.publish( + 'msg-serial-1', Annotation(type='com.example.reaction', name='like')) + + assert excinfo.value.code == 40160 + + +# UTS: realtime/unit/RTAN2a/delete-sends-annotation-0 +async def test_rtan2a_delete_sends_annotation(): + channel_name = f'test-RTAN2-delete-{random_id()}' + captured_messages = [] + + mock_ws = annotating_mock(channel_name, captured_messages=captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.annotations.delete( + 'msg-serial-1', Annotation(type='com.example.reaction', name='like')) + + ann = sent_annotations(captured_messages)[0]['annotations'][0] + assert ann['action'] == AnnotationAction.ANNOTATION_DELETE + assert ann['messageSerial'] == 'msg-serial-1' + assert ann['type'] == 'com.example.reaction' + assert ann['name'] == 'like' + + +# UTS: realtime/unit/RTAN4a/subscribe-delivers-annotations-0 +async def test_rtan4a_subscribe_delivers_annotations(): + channel_name = f'test-RTAN4-subscribe-{random_id()}' + received_annotations = [] + + mock_ws = annotating_mock(channel_name, flags=SUBSCRIBE_FLAGS) + channel = await attached_channel(mock_ws, channel_name) + + await channel.annotations.subscribe(recorder(received_annotations)) + + mock_ws.send_to_client(annotation_protocol_message(channel_name, [ + { + '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, + }, + ])) + await poll_until(lambda: len(received_annotations) == 2, + description='both annotations are delivered') + + ann1 = received_annotations[0] + assert isinstance(ann1, Annotation) + assert ann1.id == 'ann-1' + assert ann1.action == AnnotationAction.ANNOTATION_CREATE + assert ann1.type == 'com.example.reaction' + assert ann1.name == 'like' + assert ann1.client_id == 'user-1' + assert ann1.serial == 'ann-serial-1' + assert ann1.message_serial == 'msg-serial-1' + assert ann1.timestamp == 1700000000000 + + ann2 = received_annotations[1] + assert ann2.name == 'heart' + assert ann2.client_id == 'user-2' + + +# UTS: realtime/unit/RTAN4c/subscribe-type-filter-0 +async def test_rtan4c_subscribe_type_filter(): + channel_name = f'test-RTAN4c-filter-{random_id()}' + reaction_annotations = [] + + mock_ws = annotating_mock(channel_name, flags=SUBSCRIBE_FLAGS) + channel = await attached_channel(mock_ws, channel_name) + + await channel.annotations.subscribe('com.example.reaction', recorder(reaction_annotations)) + + mock_ws.send_to_client(annotation_protocol_message(channel_name, [ + { + '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, + }, + ])) + await poll_until(lambda: len(reaction_annotations) == 2, + description='both reaction annotations are delivered') + await settle() + + assert len(reaction_annotations) == 2 + assert reaction_annotations[0].name == 'like' + assert reaction_annotations[1].name == 'heart' + + +# UTS: realtime/unit/RTAN4d/subscribe-implicit-attach-0 +async def test_rtan4d_subscribe_implicit_attach(): + channel_name = f'test-RTAN4d-attach-{random_id()}' + + mock_ws = annotating_mock(channel_name, flags=SUBSCRIBE_FLAGS) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.annotations.subscribe(lambda annotation: None) + + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTAN4e/subscribe-warns-no-mode-0 +async def test_rtan4e_subscribe_warns_no_mode(caplog): + channel_name = f'test-RTAN4e-warn-{random_id()}' + + # The ATTACHED grants PUBLISH alone, leaving ANNOTATION_SUBSCRIBE out + mock_ws = annotating_mock(channel_name, flags=Flag.PUBLISH) + channel = await attached_channel(mock_ws, channel_name) + + with caplog.at_level('WARNING', logger='ably.realtime.annotations'): + await channel.annotations.subscribe(lambda annotation: None) + + warnings = [record.getMessage() for record in caplog.records if record.levelname == 'WARNING'] + assert any('ANNOTATION_SUBSCRIBE' in message for message in warnings) + + +# UTS: realtime/unit/RTAN4e1/no-warn-unattached-0 +async def test_rtan4e1_no_warn_unattached(caplog): + # The specification leaves the channel unattached with + # `RealtimeChannelOptions(attachOnSubscribe: false)`. `annotations.subscribe` always + # attaches in ably-python, so the subscribe is run as a task against a server which + # never confirms the attach; the channel stays unattached exactly as the + # specification requires, and the mode check must not run. + channel_name = f'test-RTAN4e1-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + with caplog.at_level('WARNING', logger='ably.realtime.annotations'): + subscribing = asyncio.ensure_future(channel.annotations.subscribe(lambda annotation: None)) + await poll_until(lambda: channel.state == ChannelState.ATTACHING, + description='the implicit attach is in flight') + await settle() + + assert channel.state != ChannelState.ATTACHED + + warnings = [record.getMessage() for record in caplog.records if record.levelname == 'WARNING'] + assert not any('ANNOTATION_SUBSCRIBE' in message for message in warnings) + + subscribing.cancel() + + +# UTS: realtime/unit/RTAN5a/unsubscribe-removes-listeners-0 +async def test_rtan5a_unsubscribe_removes_listeners(): + channel_name = f'test-RTAN5-unsub-{random_id()}' + received_annotations = [] + + mock_ws = annotating_mock(channel_name, flags=SUBSCRIBE_FLAGS) + channel = await attached_channel(mock_ws, channel_name) + + listener = recorder(received_annotations) + await channel.annotations.subscribe(listener) + + mock_ws.send_to_client(annotation_protocol_message(channel_name, [{ + 'action': 0, + 'type': 'com.example.reaction', + 'name': 'like', + 'messageSerial': 'msg-serial-1', + 'serial': 'ann-serial-1', + 'timestamp': 1700000000000, + }])) + await poll_until(lambda: len(received_annotations) == 1, + description='the first annotation is delivered') + + channel.annotations.unsubscribe(listener) + + mock_ws.send_to_client(annotation_protocol_message(channel_name, [{ + 'action': 0, + 'type': 'com.example.reaction', + 'name': 'heart', + 'messageSerial': 'msg-serial-1', + 'serial': 'ann-serial-2', + 'timestamp': 1700000001000, + }])) + await settle() + + assert len(received_annotations) == 1 + assert received_annotations[0].name == 'like' + + +# UTS: realtime/unit/RTAN5a/unsubscribe-type-filter-1 +async def test_rtan5a_unsubscribe_type_filter(): + channel_name = f'test-RTAN5a-typed-{random_id()}' + reaction_received = [] + comment_received = [] + + mock_ws = annotating_mock(channel_name, flags=SUBSCRIBE_FLAGS) + channel = await attached_channel(mock_ws, channel_name) + + reaction_listener = recorder(reaction_received) + comment_listener = recorder(comment_received) + + await channel.annotations.subscribe('com.example.reaction', reaction_listener) + await channel.annotations.subscribe('com.example.comment', comment_listener) + + channel.annotations.unsubscribe('com.example.reaction', reaction_listener) + + mock_ws.send_to_client(annotation_protocol_message(channel_name, [ + { + '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, + }, + ])) + await poll_until(lambda: len(comment_received) == 1, + description='the comment annotation is delivered') + await settle() + + assert len(reaction_received) == 0 + assert len(comment_received) == 1 + assert comment_received[0].type == 'com.example.comment' diff --git a/test/uts/realtime/unit/channels/channel_attach_test.py b/test/uts/realtime/unit/channels/channel_attach_test.py new file mode 100644 index 00000000..e762bff1 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_attach_test.py @@ -0,0 +1,560 @@ +"""Derived from uts/realtime/unit/channels/channel_attach.md in ably/specification. + +Spec points: RTL4, RTL4a, RTL4b, RTL4c, RTL4c1, RTL4f, RTL4g, RTL4h, RTL4i, RTL4j, RTL4k, +RTL4l, RTL4m +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelmode import ChannelMode +from ably.types.channeloptions import ChannelOptions +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# The same connection with the idle timer switched off, so that a test driving a +# `FakeClock` sees only the timers it is interested in +CONNECTED_MESSAGE_NO_IDLE = connected_message( + 'connection-id', connectionKey='connection-key', maxIdleInterval=0) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def attached_message(channel_name, **fields): + """An ATTACHED for `channel_name`, as the server confirms an attach.""" + return {'action': ProtocolMessageAction.ATTACHED, 'channel': channel_name, **fields} + + +async def advance_to_connection_state(client, clock, state, step=5000, limit=40): + """Moves `clock` forward in steps until the connection reaches `state`.""" + for _ in range(limit): + if client.connection.state == state: + return + await clock.advance(step) + raise AssertionError( + f'Connection did not reach {state} within {step * limit}ms; it is {client.connection.state}') + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL4a/already-attached-noop-0 +async def test_rtl4a_already_attached_noop(): + channel_name = 'test-RTL4a' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + await channel.attach() + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + +# UTS: realtime/unit/RTL4h/attach-while-attaching-0 +async def test_rtl4h_attach_while_attaching(): + channel_name = 'test-RTL4h' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + first = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + second = asyncio.ensure_future(channel.attach()) + await settle() + + mock_ws.send_to_client(attached_message(channel_name)) + + await asyncio.wait_for(first, OPERATION_TIMEOUT) + await asyncio.wait_for(second, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + +# UTS: realtime/unit/RTL4h/attach-while-detaching-1 +async def test_rtl4h_attach_while_detaching(): + channel_name = 'test-RTL4h-detaching' + messages_from_client = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + messages_from_client.append(msg) + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + assert channel.state == ChannelState.ATTACHED + + detach_future = asyncio.ensure_future(channel.detach()) + await await_channel_state(channel, ChannelState.DETACHING, OPERATION_TIMEOUT) + + attach_future = asyncio.ensure_future(channel.attach()) + await settle() + + # RTL4h has the attach wait for the pending detach to complete, so that the + # DETACHED below ends the detach and only then is a second ATTACH sent. + # ably-python's attach requests ATTACHING straight away, which pre-empts the + # detach and makes the pending detach fail with 90000 + with pytest.raises(AblyException) as detach_error: + await asyncio.wait_for(detach_future, OPERATION_TIMEOUT) + # The 90000 the library passes lands in `status_code` and the 409 in `code`, + # the two being the other way round in `AblyException(message, status_code, code)` + assert detach_error.value.status_code == 90000 + + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + attach_messages = [m for m in messages_from_client if m.get('action') == ProtocolMessageAction.ATTACH] + assert len(attach_messages) == 2 + + +# UTS: realtime/unit/RTL4g/attach-from-failed-0 +async def test_rtl4g_attach_from_failed(): + channel_name = 'test-RTL4g' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + if len(attach_messages) == 1: + mock_ws.send_to_client({ + 'action': ProtocolMessageAction.ERROR, + 'channel': channel_name, + 'error': {'code': 40160, 'statusCode': 401, 'message': 'Denied'}, + }) + else: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.FAILED + assert channel.error_reason is not None + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert channel.error_reason is None + + +# UTS: realtime/unit/RTL4c/clears-error-reason-0 +async def test_rtl4c_clears_error_reason(): + channel_name = 'test-RTL4c-error-clear' + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=300, suspended_retry_timeout=2000) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_refused() + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED) + assert channel.state == ChannelState.SUSPENDED + assert channel.error_reason is not None + + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE) + await advance_to_connection_state(client, clock, ConnectionState.CONNECTED, step=2500, limit=10) + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert channel.error_reason is None + + +# UTS: realtime/unit/RTL4b/fails-connection-closed-0 +async def test_rtl4b_fails_connection_closed(): + channel_name = 'test-RTL4b-closed' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await client.close() + assert client.connection.state == ConnectionState.CLOSED + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert error.value.code is not None + assert channel.state != ChannelState.ATTACHED + + +# UTS: realtime/unit/RTL4b/fails-connection-failed-1 +async def test_rtl4b_fails_connection_failed(): + channel_name = 'test-RTL4b-failed' + + def on_connection_attempt(conn): + conn.respond_with_success(CONNECTED_MESSAGE) + conn.send_to_client_and_close({ + 'action': ProtocolMessageAction.ERROR, + 'error': {'code': 80000, 'statusCode': 500, 'message': 'Fatal error'}, + }) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert error.value is not None + assert channel.state != ChannelState.ATTACHED + + +# UTS: realtime/unit/RTL4b/fails-connection-suspended-2 +async def test_rtl4b_fails_connection_suspended(): + channel_name = 'test-RTL4b-suspended' + clock = FakeClock() + + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_refused()) + client = realtime_client(mock_ws, clock=clock, realtime_request_timeout=300, channel_retry_timeout=100) + channel = client.channels.get(channel_name) + + client.connect() + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert error.value is not None + assert channel.state != ChannelState.ATTACHED + + +# UTS: realtime/unit/RTL4i/queued-while-connecting-0 +async def test_rtl4i_queued_while_connecting(): + channel_name = 'test-RTL4i' + attach_messages = [] + + # A handler which does not answer the attempt holds the connection in + # CONNECTING, which is the specification's "delay connection response" + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: None) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTING) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHING + assert attach_messages == [] + attach_future.cancel() + + +# UTS: realtime/unit/RTL4i/completes-on-connected-1 +async def test_rtl4i_completes_on_connected(): + channel_name = 'test-RTL4i-connected' + attach_messages = [] + + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: None) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTING) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + assert attach_messages == [] + + await poll_until(lambda: mock_ws.connection_attempts, OPERATION_TIMEOUT, 'a connection attempt') + mock_ws.connection_attempts[0].respond_with_success(CONNECTED_MESSAGE) + + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + +# UTS: realtime/unit/RTL4c/sends-attach-message-1 +async def test_rtl4c_sends_attach_message(): + channel_name = 'test-RTL4c' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + states_during_attach = [] + + def on_attaching(change): + states_during_attach.append(channel.state) + + channel.on(ChannelState.ATTACHING, on_attaching) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert states_during_attach == [ChannelState.ATTACHING] + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + assert attach_messages[0]['action'] == ProtocolMessageAction.ATTACH + assert attach_messages[0]['channel'] == channel_name + + +# UTS: realtime/unit/RTL4c1/includes-channel-serial-0 +async def test_rtl4c1_includes_channel_serial(): + channel_name = 'test-RTL4c1' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name, channelSerial='serial-from-server-1')) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + set_options = asyncio.ensure_future( + channel.set_options(ChannelOptions(modes=[ChannelMode.SUBSCRIBE]))) + await settle() + + assert len(attach_messages) == 2 + assert attach_messages[0].get('channelSerial') is None + assert attach_messages[1].get('channelSerial') == 'serial-from-server-1' + set_options.cancel() + + +# UTS: realtime/unit/RTL4f/timeout-to-suspended-0 +async def test_rtl4f_timeout_to_suspended(): + channel_name = 'test-RTL4f' + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + client = realtime_client(mock_ws, clock=clock, realtime_request_timeout=100) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + await clock.advance(150) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.SUSPENDED + assert error.value is not None + + +# UTS: realtime/unit/RTL4k/includes-channel-params-0 +async def test_rtl4k_includes_channel_params(): + channel_name = 'test-RTL4k' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get( + channel_name, ChannelOptions(params={'rewind': '1', 'delta': 'vcdiff'})) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert len(attach_messages) == 1 + assert attach_messages[0]['params'] is not None + assert attach_messages[0]['params']['rewind'] == '1' + assert attach_messages[0]['params']['delta'] == 'vcdiff' + + +# UTS: realtime/unit/RTL4l/modes-encoded-as-flags-0 +async def test_rtl4l_modes_encoded_as_flags(): + channel_name = 'test-RTL4l' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get( + channel_name, ChannelOptions(modes=[ChannelMode.PUBLISH, ChannelMode.SUBSCRIBE])) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert len(attach_messages) == 1 + assert attach_messages[0].get('flags') is not None + assert attach_messages[0]['flags'] & Flag.PUBLISH + assert attach_messages[0]['flags'] & Flag.SUBSCRIBE + + +# UTS: realtime/unit/RTL4m/modes-from-attached-0 +async def test_rtl4m_modes_from_attached(): + channel_name = 'test-RTL4m' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client( + attached_message(channel_name, flags=Flag.PUBLISH | Flag.SUBSCRIBE)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.modes is not None + assert ChannelMode.PUBLISH in channel.modes + assert ChannelMode.SUBSCRIBE in channel.modes + + +# UTS: realtime/unit/RTL4j/attach-resume-flag-not-set-0 +@deviation +async def test_rtl4j_attach_resume_flag_not_set(): + channel_name = 'test-RTL4j' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + set_options = asyncio.ensure_future(channel.set_options(ChannelOptions(params={'rewind': '1'}))) + await settle() + + assert len(attach_messages) == 2 + assert not attach_messages[0].get('flags', 0) & Flag.ATTACH_RESUME + assert not attach_messages[1].get('flags', 0) & Flag.ATTACH_RESUME + set_options.cancel() diff --git a/test/uts/realtime/unit/channels/channel_attributes_test.py b/test/uts/realtime/unit/channels/channel_attributes_test.py new file mode 100644 index 00000000..78d09e00 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_attributes_test.py @@ -0,0 +1,197 @@ +"""Derived from uts/realtime/unit/channels/channel_attributes.md in ably/specification. + +Spec points: RTL4c, RTL23, RTL24 +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + realtime_client, +) +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE, + MockWebSocket, + attached_message, + server_detached_message, +) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def channel_error_message(channel, code, message, status_code): + """An ERROR message scoped to a channel, which RTN15i routes to that channel.""" + return { + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL23/name-attribute-0 +async def test_rtl23_name_attribute(): + client = realtime_client() + + 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' + + +# UTS: realtime/unit/RTL24/error-reason-channel-error-0 +async def test_rtl24_error_reason_channel_error(): + channel_name = 'test-RTL24-error' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=0)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.error_reason is None + + mock_ws.send_to_client( + channel_error_message(channel_name, 90001, 'Channel error occurred', 500)) + + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + + assert channel.error_reason is not None + assert channel.error_reason.code == 90001 + assert channel.error_reason.status_code == 500 + assert channel.error_reason.message == 'Channel error occurred' + + +# UTS: realtime/unit/RTL24/error-reason-attach-failure-1 +@deviation +async def test_rtl24_error_reason_attach_failure(): + # DEVIATION: an attach rejected with a DETACHED carrying an error leaves `error_reason` + # unset. `_on_message` (`ably/realtime/channel.py:735`) answers a DETACHED received while + # ATTACHING with `_notify_state(ChannelState.SUSPENDED)` and no reason, discarding the + # error. `attach()` then reaches `raise state_change.reason` (`:150`) with `reason` None, + # which raises `TypeError: exceptions must derive from BaseException` rather than the + # AblyException RTL24 describes. + channel_name = 'test-RTL24-attach-fail' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client( + server_detached_message(channel_name, 40160, 'Permission denied', status_code=401)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.error_reason is not None + assert channel.error_reason.code == 40160 + assert channel.error_reason.status_code == 401 + + +# UTS: realtime/unit/RTL4c/error-cleared-on-attach-0 +@deviation +async def test_rtl4c_error_cleared_on_attach(): + # DEVIATION: as above, the error on the DETACHED which rejects the first attach is + # discarded, so `error_reason` is never set and the attach raises TypeError rather than + # an AblyException. The clearing half of RTL4c is covered by + # `test_rtl4c_error_cleared_preserved_detach`, which sets the error with an ERROR message. + channel_name = 'test-RTL24-clear-attach' + attach_count = 0 + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + nonlocal attach_count + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_count += 1 + if attach_count == 1: + mock_ws.send_to_client( + server_detached_message(channel_name, 50000, 'Temporary error', status_code=500)) + else: + mock_ws.send_to_client(attached_message(channel_name, flags=0)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.error_reason is not None + assert channel.error_reason.code == 50000 + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert channel.error_reason is None + + +# UTS: realtime/unit/RTL4c/error-cleared-preserved-detach-1 +async def test_rtl4c_error_cleared_preserved_detach(): + channel_name = 'test-RTL24-clear-detach' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=0)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.DETACHED), 'channel': channel_name}) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + mock_ws.send_to_client(channel_error_message(channel_name, 90002, 'Channel error', 500)) + + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + + assert channel.error_reason is not None + assert channel.error_reason.code == 90002 + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.error_reason is None + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert channel.error_reason is None diff --git a/test/uts/realtime/unit/channels/channel_connection_state_test.py b/test/uts/realtime/unit/channels/channel_connection_state_test.py new file mode 100644 index 00000000..b2b65f50 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_connection_state_test.py @@ -0,0 +1,554 @@ +"""Derived from uts/realtime/unit/channels/channel_connection_state.md in ably/specification. + +Spec points: RTL3, RTL3a, RTL3b, RTL3c, RTL3d, RTL3e, RTL4c1 +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, advance_to_connection_state, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE_NO_IDLE, + MockWebSocket, + attached_message, + connected_message, + contains_in_order, + detached_message, +) + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + +# A fatal connection-level ERROR, which takes the connection to FAILED +ACCOUNT_DISABLED = { + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': 40198, 'statusCode': 403, 'message': 'Account disabled'}, +} + + +def echo_attach_and_detach(mock_ws, attach_messages=None, **attached_fields): + """A handler confirming each ATTACH with an ATTACHED and each DETACH with a DETACHED.""" + def on_message_from_client(msg): + action = msg.get('action') + if action == ProtocolMessageAction.ATTACH: + if attach_messages is not None: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(msg.get('channel'), **attached_fields)) + elif action == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(msg.get('channel'))) + return on_message_from_client + + +def swallow_attach(mock_ws, attach_messages=None): + """A handler which records ATTACH messages and never answers them.""" + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH and attach_messages is not None: + attach_messages.append(msg) + return on_message_from_client + + +def record_channel_states(channel): + """Collects the ChannelStateChange objects `channel` emits from now on.""" + changes = [] + + def record(change): + changes.append(change) + + channel.on(record) + return changes + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +async def drop_transport(client, mock_ws): + """Ends the transport and leaves the reconnection attempt unanswered. + + RTN15a reconnects immediately after a drop from CONNECTED, so DISCONNECTED + is passed through rather than settled in; the connection then waits in + CONNECTING on an attempt nothing answers. The specification's `AWAIT_STATE + disconnected` becomes an assertion on the recorded sequence. + """ + connection_states = [] + + def record(change): + connection_states.append(change.current) + + client.connection.on(record) + mock_ws.on_connection_attempt = lambda conn: None + mock_ws.simulate_disconnect() + await poll_until( + lambda: ConnectionState.DISCONNECTED in connection_states, + OPERATION_TIMEOUT, + 'the connection to reach DISCONNECTED', + ) + await settle() + + +async def reconnect_transport(client, mock_ws): + """Ends the transport and waits for the connection to be re-established.""" + reconnections = [] + + def record(change): + reconnections.append(change) + + client.connection.on(ConnectionState.CONNECTED, record) + mock_ws.simulate_disconnect() + await poll_until(lambda: reconnections, OPERATION_TIMEOUT, 'the connection to be re-established') + + +# UTS: realtime/unit/RTL3e/disconnected-attached-noop-0 +async def test_rtl3e_disconnected_attached_noop(): + channel_name = 'test-RTL3e-attached' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + channel_state_changes = record_channel_states(channel) + + await drop_transport(client, mock_ws) + + assert channel.state == ChannelState.ATTACHED + assert len(channel_state_changes) == 0 + + +# UTS: realtime/unit/RTL3e/disconnected-attaching-noop-1 +async def test_rtl3e_disconnected_attaching_noop(): + channel_name = 'test-RTL3e-attaching' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = swallow_attach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + channel_state_changes = record_channel_states(channel) + + await drop_transport(client, mock_ws) + + assert channel.state == ChannelState.ATTACHING + assert len(channel_state_changes) == 0 + attach_future.cancel() + + +# UTS: realtime/unit/RTL3a/failed-attached-to-failed-0 +@deviation +async def test_rtl3a_failed_attached_to_failed(): + channel_name = 'test-RTL3a-attached' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + channel_state_changes = record_channel_states(channel) + + mock_ws.send_to_client_and_close(ACCOUNT_DISABLED) + await await_connection_state(client, ConnectionState.FAILED) + await settle() + + assert channel.state == ChannelState.FAILED + assert channel.error_reason is not None + assert channel.error_reason.code == 40198 + + assert len(channel_state_changes) >= 1 + failed_change = next( + (c for c in channel_state_changes if c.current == ChannelState.FAILED), None) + assert failed_change is not None + assert failed_change.previous == ChannelState.ATTACHED + assert failed_change.reason is not None + assert failed_change.reason.code == 40198 + + +# UTS: realtime/unit/RTL3a/failed-attaching-to-failed-1 +@deviation +async def test_rtl3a_failed_attaching_to_failed(): + channel_name = 'test-RTL3a-attaching' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = swallow_attach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + channel_state_changes = record_channel_states(channel) + + mock_ws.send_to_client_and_close(ACCOUNT_DISABLED) + await await_connection_state(client, ConnectionState.FAILED) + await settle() + + # Asserted ahead of the pending attach, which the specification has fail: + # the attach only ends once the channel state does change + assert channel.state == ChannelState.FAILED + assert channel.error_reason is not None + + with pytest.raises(AblyException): + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + failed_change = next( + (c for c in channel_state_changes if c.current == ChannelState.FAILED), None) + assert failed_change is not None + assert failed_change.previous == ChannelState.ATTACHING + + +# UTS: realtime/unit/RTL3a/other-states-unaffected-2 +async def test_rtl3a_other_states_unaffected(): + initialized_channel_name = 'test-RTL3a-init' + detached_channel_name = 'test-RTL3a-detached' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + initialized_channel = client.channels.get(initialized_channel_name) + detached_channel = client.channels.get(detached_channel_name) + + assert initialized_channel.state == ChannelState.INITIALIZED + + await asyncio.wait_for(detached_channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(detached_channel.detach(), OPERATION_TIMEOUT) + assert detached_channel.state == ChannelState.DETACHED + + init_changes = record_channel_states(initialized_channel) + detached_changes = record_channel_states(detached_channel) + + mock_ws.send_to_client_and_close(ACCOUNT_DISABLED) + await await_connection_state(client, ConnectionState.FAILED) + await settle() + + assert initialized_channel.state == ChannelState.INITIALIZED + assert detached_channel.state == ChannelState.DETACHED + assert len(init_changes) == 0 + assert len(detached_changes) == 0 + + +# UTS: realtime/unit/RTL3b/closed-attached-to-detached-0 +async def test_rtl3b_closed_attached_to_detached(): + channel_name = 'test-RTL3b' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + channel_state_changes = record_channel_states(channel) + + await asyncio.wait_for(client.close(), OPERATION_TIMEOUT) + assert client.connection.state == ConnectionState.CLOSED + + assert channel.state == ChannelState.DETACHED + + detached_change = next( + (c for c in channel_state_changes if c.current == ChannelState.DETACHED), None) + assert detached_change is not None + assert detached_change.previous == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTL3b/closed-attaching-to-detached-1 +async def test_rtl3b_closed_attaching_to_detached(): + channel_name = 'test-RTL3b-attaching' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = swallow_attach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + channel_state_changes = record_channel_states(channel) + + await asyncio.wait_for(client.close(), OPERATION_TIMEOUT) + assert client.connection.state == ConnectionState.CLOSED + + # The specification has the pending attach fail. ably-python's `attach()` + # raises only for SUSPENDED and FAILED, so the DETACHED which RTL3b brings + # about resolves it instead. See deviations.md + assert await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) is None + + assert channel.state == ChannelState.DETACHED + + detached_change = next( + (c for c in channel_state_changes if c.current == ChannelState.DETACHED), None) + assert detached_change is not None + assert detached_change.previous == ChannelState.ATTACHING + + +# UTS: realtime/unit/RTL3c/suspended-attached-to-suspended-0 +async def test_rtl3c_suspended_attached_to_suspended(): + channel_name = 'test-RTL3c' + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + # A refused attempt is only given up on when the connecting transition timer + # expires, so a short request timeout keeps each retry cycle short + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=1000, realtime_request_timeout=300) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + channel_state_changes = record_channel_states(channel) + + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_refused() + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED, step=5000, limit=30) + + assert channel.state == ChannelState.SUSPENDED + + suspended_change = next( + (c for c in channel_state_changes if c.current == ChannelState.SUSPENDED), None) + assert suspended_change is not None + assert suspended_change.previous == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTL3c/suspended-attaching-to-suspended-1 +async def test_rtl3c_suspended_attaching_to_suspended(): + channel_name = 'test-RTL3c-attaching' + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + mock_ws.on_message_from_client = swallow_attach(mock_ws) + # The channel's attach timeout and the connection's transition timeout are + # the same option, so it is set beyond the connection state TTL: otherwise + # RTL4f suspends the channel on its own before the connection gets there, + # and the transition RTL3c is about never happens + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=1000, realtime_request_timeout=300000) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + channel_state_changes = record_channel_states(channel) + + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_refused() + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED, step=5000, limit=30) + + assert channel.state == ChannelState.SUSPENDED + + suspended_change = next( + (c for c in channel_state_changes if c.current == ChannelState.SUSPENDED), None) + assert suspended_change is not None + assert suspended_change.previous == ChannelState.ATTACHING + + with pytest.raises(AblyException): + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + +# UTS: realtime/unit/RTL3d/reattach-attached-with-serial-0 +async def test_rtl3d_reattach_attached_with_serial(): + channel_name = 'test-RTL3d-attached' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach( + mock_ws, attach_messages, channelSerial='serial-001') + client = await connected_client(mock_ws, disconnected_retry_timeout=100) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + channel_state_changes = record_channel_states(channel) + + await reconnect_transport(client, mock_ws) + # The channel is ATTACHED throughout the re-attach's opening moments, so the + # second ATTACH is what marks the re-attach having started + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'a second ATTACH') + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 2 + + assert attach_messages[1].get('channelSerial') == 'serial-001' + + observed = [c.current for c in channel_state_changes] + assert contains_in_order(observed, [ChannelState.ATTACHING, ChannelState.ATTACHED]) + + +# UTS: realtime/unit/RTL3d/reattach-suspended-channels-1 +async def test_rtl3d_reattach_suspended_channels(): + channel_name = 'test-RTL3d-suspended' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws, attach_messages) + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=1000, suspended_retry_timeout=2000, + realtime_request_timeout=300) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_refused() + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED, step=5000, limit=30) + assert channel.state == ChannelState.SUSPENDED + + channel_state_changes = record_channel_states(channel) + + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE) + + await advance_to_connection_state(client, clock, ConnectionState.CONNECTED, step=2500, limit=10) + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) >= 2 + + observed = [c.current for c in channel_state_changes] + assert contains_in_order(observed, [ChannelState.ATTACHING, ChannelState.ATTACHED]) + + +# UTS: realtime/unit/RTL3d/init-detached-not-reattached-2 +async def test_rtl3d_init_detached_not_reattached(): + initialized_channel_name = 'test-RTL3d-init' + detached_channel_name = 'test-RTL3d-detached' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws, attach_messages) + client = await connected_client(mock_ws, disconnected_retry_timeout=100) + initialized_channel = client.channels.get(initialized_channel_name) + detached_channel = client.channels.get(detached_channel_name) + + assert initialized_channel.state == ChannelState.INITIALIZED + + await asyncio.wait_for(detached_channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(detached_channel.detach(), OPERATION_TIMEOUT) + assert detached_channel.state == ChannelState.DETACHED + + attach_count_before = len(attach_messages) + + init_changes = record_channel_states(initialized_channel) + detached_changes = record_channel_states(detached_channel) + + await reconnect_transport(client, mock_ws) + await settle() + + assert initialized_channel.state == ChannelState.INITIALIZED + assert detached_channel.state == ChannelState.DETACHED + assert len(init_changes) == 0 + assert len(detached_changes) == 0 + + new_attach_channels = [m.get('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 + + +# UTS: realtime/unit/RTL3d/multiple-channels-reattached-3 +async def test_rtl3d_multiple_channels_reattached(): + channel1_name = 'test-RTL3d-multi1' + channel2_name = 'test-RTL3d-multi2' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws, attach_messages) + client = await connected_client(mock_ws, disconnected_retry_timeout=100) + channel1 = client.channels.get(channel1_name) + channel2 = client.channels.get(channel2_name) + + await asyncio.wait_for(channel1.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel2.attach(), OPERATION_TIMEOUT) + assert channel1.state == ChannelState.ATTACHED + assert channel2.state == ChannelState.ATTACHED + + attach_count_before = len(attach_messages) + + await reconnect_transport(client, mock_ws) + await poll_until( + lambda: len(attach_messages) == attach_count_before + 2, + OPERATION_TIMEOUT, + 'both channels to re-attach', + ) + await await_channel_state(channel1, ChannelState.ATTACHED, OPERATION_TIMEOUT) + await await_channel_state(channel2, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + assert channel1.state == ChannelState.ATTACHED + assert channel2.state == ChannelState.ATTACHED + + new_attach_channels = [m.get('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/test/uts/realtime/unit/channels/channel_delta_decoding_test.py b/test/uts/realtime/unit/channels/channel_delta_decoding_test.py new file mode 100644 index 00000000..fa71c466 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_delta_decoding_test.py @@ -0,0 +1,527 @@ +"""Derived from uts/realtime/unit/channels/channel_delta_decoding.md in ably/specification. + +Spec points: RTL18, RTL18a, RTL18b, RTL18c, RTL19, RTL19a, RTL19b, RTL19c, RTL20, RTL21, +PC3, PC3a + +The mock vcdiff encoder and decoder of uts/realtime/unit/helpers/mock_vcdiff.md are +built below rather than in `test/uts/helpers`, since only this specification uses them. +Only the binary form of the encoder is implemented: ably-python's plugin seam is the +binary-only `VCDiffDecoder` (VD2a), so the string overloads the specification offers as +a test-setup convenience have nothing to attach to. `encode` takes text or bytes and +always produces the binary delta the decoder reads. + +Deltas travel as raw bytes, which the default msgpack protocol carries directly; only +the RTL19a test, which is about the base64 step, base64-encodes its payloads. + +Where a message's encoding ends at `vcdiff` there is no `utf-8` step to turn the delta +result back into text, so the SDK delivers bytes and the assertions below are written +against bytes where the specification writes a string literal. See +[deviations.md](../../../deviations.md). +""" + +import base64 +import uuid + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.options import VCDiffDecoder +from test.uts.helpers.client import await_connection_state, poll_until, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + MockWebSocket, + attached_message, + connected_message, + message_protocol_message, +) + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def _to_bytes(value): + return value.encode('utf-8') if isinstance(value, str) else bytes(value) + + +def _base64url_encode(data): + return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=') + + +def _base64url_decode(text): + return base64.urlsafe_b64decode(text + '=' * (-len(text) % 4)) + + +class MockVCDiffEncoder: + """Builds the deterministic delta the mock decoder reads. + + A delta is `base64url(base) + '/' + base64url(value)` as UTF-8 bytes, so the base + the delta was built against is carried inside it and a wrong stored base payload + shows up as a decode failure rather than as a wrong result. + """ + + def encode(self, base, value): + return f'{_base64url_encode(_to_bytes(base))}/{_base64url_encode(_to_bytes(value))}'.encode() + + +class MockVCDiffDecoder(VCDiffDecoder): + """The VD2a decoder side of the mock, validating the base it is handed. + + `on_decode` is called with the arguments the SDK passed before the delta is read, + which is how a test inspects them or makes a particular call fail. + """ + + def __init__(self, on_decode=None): + self.on_decode = on_decode + self.calls = [] + + def decode(self, delta: bytes, base: bytes) -> bytes: + self.calls.append({'delta': delta, 'base': base}) + if self.on_decode is not None: + self.on_decode(delta, base) + parts = delta.decode('utf-8').split('/') + if len(parts) != 2: + raise ValueError('Invalid delta format') + if _base64url_decode(parts[0]) != base: + raise ValueError('Base mismatch: expected base does not match delta') + return _base64url_decode(parts[1]) + + +class FailingMockVCDiffDecoder(VCDiffDecoder): + """A decoder which never decodes anything, for the RTL18 recovery tests.""" + + def decode(self, delta: bytes, base: bytes) -> bytes: + raise ValueError('Simulated vcdiff decode failure') + + +def delta_message(id, delta, from_id, encoding='vcdiff', **fields): + """A message carrying `delta` as a vcdiff delta from the message `from_id`.""" + return { + 'id': id, + 'data': delta, + 'encoding': encoding, + 'extras': {'delta': {'from': from_id, 'format': 'vcdiff'}}, + **fields, + } + + +def attaching_mock(channel_name, attach_messages=None, attach_replies=None): + """A mock which connects and confirms attaches for `channel_name`. + + `attach_replies` caps how many ATTACHes are answered, so that a test can leave a + recovery attach outstanding; every ATTACH is answered when it is None. + """ + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + if attach_messages is not None: + attach_messages.append(msg) + if attach_replies is None or len(mock_ws.attaches_answered) < attach_replies: + mock_ws.attaches_answered.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.attaches_answered = [] + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def attached_channel(mock_ws, channel_name, **kwargs): + """A channel attached over `mock_ws`, which every test here starts from.""" + client = realtime_client(mock_ws, **kwargs) + channel = client.channels.get(channel_name) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + return channel + + +def recorder(received): + """A subscribe listener appending each message it is given to `received`.""" + def record(message): + received.append(message) + return record + + +def state_recorder(state_changes): + """A channel listener appending each state change to `state_changes`.""" + def record(state_change): + state_changes.append(state_change) + return record + + +# UTS: realtime/unit/RTL21/ascending-index-order-0 +async def test_rtl21_ascending_index_order(): + channel_name = f'test-RTL21-order-{random_id()}' + encoder = MockVCDiffEncoder() + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + await channel.subscribe(recorder(received_messages)) + + base_data = 'first message' + second_data = 'second message' + third_data = 'third message' + + # The second and third messages are deltas from the ones before them, so they only + # decode if the array is processed in index order + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'serial:0', 'data': base_data, 'encoding': None}, + delta_message('serial:1', encoder.encode(base_data, second_data), from_id='serial:0'), + delta_message('serial:2', encoder.encode(second_data, third_data), from_id='serial:1'), + ], id='serial:0')) + await poll_until(lambda: len(received_messages) == 3, description='all three messages arrive') + + assert received_messages[0].data == 'first message' + assert received_messages[1].data == b'second message' + assert received_messages[2].data == b'third message' + + +# UTS: realtime/unit/RTL19b/stores-base-payload-0 +async def test_rtl19b_stores_base_payload(): + channel_name = f'test-RTL19b-base-{random_id()}' + encoder = MockVCDiffEncoder() + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + await channel.subscribe(recorder(received_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'base payload', 'encoding': None}, + ], id='msg-1:0')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + delta = encoder.encode('base payload', 'updated payload') + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', delta, from_id='msg-1:0'), + ], id='msg-2:0')) + await poll_until(lambda: len(received_messages) == 2, description='the delta message arrives') + + assert received_messages[0].data == 'base payload' + assert received_messages[1].data == b'updated payload' + + +# UTS: realtime/unit/RTL19b/json-wire-form-base-1 +async def test_rtl19b_json_wire_form_base(): + channel_name = f'test-RTL19b-json-base-{random_id()}' + encoder = MockVCDiffEncoder() + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + await channel.subscribe(recorder(received_messages)) + + json_string = '{"foo":"bar","count":1}' + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': json_string, 'encoding': 'json'}, + ], id='msg-1:0')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + # The delta is computed against the JSON string on the wire, not the parsed object, + # so it only decodes if the wire form was stored as the base payload + new_json_string = '{"foo":"baz","count":2}' + delta = encoder.encode(json_string, new_json_string) + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', delta, from_id='msg-1:0', encoding='utf-8/vcdiff'), + ], id='msg-2:0')) + await poll_until(lambda: len(received_messages) == 2, description='the delta message arrives') + + assert received_messages[0].data == {'foo': 'bar', 'count': 1} + assert received_messages[1].data == new_json_string + + +# UTS: realtime/unit/RTL19a/base64-decoded-before-store-0 +async def test_rtl19a_base64_decoded_before_store(): + channel_name = f'test-RTL19a-base64-{random_id()}' + encoder = MockVCDiffEncoder() + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + await channel.subscribe(recorder(received_messages)) + + base_binary = bytes([0x48, 0x65, 0x6C, 0x6C, 0x6F]) + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'SGVsbG8=', 'encoding': 'base64'}, + ], id='msg-1:0')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + new_binary = bytes([0x57, 0x6F, 0x72, 0x6C, 0x64]) + delta = encoder.encode(base_binary, new_binary) + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', base64.b64encode(delta).decode('ascii'), + from_id='msg-1:0', encoding='vcdiff/base64'), + ], id='msg-2:0')) + await poll_until(lambda: len(received_messages) == 2, description='the delta message arrives') + + assert received_messages[0].data == base_binary + assert received_messages[1].data == new_binary + + +# UTS: realtime/unit/RTL19c/delta-result-becomes-base-0 +async def test_rtl19c_delta_result_becomes_base(): + channel_name = f'test-RTL19c-chain-{random_id()}' + encoder = MockVCDiffEncoder() + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + await channel.subscribe(recorder(received_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'value-A', 'encoding': None}, + ], id='msg-1:0')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', encoder.encode('value-A', 'value-B'), from_id='msg-1:0'), + ], id='msg-2:0')) + await poll_until(lambda: len(received_messages) == 2, description='the first delta arrives') + + # The third message is a delta from the second, so it only decodes if the result of + # the first delta became the base payload + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-3:0', encoder.encode('value-B', 'value-C'), from_id='msg-2:0'), + ], id='msg-3:0')) + await poll_until(lambda: len(received_messages) == 3, description='the second delta arrives') + + assert received_messages[0].data == 'value-A' + assert received_messages[1].data == b'value-B' + assert received_messages[2].data == b'value-C' + + +# UTS: realtime/unit/RTL20/mismatched-id-triggers-recovery-0 +async def test_rtl20_mismatched_id_triggers_recovery(): + channel_name = f'test-RTL20-mismatch-{random_id()}' + encoder = MockVCDiffEncoder() + state_changes = [] + attach_messages = [] + + mock_ws = attaching_mock(channel_name, attach_messages) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + channel.on(state_recorder(state_changes)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'base payload', 'encoding': None}, + ], id='msg-1:0', channelSerial='serial-1')) + await settle() + + state_changes.clear() + initial_attach_count = len(attach_messages) + + delta = encoder.encode('base payload', 'new payload') + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', delta, from_id='msg-999:0'), + ], id='msg-2:0')) + await poll_until(lambda: len(attach_messages) > initial_attach_count, + description='a recovery ATTACH is sent') + + # RTL18c: the recovery ATTACH carries the serial of the last message that decoded + recovery_attach = attach_messages[-1] + assert recovery_attach['channelSerial'] == 'serial-1' + + attaching = [change for change in state_changes if change.current == ChannelState.ATTACHING] + assert len(attaching) == 1 + assert attaching[0].reason.code == 40018 + + +# UTS: realtime/unit/RTL20/last-id-updated-on-decode-1 +async def test_rtl20_last_id_updated_on_decode(): + channel_name = f'test-RTL20-id-update-{random_id()}' + encoder = MockVCDiffEncoder() + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder()) + await channel.subscribe(recorder(received_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'serial:0', 'data': 'first', 'encoding': None}, + {'id': 'serial:1', 'data': 'second', 'encoding': None}, + ], id='serial:0')) + await poll_until(lambda: len(received_messages) == 2, description='both messages arrive') + + # The delta references the last message of the previous array, so it only decodes if + # that id was the one stored + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', encoder.encode('second', 'third'), from_id='serial:1'), + ], id='msg-2:0')) + await poll_until(lambda: len(received_messages) == 3, description='the delta message arrives') + + assert received_messages[0].data == 'first' + assert received_messages[1].data == 'second' + assert received_messages[2].data == b'third' + + +# UTS: realtime/unit/PC3/vcdiff-plugin-decodes-0 +async def test_pc3_vcdiff_plugin_decodes(): + channel_name = f'test-PC3-decode-{random_id()}' + encoder = MockVCDiffEncoder() + decode_calls = [] + + def on_decode(delta, base): + decode_calls.append({'delta': delta, 'base': base}) + + received_messages = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel( + mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder(on_decode=on_decode)) + await channel.subscribe(recorder(received_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'hello world', 'encoding': None}, + ], id='msg-1:0')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + delta = encoder.encode('hello world', 'goodbye world') + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', delta, from_id='msg-1:0'), + ], id='msg-2:0')) + await poll_until(lambda: len(received_messages) == 2, description='the delta message arrives') + + assert len(decode_calls) == 1 + # PC3a: a string base payload reaches the decoder as its UTF-8 bytes + assert decode_calls[0]['base'] == b'hello world' + assert decode_calls[0]['delta'] == delta + + assert received_messages[1].data == b'goodbye world' + + +# UTS: realtime/unit/PC3/no-plugin-fails-1 +@deviation +async def test_pc3_no_plugin_fails(): + channel_name = f'test-PC3-no-plugin-{random_id()}' + state_changes = [] + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name) + channel.on(state_recorder(state_changes)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-1:0', 'some-delta-data', from_id='msg-0:0'), + ], id='msg-1:0')) + await poll_until(lambda: channel.state == ChannelState.FAILED, + description='the channel fails for want of a vcdiff decoder') + + assert channel.error_reason.code == 40019 + + +# UTS: realtime/unit/RTL18/decode-failure-recovery-0 +async def test_rtl18_decode_failure_recovery(): + channel_name = f'test-RTL18-recovery-{random_id()}' + state_changes = [] + attach_messages = [] + received_messages = [] + + mock_ws = attaching_mock(channel_name, attach_messages) + channel = await attached_channel( + mock_ws, channel_name, vcdiff_decoder=FailingMockVCDiffDecoder()) + channel.on(state_recorder(state_changes)) + await channel.subscribe(recorder(received_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'base payload', 'encoding': None}, + ], id='msg-1:0', channelSerial='serial-100')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + state_changes.clear() + initial_attach_count = len(attach_messages) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', b'fake-delta-payload', from_id='msg-1:0'), + ], id='msg-2:0', channelSerial='serial-200')) + await poll_until(lambda: len(attach_messages) > initial_attach_count, + description='a recovery ATTACH is sent') + await settle() + + # RTL18b: the message the decoder could not read was discarded + assert len(received_messages) == 1 + assert received_messages[0].data == 'base payload' + + # RTL18c: the recovery ATTACH carries the serial of the last message that decoded + assert attach_messages[-1]['channelSerial'] == 'serial-100' + + attaching = [change for change in state_changes if change.current == ChannelState.ATTACHING] + assert len(attaching) == 1 + assert attaching[0].reason.code == 40018 + + +# UTS: realtime/unit/RTL18c/recovery-completes-on-attached-0 +async def test_rtl18c_recovery_completes_on_attached(): + channel_name = f'test-RTL18c-complete-{random_id()}' + received_messages = [] + decode_attempts = [] + + def fail_first(delta, base): + decode_attempts.append(delta) + if len(decode_attempts) == 1: + raise ValueError('Simulated decode failure') + + mock_ws = attaching_mock(channel_name) + channel = await attached_channel( + mock_ws, channel_name, vcdiff_decoder=MockVCDiffDecoder(on_decode=fail_first)) + await channel.subscribe(recorder(received_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'original base', 'encoding': None}, + ], id='msg-1:0', channelSerial='serial-1')) + await poll_until(lambda: len(received_messages) == 1, description='the base message arrives') + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', b'bad-delta', from_id='msg-1:0'), + ], id='msg-2:0', channelSerial='serial-2')) + await poll_until(lambda: len(decode_attempts) == 1, description='the delta fails to decode') + + # The server confirms the recovery ATTACH, which returns the channel to ATTACHED + await poll_until(lambda: channel.state == ChannelState.ATTACHED, + description='the channel recovers to ATTACHED') + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-3:0', 'data': 'fresh after recovery', 'encoding': None}, + ], id='msg-3:0', channelSerial='serial-3')) + await poll_until(lambda: len(received_messages) == 2, + description='the message sent after recovery arrives') + + assert channel.state == ChannelState.ATTACHED + # RTL18b: the failed delta was discarded, so only the base and the fresh message arrived + assert received_messages[0].data == 'original base' + assert received_messages[1].data == 'fresh after recovery' + + +# UTS: realtime/unit/RTL18/single-recovery-at-time-1 +async def test_rtl18_single_recovery_at_time(): + channel_name = f'test-RTL18-single-recovery-{random_id()}' + attach_messages = [] + + # Only the first ATTACH is answered, so the recovery attach stays outstanding + mock_ws = attaching_mock(channel_name, attach_messages, attach_replies=1) + channel = await attached_channel( + mock_ws, channel_name, vcdiff_decoder=FailingMockVCDiffDecoder()) + + initial_attach_count = len(attach_messages) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'id': 'msg-1:0', 'data': 'base', 'encoding': None}, + ], id='msg-1:0', channelSerial='serial-1')) + await settle() + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-2:0', b'bad-delta-1', from_id='msg-1:0'), + ], id='msg-2:0')) + await poll_until(lambda: channel.state == ChannelState.ATTACHING, + description='the first failure starts recovery') + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + delta_message('msg-3:0', b'bad-delta-2', from_id='msg-2:0'), + ], id='msg-3:0')) + await settle() + + assert len(attach_messages) - initial_attach_count == 1 diff --git a/test/uts/realtime/unit/channels/channel_detach_test.py b/test/uts/realtime/unit/channels/channel_detach_test.py new file mode 100644 index 00000000..90c8b397 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_detach_test.py @@ -0,0 +1,503 @@ +"""Derived from uts/realtime/unit/channels/channel_detach.md in ably/specification. + +Spec points: RTL5, RTL5a, RTL5b, RTL5d, RTL5e, RTL5f, RTL5i, RTL5j, RTL5k, RTL5l +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# The same connection with the idle timer switched off, so that a test driving a +# `FakeClock` sees only the timers it is interested in +CONNECTED_MESSAGE_NO_IDLE = connected_message( + 'connection-id', connectionKey='connection-key', maxIdleInterval=0) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def attached_message(channel_name, **fields): + """An ATTACHED for `channel_name`, as the server confirms an attach.""" + return {'action': ProtocolMessageAction.ATTACHED, 'channel': channel_name, **fields} + + +def detached_message(channel_name, **fields): + """A DETACHED for `channel_name`, as the server confirms a detach.""" + return {'action': ProtocolMessageAction.DETACHED, 'channel': channel_name, **fields} + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL5a/detach-initialized-noop-0 +async def test_rtl5a_detach_initialized_noop(): + channel_name = 'test-RTL5a' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + state_changes = [] + + def record(change): + state_changes.append(change) + + channel.on(record) + + assert channel.state == ChannelState.INITIALIZED + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.INITIALIZED + assert state_changes == [] + + +# UTS: realtime/unit/RTL5a/detach-already-detached-noop-1 +async def test_rtl5a_detach_already_detached_noop(): + channel_name = 'test-RTL5a-detached' + detach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 1 + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 1 + + +# UTS: realtime/unit/RTL5i/detach-while-detaching-0 +@deviation +async def test_rtl5i_detach_while_detaching(): + channel_name = 'test-RTL5i' + detach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + first = asyncio.ensure_future(channel.detach()) + await await_channel_state(channel, ChannelState.DETACHING, OPERATION_TIMEOUT) + + second = asyncio.ensure_future(channel.detach()) + await settle() + + mock_ws.send_to_client(detached_message(channel_name)) + + await asyncio.wait_for(first, OPERATION_TIMEOUT) + await asyncio.wait_for(second, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 1 + + +# UTS: realtime/unit/RTL5i/detach-while-attaching-1 +async def test_rtl5i_detach_while_attaching(): + channel_name = 'test-RTL5i-attaching' + messages_from_client = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + messages_from_client.append(msg) + if msg.get('action') == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + detach_future = asyncio.ensure_future(channel.detach()) + await settle() + + mock_ws.send_to_client(attached_message(channel_name)) + + # The specification allows the superseded attach either to resolve or to be + # rejected; ably-python resolves it on the DETACHING state change + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + await asyncio.wait_for(detach_future, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert len(messages_from_client) == 2 + assert messages_from_client[0]['action'] == ProtocolMessageAction.ATTACH + assert messages_from_client[1]['action'] == ProtocolMessageAction.DETACH + + +# UTS: realtime/unit/RTL5b/detach-failed-errors-0 +async def test_rtl5b_detach_failed_errors(): + channel_name = 'test-RTL5b' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({ + 'action': ProtocolMessageAction.ERROR, + 'channel': channel_name, + 'error': {'code': 40160, 'statusCode': 401, 'message': 'Not permitted'}, + }) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.FAILED + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + # The 90001 the library passes lands in `status_code` and the 400 in `code`, + # the two being the other way round in `AblyException(message, status_code, code)` + assert error.value.status_code == 90001 + assert channel.state == ChannelState.FAILED + + +# UTS: realtime/unit/RTL5j/detach-suspended-to-detached-0 +async def test_rtl5j_detach_suspended_to_detached(): + channel_name = 'test-RTL5j' + detach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, clock=clock, realtime_request_timeout=100) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + await clock.advance(150) + + with pytest.raises(AblyException): + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + assert channel.state == ChannelState.SUSPENDED + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert detach_messages == [] + + +# UTS: realtime/unit/RTL5l/detach-not-connected-immediate-0 +@deviation +async def test_rtl5l_detach_not_connected_immediate(): + channel_name = 'test-RTL5l' + detach_messages = [] + + # A handler which does not answer the attempt holds the connection in + # CONNECTING, which is the specification's "delay connection" + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: None) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTING) + + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert detach_messages == [] + attach_future.cancel() + + +# UTS: realtime/unit/RTL5l/detach-attached-when-disconnected-1 +@deviation +async def test_rtl5l_detach_attached_when_disconnected(): + channel_name = 'test-channel' + messages_sent = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=300, disconnected_retry_timeout=60000) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + def attach_responder(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = attach_responder + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + # The reconnect RTN15a starts straight after the drop is left unanswered, so + # that its transition timer returns the connection to a settled DISCONNECTED + mock_ws.on_connection_attempt = lambda conn: None + mock_ws.simulate_disconnect() + await settle() + await clock.advance(500) + assert client.connection.state == ConnectionState.DISCONNECTED + + def record(msg): + messages_sent.append(msg) + + mock_ws.on_message_from_client = record + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + detach_messages = [m for m in messages_sent if m.get('action') == ProtocolMessageAction.DETACH] + assert detach_messages == [] + + +# UTS: realtime/unit/RTL5d/normal-detach-flow-0 +async def test_rtl5d_normal_detach_flow(): + channel_name = 'test-RTL5d' + detach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + states_during_detach = [] + + def on_detaching(change): + states_during_detach.append(channel.state) + + channel.on(ChannelState.DETACHING, on_detaching) + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert states_during_detach == [ChannelState.DETACHING] + assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 1 + assert detach_messages[0]['action'] == ProtocolMessageAction.DETACH + assert detach_messages[0]['channel'] == channel_name + + +# UTS: realtime/unit/RTL5f/timeout-returns-previous-state-0 +async def test_rtl5f_timeout_returns_previous_state(): + channel_name = 'test-RTL5f' + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, clock=clock, realtime_request_timeout=100) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + detach_future = asyncio.ensure_future(channel.detach()) + await await_channel_state(channel, ChannelState.DETACHING, OPERATION_TIMEOUT) + + await clock.advance(150) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(detach_future, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert error.value is not None + + +# UTS: realtime/unit/RTL5k/attached-while-detaching-0 +@deviation +async def test_rtl5k_attached_while_detaching(): + channel_name = 'test-RTL5k' + detach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + if len(detach_messages) == 1: + mock_ws.send_to_client(attached_message(channel_name)) + else: + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, realtime_request_timeout=300) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 2 + + +# UTS: realtime/unit/RTL5k/attached-while-detached-1 +@deviation +async def test_rtl5k_attached_while_detached(): + channel_name = 'test-RTL5k-detached' + detach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + detach_messages.append(msg) + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.DETACHED + assert len(detach_messages) == 1 + + mock_ws.send_to_client(attached_message(channel_name)) + await poll_until(lambda: len(detach_messages) == 2, OPERATION_TIMEOUT, 'a second DETACH') + + assert len(detach_messages) == 2 + assert channel.state == ChannelState.DETACHED + + +# UTS: realtime/unit/RTL5/detach-state-change-events-0 +async def test_rtl5_detach_state_change_events(): + channel_name = 'test-RTL5-events' + state_changes = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + def record(change): + state_changes.append(change) + + channel.on(record) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + state_changes.clear() + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert len(state_changes) >= 2 + # The specification also asserts `event` on each change; ably-python has no + # ChannelStateChange.event, the event being the key the listener is + # registered against + assert state_changes[0].current == ChannelState.DETACHING + assert state_changes[0].previous == ChannelState.ATTACHED + assert state_changes[1].current == ChannelState.DETACHED + assert state_changes[1].previous == ChannelState.DETACHING diff --git a/test/uts/realtime/unit/channels/channel_error_test.py b/test/uts/realtime/unit/channels/channel_error_test.py new file mode 100644 index 00000000..b135966b --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_error_test.py @@ -0,0 +1,250 @@ +"""Derived from uts/realtime/unit/channels/channel_error.md in ably/specification. + +Spec points: RTL14 +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE_NO_IDLE, + MockWebSocket, + attached_message, + connected_message, + server_detached_message, +) + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def channel_error_message(channel, code, message, status_code): + """An ERROR protocol message scoped to `channel`. + + A channel attribute is what distinguishes this from the connection-level + ERROR of RTN15i; `ERROR_MESSAGE` in the helpers builds the latter. + """ + return { + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def attach_echo(mock_ws, recorder=None): + """A handler which confirms every ATTACH with an ATTACHED for the same channel.""" + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + if recorder is not None: + recorder.append(msg) + mock_ws.send_to_client(attached_message(msg.get('channel'))) + return on_message_from_client + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL14/attached-to-failed-0 +async def test_rtl14_attached_to_failed(): + channel_name = 'test-RTL14-attached' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_echo(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + channel_state_changes = [] + + def record(change): + channel_state_changes.append(change) + + channel.on(record) + + mock_ws.send_to_client(channel_error_message(channel_name, 40160, 'Not permitted', 401)) + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.FAILED + + assert channel.error_reason is not None + assert channel.error_reason.code == 40160 + assert channel.error_reason.status_code == 401 + assert 'Not permitted' in channel.error_reason.message + + assert len(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 None + assert channel_state_changes[0].reason.code == 40160 + + # A channel-scoped ERROR is dispatched to the channel and leaves the + # connection alone + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTL14/attaching-to-failed-1 +async def test_rtl14_attaching_to_failed(): + channel_name = 'test-RTL14-attaching' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client( + channel_error_message(msg.get('channel'), 40160, 'Not permitted', 401)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.FAILED + + assert channel.error_reason is not None + assert channel.error_reason.code == 40160 + + assert error.value.code == 40160 + + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTL14/pending-detach-error-2 +async def test_rtl14_pending_detach_error(): + channel_name = 'test-RTL14-detaching' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + action = msg.get('action') + if action == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + elif action == ProtocolMessageAction.DETACH: + mock_ws.send_to_client( + channel_error_message(msg.get('channel'), 90198, 'Detach failed', 500)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.FAILED + + assert channel.error_reason is not None + assert channel.error_reason.code == 90198 + + assert error.value.code == 90198 + + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTL14/other-channels-unaffected-3 +async def test_rtl14_other_channels_unaffected(): + channel_name_a = 'test-RTL14-a' + channel_name_b = 'test-RTL14-b' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_echo(mock_ws) + client = await connected_client(mock_ws) + channel_a = client.channels.get(channel_name_a) + channel_b = client.channels.get(channel_name_b) + + await asyncio.wait_for(channel_a.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel_b.attach(), OPERATION_TIMEOUT) + assert channel_a.state == ChannelState.ATTACHED + assert channel_b.state == ChannelState.ATTACHED + + mock_ws.send_to_client(channel_error_message(channel_name_a, 40160, 'Not permitted', 401)) + await await_channel_state(channel_a, ChannelState.FAILED, OPERATION_TIMEOUT) + + assert channel_a.state == ChannelState.FAILED + assert channel_a.error_reason is not None + + assert channel_b.state == ChannelState.ATTACHED + assert channel_b.error_reason is None + + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTL14/cancels-pending-timers-4 +async def test_rtl14_cancels_pending_timers(): + channel_name = 'test-RTL14-timers' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + if len(attach_messages) == 1: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=100, channel_retry_timeout=200) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert len(attach_messages) == 1 + + # A server-initiated DETACHED re-attaches the channel (RTL13a); the second + # ATTACH goes unanswered, so the attach times out into SUSPENDED + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Detach', 500)) + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'a re-attach') + + await clock.advance(150) + await await_channel_state(channel, ChannelState.SUSPENDED, OPERATION_TIMEOUT) + + # The channel retry timer is now pending; the ERROR arrives before it fires + mock_ws.send_to_client(channel_error_message(channel_name, 40160, 'Not permitted', 401)) + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + + attach_count_after_error = len(attach_messages) + + await clock.advance(500) + await settle() + + assert channel.state == ChannelState.FAILED + assert len(attach_messages) == attach_count_after_error diff --git a/test/uts/realtime/unit/channels/channel_get_message_test.py b/test/uts/realtime/unit/channels/channel_get_message_test.py new file mode 100644 index 00000000..7a2cb2ab --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_get_message_test.py @@ -0,0 +1,55 @@ +"""Derived from uts/realtime/unit/channels/channel_get_message.md in ably/specification. + +Spec points: RTL28 + +The specification points at uts/rest/unit/channel/get_message.md (RSL11) rather than +listing steps of its own, so its single Test ID becomes one test driving an +`AblyRealtime` over the HTTP mock and asserting the core observable of the derived +REST suite: the endpoint the call reaches and the `Message` it returns. +""" + +import uuid + +from ably.types.message import Message +from test.uts.helpers.client import realtime_client +from test.uts.helpers.mock_http import MockHttpClient + + +def random_id(): + return uuid.uuid4().hex[:8] + + +# UTS: realtime/unit/RTL28/identical-to-rest-0 +async def test_rtl28_identical_to_rest(): + channel_name = f'test-RTL28-{random_id()}' + captured_requests = [] + + def on_request(request): + captured_requests.append(request) + request.respond_with(200, { + 'name': 'evt', + 'data': 'hello', + 'serial': 'msg-serial-123', + 'timestamp': 1700000000000, + }) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + # `auto_connect` is left at `realtime_client`'s false, so no websocket is opened + client = realtime_client(mock_http=mock_http) + channel = client.channels.get(channel_name) + + message = await channel.get_message('msg-serial-123') + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == 'GET' + assert request.url.path == f'/channels/{channel_name}/messages/msg-serial-123' + assert not request.body + + assert isinstance(message, Message) + assert message.serial == 'msg-serial-123' + assert message.name == 'evt' + assert message.data == 'hello' diff --git a/test/uts/realtime/unit/channels/channel_history_test.py b/test/uts/realtime/unit/channels/channel_history_test.py new file mode 100644 index 00000000..8f5647b2 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_history_test.py @@ -0,0 +1,150 @@ +"""Derived from uts/realtime/unit/channels/channel_history.md in ably/specification. + +Spec points: RTL10, RTL10a, RTL10b, RTL10c + +RTL10a points at uts/rest/unit/channel/history.md (RSL2) rather than listing steps of +its own, so its Test ID becomes one test driving an `AblyRealtime` over the HTTP mock +and asserting the core observable of the derived REST suite. + +The two RTL10b tests cover `untilAttach`, which ably-python does not implement; see +[deviations.md](../../../deviations.md). +""" + +import uuid + +import pytest + +from ably.http.paginatedresult import PaginatedResult +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +ATTACH_SERIAL = 'serial-abc:0' + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def capture_and_respond(captured_requests, body): + def on_request(request): + captured_requests.append(request) + request.respond_with(200, body) + + return on_request + + +def attaching_mock(channel_name, channel_serial=None): + """A mock which connects and confirms the channel's attach.""" + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + fields = {} if channel_serial is None else {'channelSerial': channel_serial} + mock_ws.send_to_client(attached_message(channel_name, **fields)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +# UTS: realtime/unit/RTL10a/supports-rest-params-0 +async def test_rtl10a_supports_rest_params(): + # The specification directs uts/rest/unit/channel/history.md (RSL2) at a realtime + # channel in place of a REST one. `AblyRealtime` subclasses `AblyRest`, so the same + # HTTP mock serves it, and this mirrors that suite's `RSL2a/returns-paginated-result-0` + # and `RSL2b/query-parameters-0`. + channel_name = f'test-RTL10a-{random_id()}' + captured_requests = [] + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=capture_and_respond(captured_requests, [ + {'id': 'msg1', 'name': 'event1', 'data': 'data1', 'timestamp': 1000}, + {'id': 'msg2', 'name': 'event2', 'data': 'data2', 'timestamp': 2000}, + ]), + ) + # `auto_connect` is left at `realtime_client`'s false, so no websocket is opened + client = realtime_client(mock_http=mock_http) + channel = client.channels.get(channel_name) + + # RTL10c: the first page of messages + result = await channel.history() + + assert isinstance(result, PaginatedResult) + assert len(result.items) == 2 + assert result.items[0].id == 'msg1' + assert result.items[0].data == 'data1' + + assert captured_requests[0].url.path == f'/channels/{channel_name}/messages' + + # RTL10a: the parameters `RestChannel#history` takes + await channel.history(direction='forwards', limit=50, start=1000, end=2000) + + query_params = captured_requests[1].url.query_params + assert query_params['direction'] == 'forwards' + assert query_params['limit'] == '50' + assert query_params['start'] == '1000' + assert query_params['end'] == '2000' + + +# UTS: realtime/unit/RTL10b/adds-from-serial-0 +@deviation +async def test_rtl10b_adds_from_serial(): + channel_name = f'test-RTL10b-{random_id()}' + captured_requests = [] + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=capture_and_respond(captured_requests, []), + ) + mock_ws = attaching_mock(channel_name, channel_serial=ATTACH_SERIAL) + client = realtime_client(mock_ws, mock_http=mock_http) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + assert channel.state == ChannelState.ATTACHED + + await channel.history(until_attach=True) + + assert captured_requests[0].url.query_params['fromSerial'] == ATTACH_SERIAL + + +# UTS: realtime/unit/RTL10b/errors-when-not-attached-1 +async def test_rtl10b_errors_when_not_attached(): + # The specification asks for an error when `untilAttach` is requested on a channel + # that is not attached. ably-python raises one, but for a different reason: `history` + # takes no `until_attach` parameter at all, and `catch_all` + # (`ably/util/exceptions.py:93`) turns the resulting TypeError into an AblyException + # whatever the channel's state. See the deviations file. + channel_name = f'test-RTL10b-err-{random_id()}' + captured_requests = [] + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=capture_and_respond(captured_requests, []), + ) + mock_ws = attaching_mock(channel_name) + client = realtime_client(mock_ws, mock_http=mock_http) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert channel.state == ChannelState.INITIALIZED + + with pytest.raises(AblyException): + await channel.history(until_attach=True) + + # Nothing was requested: the call failed before it reached the HTTP layer + assert captured_requests == [] diff --git a/test/uts/realtime/unit/channels/channel_message_versions_test.py b/test/uts/realtime/unit/channels/channel_message_versions_test.py new file mode 100644 index 00000000..ffac77fd --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_message_versions_test.py @@ -0,0 +1,68 @@ +"""Derived from uts/realtime/unit/channels/channel_message_versions.md in ably/specification. + +Spec points: RTL31 + +The specification points at uts/rest/unit/channel/message_versions.md (RSL14) rather +than listing steps of its own, so its single Test ID becomes one test driving an +`AblyRealtime` over the HTTP mock and asserting the core observable of the derived +REST suite: the endpoint the call reaches and the paginated versions it returns. +""" + +import uuid + +from ably.http.paginatedresult import PaginatedResult +from ably.types.message import Message, MessageAction +from test.uts.helpers.client import realtime_client +from test.uts.helpers.mock_http import MockHttpClient + + +def random_id(): + return uuid.uuid4().hex[:8] + + +# UTS: realtime/unit/RTL31/identical-to-rest-0 +async def test_rtl31_identical_to_rest(): + channel_name = f'test-RTL31-{random_id()}' + captured_requests = [] + + def on_request(request): + captured_requests.append(request) + request.respond_with(200, [ + { + 'name': 'evt', + 'data': 'v2-data', + 'serial': 'msg-serial-1', + 'action': 1, + 'version': {'serial': 'vs2', 'timestamp': 1700000002000}, + }, + { + 'name': 'evt', + 'data': 'v1-data', + 'serial': 'msg-serial-1', + 'action': 0, + 'version': {'serial': 'vs1', 'timestamp': 1700000001000}, + }, + ]) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + # `auto_connect` is left at `realtime_client`'s false, so no websocket is opened + client = realtime_client(mock_http=mock_http) + channel = client.channels.get(channel_name) + + result = await channel.get_message_versions('msg-serial-1') + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == 'GET' + assert request.url.path == f'/channels/{channel_name}/messages/msg-serial-1/versions' + + assert isinstance(result, PaginatedResult) + assert len(result.items) == 2 + assert all(isinstance(item, Message) for item in result.items) + assert result.items[0].action == MessageAction.MESSAGE_UPDATE + assert result.items[0].version.serial == 'vs2' + assert result.items[1].action == MessageAction.MESSAGE_CREATE + assert result.items[1].version.serial == 'vs1' diff --git a/test/uts/realtime/unit/channels/channel_options_test.py b/test/uts/realtime/unit/channels/channel_options_test.py new file mode 100644 index 00000000..43284aa8 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_options_test.py @@ -0,0 +1,348 @@ +"""Derived from uts/realtime/unit/channels/channel_options.md in ably/specification. + +Spec points: DO2a, RTL16, RTL16a, RTS3b, RTS3c, RTS3c1, RTS5, RTS5a, RTS5a1, +RTS5a2, TB2, TB2c, TB2d, TB3, TB4 + +`ChannelOptions.cipher` is the specification's `cipherParams`, and the options a +channel carries are the mapping `ChannelOptions.to_dict()` produces rather than +a `ChannelOptions` object, so `channel.options['params']` stands in for the +specification's `channel.options.params`. +""" + +import asyncio +import base64 +from urllib.parse import parse_qs + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelmode import ChannelMode +from ably.types.channeloptions import ChannelOptions +from ably.types.channelstate import ChannelState +from ably.util.crypto import get_default_params +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + realtime_client, +) +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE, + MockWebSocket, + attached_message, +) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + +# A 256-bit key, base64 encoded as the specification writes it +CIPHER_KEY = 'MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=' + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +def attaching_mock(): + """A mock which connects and then leaves every ATTACH unanswered.""" + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + return mock_ws + + +def attached_mock(attach_messages): + """A mock which connects and answers each ATTACH with an ATTACHED.""" + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +# UTS: realtime/unit/TB2/channel-options-attributes-0 +async def test_tb2_channel_options_attributes(): + options = ChannelOptions() + + assert options.cipher is None + assert options.params is None + assert options.modes is None + # TB4's `attachOnSubscribe` is not among ChannelOptions' attributes; see the + # TB4 test below + + +# UTS: realtime/unit/TB2c/options-with-params-0 +async def test_tb2c_options_with_params(): + options = ChannelOptions(params={'rewind': '1', 'delta': 'vcdiff'}) + + assert options.params['rewind'] == '1' + assert options.params['delta'] == 'vcdiff' + + +# UTS: realtime/unit/TB2d/options-with-modes-0 +async def test_tb2d_options_with_modes(): + options = ChannelOptions(modes=[ChannelMode.PUBLISH, ChannelMode.SUBSCRIBE]) + + assert ChannelMode.PUBLISH in options.modes + assert ChannelMode.SUBSCRIBE in options.modes + assert len(options.modes) == 2 + + +# UTS: realtime/unit/TB3/with-cipher-key-0 +@deviation +async def test_tb3_with_cipher_key(): + # DEVIATION: TB3's `withCipherKey` constructor is absent from `ChannelOptions` + # (`ably/types/channeloptions.py`), which takes a `CipherParams` and offers no factory + # that builds one from a key. `ably.util.crypto.get_default_params({'key': key})` is the + # nearest equivalent, and it is not on ChannelOptions. + options = ChannelOptions.with_cipher_key(CIPHER_KEY) + + assert options.cipher is not None + assert options.cipher.algorithm.lower() == 'aes' + assert options.cipher.key_length == 256 + + +# UTS: realtime/unit/TB4/attach-on-subscribe-default-0 +@deviation +async def test_tb4_attach_on_subscribe_default(): + # DEVIATION: `attachOnSubscribe` is absent from `ChannelOptions` + # (`ably/types/channeloptions.py`), which accepts only cipher, params and modes, so + # passing it raises `TypeError`. `subscribe()` always attaches (RTL7g), with no way to + # opt out. + options1 = ChannelOptions() + options2 = ChannelOptions(attach_on_subscribe=False) + + assert options1.attach_on_subscribe is True + assert options2.attach_on_subscribe is False + + +# UTS: realtime/unit/RTS3b/options-set-on-new-0 +async def test_rts3b_options_set_on_new(): + channel_name = 'test-RTS3b' + client = realtime_client() + + channel_options = ChannelOptions(params={'rewind': '1'}, modes=[ChannelMode.SUBSCRIBE]) + + channel = client.channels.get(channel_name, channel_options) + + assert channel.options['params']['rewind'] == '1' + assert ChannelMode.SUBSCRIBE in channel.options['modes'] + + +# UTS: realtime/unit/RTS3c/options-updated-existing-0 +async def test_rts3c_options_updated_existing(): + channel_name = 'test-RTS3c' + client = realtime_client() + + # The specification distinguishes the two sets of options by `attachOnSubscribe`, + # which ably-python does not have; modes serve the same purpose here, and an + # unattached channel takes any options without reattaching + initial_options = ChannelOptions(modes=[ChannelMode.SUBSCRIBE]) + channel = client.channels.get(channel_name, initial_options) + + new_options = ChannelOptions(cipher=get_default_params({'key': CIPHER_KEY})) + same_channel = client.channels.get(channel_name, new_options) + + assert same_channel is channel + assert channel.options['cipher'] is not None + assert 'modes' not in channel.options + + +# UTS: realtime/unit/RTS3c1/error-reattach-params-0 +async def test_rts3c1_error_reattach_params(): + channel_name = 'test-RTS3c1' + attach_messages = [] + + mock_ws = attached_mock(attach_messages) + client = await connected_client(mock_ws) + + channel = client.channels.get(channel_name) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + new_options = ChannelOptions(params={'rewind': '1'}) + + with pytest.raises(AblyException) as error: + client.channels.get(channel_name, new_options) + assert error.value.code == 40000 + + assert channel.options.get('params') is None + + +# UTS: realtime/unit/RTS3c1/error-reattach-modes-1 +async def test_rts3c1_error_reattach_modes(): + channel_name = 'test-RTS3c1-attaching' + + mock_ws = attaching_mock() + client = await connected_client(mock_ws) + + channel = client.channels.get(channel_name) + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + new_options = ChannelOptions(modes=[ChannelMode.SUBSCRIBE]) + + with pytest.raises(AblyException) as error: + client.channels.get(channel_name, new_options) + assert error.value.code == 40000 + + attach_future.cancel() + + +# UTS: realtime/unit/RTL16/set-options-updates-0 +async def test_rtl16_set_options_updates(): + channel_name = 'test-RTL16' + client = realtime_client() + channel = client.channels.get(channel_name) + + # The specification also sets `attachOnSubscribe`, which ChannelOptions does not + # carry; see the TB4 test + new_options = ChannelOptions(params={'delta': 'vcdiff'}) + await asyncio.wait_for(channel.set_options(new_options), OPERATION_TIMEOUT) + + assert channel.options['params']['delta'] == 'vcdiff' + + +# UTS: realtime/unit/RTL16a/triggers-reattach-0 +@deviation +async def test_rtl16a_triggers_reattach(): + # DEVIATION: `set_options` never returns for an attached channel. It calls + # `_attach_impl()` directly and then awaits the internal state emitter + # (`ably/realtime/channel.py:99-102`), but the server's ATTACHED arrives while the + # channel is still ATTACHED, so `_on_message` takes the RTL12 branch (`:722-726`) and + # emits `update` on the public emitter alone. Nothing ever reaches the internal + # emitter, so the await hangs. Because `_attach_impl()` is called in place of + # `_request_state(ATTACHING)`, no ATTACHING state change is emitted either. The + # options themselves are stored, before the reattach is requested. + channel_name = 'test-RTL16a' + attach_messages = [] + + mock_ws = attached_mock(attach_messages) + client = await connected_client(mock_ws) + + channel = client.channels.get(channel_name) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + state_changes = [] + + def record(change): + state_changes.append(change) + + channel.on(record) + + new_options = ChannelOptions(params={'rewind': '1'}) + # The specification awaits this without a deadline; a deadline is what turns the + # hang into a failure rather than a stuck test run + await asyncio.wait_for(channel.set_options(new_options), OPERATION_TIMEOUT) + + assert any(change.current == ChannelState.ATTACHING for change in state_changes) + assert channel.state == ChannelState.ATTACHED + assert channel.options['params']['rewind'] == '1' + + +# UTS: realtime/unit/RTS5a/creates-derived-channel-0 +@deviation +async def test_rts5a_creates_derived_channel(): + # DEVIATION: `Channels.get_derived` is absent (`ably/realtime/channel.py:964`), as is + # `DeriveOptions`. `Channels.__getattr__` (`ably/rest/channel.py:408`) answers any + # unknown attribute with a channel of that name, so the call raises + # `TypeError: 'RealtimeChannel' object is not callable` rather than AttributeError. + from ably import DeriveOptions + + base_channel_name = 'test-RTS5a' + client = realtime_client() + + derive_options = DeriveOptions(filter="name == 'foo'") + + channel = client.channels.get_derived(base_channel_name, derive_options) + + assert channel.name.startswith('[filter=') + assert channel.name.endswith(']' + base_channel_name) + + +# UTS: realtime/unit/RTS5a1/filter-base64-encoded-0 +@deviation +async def test_rts5a1_filter_base64_encoded(): + # DEVIATION: derived channels are absent; see the RTS5a test. + from ably import DeriveOptions + + base_channel_name = 'test-RTS5a1' + client = realtime_client() + + channel_filter = "name == 'test'" + derive_options = DeriveOptions(filter=channel_filter) + + channel = client.channels.get_derived(base_channel_name, derive_options) + expected_encoded = base64.b64encode(channel_filter.encode()).decode() + + assert channel.name == '[filter=' + expected_encoded + ']' + base_channel_name + + +# UTS: realtime/unit/RTS5a2/derived-with-params-0 +@deviation +async def test_rts5a2_derived_with_params(): + # DEVIATION: derived channels are absent; see the RTS5a test. + from ably import DeriveOptions + + base_channel_name = 'test-RTS5a2' + client = realtime_client() + + derive_options = DeriveOptions(filter="type == 'message'") + channel_options = ChannelOptions(params={'rewind': '1', 'delta': 'vcdiff'}) + + channel = client.channels.get_derived(base_channel_name, derive_options, channel_options) + + assert channel.name.endswith(']' + base_channel_name) + + qualifier = channel.name[channel.name.index('[') + 1:channel.name.index(']')] + assert qualifier.startswith('filter=') + + assert '?' in qualifier + parsed_params = parse_qs(qualifier.split('?')[1]) + assert parsed_params['rewind'] == ['1'] + assert parsed_params['delta'] == ['vcdiff'] + assert len(parsed_params) == 2 + + +# UTS: realtime/unit/RTS5/get-derived-with-options-0 +@deviation +async def test_rts5_get_derived_with_options(): + # DEVIATION: derived channels are absent; see the RTS5a test. `attachOnSubscribe` is + # absent too; see the TB4 test. + from ably import DeriveOptions + + base_channel_name = 'test-RTS5' + client = realtime_client() + + derive_options = DeriveOptions(filter='true') + channel_options = ChannelOptions(modes=[ChannelMode.SUBSCRIBE], attach_on_subscribe=False) + + channel = client.channels.get_derived(base_channel_name, derive_options, channel_options) + + assert ChannelMode.SUBSCRIBE in channel.options['modes'] + assert channel.options['attachOnSubscribe'] is False + + +# UTS: realtime/unit/DO2a/filter-attribute-0 +@deviation +async def test_do2a_filter_attribute(): + # DEVIATION: `DeriveOptions` is absent from the library, so the import fails. + from ably import DeriveOptions + + derive_options = DeriveOptions(filter="name == 'event' && data.count > 10") + + assert derive_options.filter == "name == 'event' && data.count > 10" diff --git a/test/uts/realtime/unit/channels/channel_properties_test.py b/test/uts/realtime/unit/channels/channel_properties_test.py new file mode 100644 index 00000000..dfa0bcff --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_properties_test.py @@ -0,0 +1,385 @@ +"""Derived from uts/realtime/unit/channels/channel_properties.md in ably/specification. + +Spec points: RTL15, RTL15b, RTL15b2, RTL15c + +RTL15's `RealtimeChannel#properties` object is absent from ably-python; the two +serials it holds are kept as private fields on the channel. These tests read +them through the accessors below, so that what RTL15b and RTL15c require of the +serials is exercised even though the object that should carry them is missing. +""" + +import asyncio + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE, + CONNECTED_MESSAGE_NO_IDLE, + MockWebSocket, + attached_message, + detached_message, + server_detached_message, +) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def attach_serial(channel): + """The channel's `attachSerial`, which the library keeps privately.""" + return channel._RealtimeChannel__attach_serial + + +def channel_serial(channel): + """The channel's `channelSerial`, which the library keeps privately.""" + return channel._RealtimeChannel__channel_serial + + +def message_message(channel, channel_serial=None, messages=None): + """A MESSAGE protocol message carrying one message for `channel`.""" + msg = { + 'action': int(ProtocolMessageAction.MESSAGE), + 'channel': channel, + 'messages': messages if messages is not None else [{'name': 'event', 'data': 'data'}], + } + if channel_serial is not None: + msg['channelSerial'] = channel_serial + return msg + + +def presence_message(channel, channel_serial): + """A PRESENCE protocol message with no members, sent for its serial alone.""" + return { + 'action': int(ProtocolMessageAction.PRESENCE), + 'channel': channel, + 'channelSerial': channel_serial, + 'presence': [], + } + + +def channel_error_message(channel, code, message, status_code): + """An ERROR message scoped to a channel, which RTN15i routes to that channel.""" + return { + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def attach_responder(mock_ws, serial_for_attach): + """Answers each ATTACH with an ATTACHED whose serial `serial_for_attach` decides.""" + attach_count = 0 + + def on_message_from_client(msg): + nonlocal attach_count + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_count += 1 + serial = serial_for_attach(attach_count) + if serial is not None: + mock_ws.send_to_client(attached_message(msg.get('channel'), channelSerial=serial)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(msg.get('channel'))) + + return on_message_from_client + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL15c/attach-serial-from-attached-0 +async def test_rtl15c_attach_serial_from_attached(): + channel_name = 'test-RTL15c' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder( + mock_ws, lambda count: f'attach-serial-{count}') + + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + assert attach_serial(channel) is None + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert attach_serial(channel) == 'attach-serial-1' + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert attach_serial(channel) == 'attach-serial-2' + + +# UTS: realtime/unit/RTL15c/attach-serial-server-reattach-1 +async def test_rtl15c_attach_serial_server_reattach(): + channel_name = 'test-RTL15c-update' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'initial-serial') + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert attach_serial(channel) == 'initial-serial' + + # An unsolicited ATTACHED with no RESUMED flag, which RTL12 treats as an update + mock_ws.send_to_client(attached_message(channel_name, channelSerial='updated-serial')) + await poll_until( + lambda: attach_serial(channel) == 'updated-serial', + OPERATION_TIMEOUT, "attachSerial is updated") + + assert attach_serial(channel) == 'updated-serial' + + +# UTS: realtime/unit/RTL15c/attach-serial-not-updated-resumed-2 +@deviation +async def test_rtl15c_attach_serial_not_updated_resumed(): + # DEVIATION: RTL15c updates attachSerial only from an ATTACHED whose `resumed` is false. + # `_on_message` (`ably/realtime/channel.py:708`) assigns `__attach_serial` from every + # ATTACHED, before it has looked at the flags, so a resumed ATTACHED overwrites it. + channel_name = 'test-RTL15c-resumed' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'initial-serial') + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert attach_serial(channel) == 'initial-serial' + + mock_ws.send_to_client(attached_message( + channel_name, channelSerial='resumed-serial', flags=int(Flag.RESUMED))) + + # channelSerial is updated per RTL15b whatever the RESUMED flag says, which + # shows that the message has been processed + await poll_until( + lambda: channel_serial(channel) == 'resumed-serial', + OPERATION_TIMEOUT, "channelSerial is updated") + + assert attach_serial(channel) == 'initial-serial' + + +# UTS: realtime/unit/RTL15b/channel-serial-from-attached-0 +async def test_rtl15b_channel_serial_from_attached(): + channel_name = 'test-RTL15b-attached' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'serial-001') + + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel_serial(channel) is None + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel_serial(channel) == 'serial-001' + + +# UTS: realtime/unit/RTL15b/channel-serial-from-messages-1 +@deviation +async def test_rtl15b_channel_serial_from_messages(): + # DEVIATION: RTL15b updates channelSerial for a PRESENCE action as it does for MESSAGE. + # The PRESENCE branch of `_on_message` (`ably/realtime/channel.py:751`) hands the members + # to the presence map and never touches `__channel_serial`, so it keeps the serial of the + # last MESSAGE. The MESSAGE half of this test passes. + channel_name = 'test-RTL15b-messages' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'serial-001') + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel_serial(channel) == 'serial-001' + + mock_ws.send_to_client(message_message(channel_name, channel_serial='serial-002')) + await poll_until( + lambda: channel_serial(channel) == 'serial-002', + OPERATION_TIMEOUT, "channelSerial follows the MESSAGE") + + mock_ws.send_to_client(presence_message(channel_name, 'serial-003')) + await settle() + + assert channel_serial(channel) == 'serial-003' + + +# UTS: realtime/unit/RTL15b/serial-not-updated-empty-2 +@deviation +async def test_rtl15b_serial_not_updated_empty(): + # DEVIATION: RTL15b sets channelSerial from a protocol message only where the field is + # populated. `_on_message` (`ably/realtime/channel.py:743`) assigns it unconditionally + # for a MESSAGE, so a MESSAGE with no channelSerial sets the channel's serial to null. + channel_name = 'test-RTL15b-noupdate' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'serial-001') + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel_serial(channel) == 'serial-001' + + mock_ws.send_to_client(message_message(channel_name)) + await settle() + + assert channel_serial(channel) == 'serial-001' + + +# UTS: realtime/unit/RTL15b/serial-not-updated-irrelevant-3 +async def test_rtl15b_serial_not_updated_irrelevant(): + channel_name = 'test-RTL15b-irrelevant' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(msg.get('channel'), channelSerial='serial-001')) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel_serial(channel) == 'serial-001' + + detached = server_detached_message(channel_name, 90198, 'Detached', status_code=500) + detached['channelSerial'] = 'serial-should-not-apply' + mock_ws.send_to_client(detached) + + # RTL13a reattaches; waiting for the second ATTACH keeps the wait off the + # ATTACHED state the channel still holds at this point + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, "a second ATTACH") + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + assert len(attach_messages) == 2 + assert channel_serial(channel) == 'serial-001' + + +# UTS: realtime/unit/RTL15b2/serial-cleared-detached-0 +async def test_rtl15b2_serial_cleared_detached(): + channel_name = 'test-RTL15b1-detached' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'serial-001') + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel_serial(channel) == 'serial-001' + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert channel_serial(channel) is None + + +# UTS: realtime/unit/RTL15b2/serial-retained-suspended-1 +@deviation +async def test_rtl15b2_serial_retained_suspended(): + # DEVIATION: RTL15b2 clears channelSerial only for DETACHED and FAILED, keeping it + # through SUSPENDED so that the next ATTACH can carry it (RTL4c1). `_notify_state` + # (`ably/realtime/channel.py:812`) clears it for SUSPENDED too, under a comment naming + # the superseded RTP5a1. + channel_name = 'test-RTL15b2-suspended' + clock = FakeClock() + attach_count = 0 + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + nonlocal attach_count + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_count += 1 + if attach_count == 1: + mock_ws.send_to_client(attached_message(msg.get('channel'), channelSerial='serial-001')) + # A second ATTACH goes unanswered, so it times out into SUSPENDED + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, clock=clock, realtime_request_timeout=100) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel_serial(channel) == 'serial-001' + + mock_ws.send_to_client( + server_detached_message(channel_name, 90198, 'Detached', status_code=500)) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + await settle() + + await clock.advance(150) + await await_channel_state(channel, ChannelState.SUSPENDED, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.SUSPENDED + assert channel_serial(channel) == 'serial-001' + + +# UTS: realtime/unit/RTL15b2/serial-cleared-failed-2 +async def test_rtl15b2_serial_cleared_failed(): + channel_name = 'test-RTL15b2-failed' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws, lambda count: 'serial-001') + + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel_serial(channel) == 'serial-001' + + mock_ws.send_to_client(channel_error_message(channel_name, 40160, 'Not permitted', 401)) + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + + assert channel.state == ChannelState.FAILED + assert channel_serial(channel) is None diff --git a/test/uts/realtime/unit/channels/channel_publish_pending_test.py b/test/uts/realtime/unit/channels/channel_publish_pending_test.py new file mode 100644 index 00000000..4b41d8bc --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_publish_pending_test.py @@ -0,0 +1,556 @@ +"""Derived from uts/realtime/unit/channels/channel_publish.md in ably/specification. + +Spec points: RTN7d, RTN7e, RTN19a, RTN19a2, RTN19b + +The RTL6 sections of the same specification are derived in `channel_publish_test.py`, +which these tests share their setup helpers with. The two files split one specification +between publishing itself and the fate of a message still awaiting its ACK. + +`RealtimeChannel.publish()` resolves on the ACK (RTL6b), so a publish these +specifications deliberately leave unacknowledged is driven as a task and awaited once the +state change under test has happened. +""" + +import asyncio + +import pytest + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.operations import PublishResult +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + STATE_TIMEOUT, + await_channel_state, + await_connection_state, + next_connection_state, + poll_until, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message +from test.uts.realtime.unit.channels.channel_publish_test import ( + CONNECTED_MESSAGE, + ack_message, + advance_until_suspended, + attached_message, + connected_client, + published, + random_id, +) + + +def attach_only_server(mock_ws): + """Answers an ATTACH with ATTACHED and leaves every MESSAGE unacknowledged.""" + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg['channel'])) + + return on_message_from_client + + +async def await_published(mock_ws, count=1): + """Waits until `count` MESSAGE ProtocolMessages have left the client. + + A publish awaiting its ACK is in flight rather than in any state, so it is + the message on the wire that says the premise holds. + """ + await poll_until(lambda: len(published(mock_ws)) >= count, + description=f'{count} MESSAGE(s) sent') + + +# UTS: realtime/unit/RTN7e/pending-fail-suspended-0 +async def test_rtn7e_pending_fail_suspended(): + channel_name = f'test-RTN7e-suspended-{random_id()}' + clock = FakeClock() + attempt_count = 0 + + def on_connection_attempt(conn): + nonlocal attempt_count + attempt_count += 1 + # The specification installs a second mock which refuses every attempt; + # the mock is installed once here and refuses from the second attempt on + if attempt_count == 1: + conn.respond_with_success(CONNECTED_MESSAGE) + else: + conn.respond_with_refused() + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + mock_ws.on_message_from_client = attach_only_server(mock_ws) + client = await connected_client(mock_ws, clock=clock, disconnected_retry_timeout=1000) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('pending', 'data')) + await await_published(mock_ws) + + mock_ws.simulate_disconnect() + await settle() + + await advance_until_suspended(client, clock) + assert client.connection.state == ConnectionState.SUSPENDED + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + assert error.value.code is not None + + +# UTS: realtime/unit/RTN7e/pending-fail-closed-1 +async def test_rtn7e_pending_fail_closed(): + channel_name = f'test-RTN7e-closed-{random_id()}' + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attach_only_server(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('pending', 'data')) + await await_published(mock_ws) + + await client.close() + assert client.connection.state == ConnectionState.CLOSED + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + assert error.value.code is not None + + +# UTS: realtime/unit/RTN7e/pending-fail-failed-2 +# DEVIATION RTN7e: a connection-level ERROR reaches FAILED through +# `ConnectionManager.on_error` -> `enact_state_change` +# (`ably/realtime/connectionmanager.py:477`), which never calls +# `fail_queued_messages`. The message stays pending and the publish never resolves. +# See deviations.md. +@deviation +async def test_rtn7e_pending_fail_failed(): + channel_name = f'test-RTN7e-failed-{random_id()}' + attempt_count = 0 + + def on_connection_attempt(conn): + nonlocal attempt_count + attempt_count += 1 + if attempt_count == 1: + conn.respond_with_success(CONNECTED_MESSAGE) + else: + conn.respond_with_success() + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + # The message is left unacknowledged and a fatal error forces FAILED + mock_ws.send_to_client_and_close({ + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': 80000, 'statusCode': 400, 'message': 'Fatal error'}, + }) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('pending', 'data')) + + await await_connection_state(client, ConnectionState.FAILED) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + assert error.value.code is not None + + +# UTS: realtime/unit/RTN7e/multiple-pending-fail-3 +async def test_rtn7e_multiple_pending_fail(): + channel_name = f'test-RTN7e-multi-{random_id()}' + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attach_only_server(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publishes = [ + asyncio.ensure_future(channel.publish('msg1', 'data1')), + asyncio.ensure_future(channel.publish('msg2', 'data2')), + asyncio.ensure_future(channel.publish('msg3', 'data3')), + ] + await await_published(mock_ws, 3) + + await client.close() + + for publish_task in publishes: + with pytest.raises(AblyException) as error: + await asyncio.wait_for(publish_task, STATE_TIMEOUT) + assert error.value.code is not None + + +# UTS: realtime/unit/RTN7e/error-represents-reason-4 +# DEVIATION RTN7e: as for `pending-fail-failed-2`, nothing fails the pending message when +# a connection-level ERROR drives the connection to FAILED, so no error reaches the +# publish at all — let alone the one that caused the state change. The connection's own +# `error_reason` does carry it. See deviations.md. +@deviation +async def test_rtn7e_error_represents_reason(): + channel_name = f'test-RTN7e-error-reason-{random_id()}' + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + mock_ws.send_to_client_and_close({ + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': 80019, 'statusCode': 400, + 'message': 'Connection closed due to admin action'}, + }) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('pending', 'data')) + + await await_connection_state(client, ConnectionState.FAILED) + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + assert error.value.code == 80019 + assert error.value.status_code == 400 + assert error.value.message == 'Connection closed due to admin action' + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80019 + + +# UTS: realtime/unit/RTN7d/fail-disconnected-no-queue-0 +async def test_rtn7d_fail_disconnected_no_queue(): + channel_name = f'test-RTN7d-{random_id()}' + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attach_only_server(mock_ws) + client = await connected_client(mock_ws, queue_messages=False) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('pending', 'data')) + await await_published(mock_ws) + + state_changes = [] + + def record(change): + state_changes.append(change.current) + + client.connection.on(record) + + mock_ws.simulate_disconnect() + + with pytest.raises(AblyException) as error: + await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + assert error.value.code is not None + assert ConnectionState.DISCONNECTED in state_changes + + +# UTS: realtime/unit/RTN7d/survive-disconnected-queue-1 +async def test_rtn7d_survive_disconnected_queue(): + channel_name = f'test-RTN7d-default-{random_id()}' + captured_messages = [] + connection_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_count + connection_count += 1 + conn.respond_with_success(CONNECTED_MESSAGE) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append((connection_count, msg)) + # The first connection leaves the message pending + if connection_count >= 2: + mock_ws.send_to_client(ack_message(msg, ['serial-ack'])) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('pending', 'data')) + await await_published(mock_ws) + + # RTN15a retries a drop from CONNECTED immediately, so the reconnection + # needs no time to pass where the specification advances the clock + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + mock_ws.simulate_disconnect() + await reconnected + + result = await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + assert isinstance(result, PublishResult) + assert result.serials[0] == 'serial-ack' + + +# UTS: realtime/unit/RTN19a/resent-on-new-transport-0 +async def test_rtn19a_resent_on_new_transport(): + channel_name = f'test-RTN19a-{random_id()}' + captured_messages = [] + connection_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_count + connection_count += 1 + conn.respond_with_success(CONNECTED_MESSAGE) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append({'msg': msg, 'connection': connection_count}) + if connection_count >= 2: + mock_ws.send_to_client(ack_message(msg, ['serial-resent'])) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publish_task = asyncio.ensure_future(channel.publish('resend-me', 'data')) + await await_published(mock_ws) + + first_transport_messages = [m for m in captured_messages if m['connection'] == 1] + assert len(first_transport_messages) == 1 + + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + mock_ws.simulate_disconnect() + await reconnected + + result = await asyncio.wait_for(publish_task, STATE_TIMEOUT) + + second_transport_messages = [m for m in captured_messages if m['connection'] == 2] + assert len(second_transport_messages) >= 1 + assert second_transport_messages[0]['msg']['messages'][0]['name'] == 'resend-me' + + assert isinstance(result, PublishResult) + assert result.serials[0] == 'serial-resent' + + +# UTS: realtime/unit/RTN19a2/same-serial-on-resume-0 +async def test_rtn19a2_same_serial_on_resume(): + channel_name = f'test-RTN19a2-resume-{random_id()}' + captured_messages = [] + original_connection_id = 'connection-abc' + connection_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_count + connection_count += 1 + # RTN15c6: the same connectionId on both makes the second a valid resume. + # The specification puts connectionKey at the top level of the CONNECTED; + # protocol.md carries it in connectionDetails, which is where it is read from. + conn.respond_with_success(connected_message( + original_connection_id, connectionKey=f'key-{connection_count}')) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append({'msg': msg, 'connection': connection_count}) + if connection_count >= 2: + mock_ws.send_to_client(ack_message(msg, ['serial-resumed'])) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publishes = [ + asyncio.ensure_future(channel.publish('msg1', 'data1')), + asyncio.ensure_future(channel.publish('msg2', 'data2')), + ] + await await_published(mock_ws, 2) + + first_transport_messages = [m for m in captured_messages if m['connection'] == 1] + original_serial_1 = first_transport_messages[0]['msg']['msgSerial'] + original_serial_2 = first_transport_messages[1]['msg']['msgSerial'] + + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + mock_ws.simulate_disconnect() + await reconnected + + await asyncio.wait_for(asyncio.gather(*publishes), STATE_TIMEOUT) + + second_transport_messages = [m for m in captured_messages if m['connection'] == 2] + assert len(second_transport_messages) == 2 + assert second_transport_messages[0]['msg']['msgSerial'] == original_serial_1 + assert second_transport_messages[1]['msg']['msgSerial'] == original_serial_2 + + +# UTS: realtime/unit/RTN19a2/new-serial-failed-resume-1 +async def test_rtn19a2_new_serial_failed_resume(): + channel_name = f'test-RTN19a2-failed-resume-{random_id()}' + captured_messages = [] + connection_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_count + connection_count += 1 + if connection_count == 1: + conn.respond_with_success(connected_message('connection-first', connectionKey='key-first')) + else: + # RTN15c7: a new connectionId with an error is a failed resume + failed_resume = connected_message('connection-new', connectionKey='key-new') + failed_resume['error'] = { + 'code': 80018, 'statusCode': 400, 'message': 'Connection not resumable'} + conn.respond_with_success(failed_resume) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append({'msg': msg, 'connection': connection_count}) + if connection_count >= 2: + mock_ws.send_to_client(ack_message(msg, ['serial-new'])) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + publishes = [ + asyncio.ensure_future(channel.publish('msg1', 'data1')), + asyncio.ensure_future(channel.publish('msg2', 'data2')), + ] + await await_published(mock_ws, 2) + + first_transport_messages = [m for m in captured_messages if m['connection'] == 1] + assert first_transport_messages[0]['msg']['msgSerial'] == 0 + assert first_transport_messages[1]['msg']['msgSerial'] == 1 + + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + mock_ws.simulate_disconnect() + await reconnected + + await asyncio.wait_for(asyncio.gather(*publishes), STATE_TIMEOUT) + + # NOTE: the specification's assertion cannot distinguish the two behaviours it is + # written to separate — the original serials are already 0 and 1, so a resend that + # kept them and a resend that drew fresh ones from a reset counter look identical. + # See deviations.md; what ably-python actually does is resend the + # message dictionary unchanged, serial included. + second_transport_messages = [m for m in captured_messages if m['connection'] == 2] + assert len(second_transport_messages) == 2 + assert second_transport_messages[0]['msg']['msgSerial'] == 0 + assert second_transport_messages[1]['msg']['msgSerial'] == 1 + + +# UTS: realtime/unit/RTN19b/attach-resent-on-reconnect-0 +async def test_rtn19b_attach_resent_on_reconnect(): + channel_name = f'test-RTN19b-attach-{random_id()}' + captured_attach_messages = [] + connection_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_count + connection_count += 1 + conn.respond_with_success(CONNECTED_MESSAGE) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + captured_attach_messages.append({'msg': msg, 'connection': connection_count}) + # The first connection leaves the channel ATTACHING + if connection_count >= 2: + mock_ws.send_to_client(attached_message(msg['channel'])) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_task = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING) + + first_transport_attaches = [m for m in captured_attach_messages if m['connection'] == 1] + assert len(first_transport_attaches) == 1 + assert first_transport_attaches[0]['msg']['channel'] == channel_name + + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + mock_ws.simulate_disconnect() + await reconnected + + await asyncio.wait_for(attach_task, STATE_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + + second_transport_attaches = [m for m in captured_attach_messages if m['connection'] == 2] + assert len(second_transport_attaches) >= 1 + assert second_transport_attaches[0]['msg']['channel'] == channel_name + + +# UTS: realtime/unit/RTN19b/detach-resent-on-reconnect-1 +async def test_rtn19b_detach_resent_on_reconnect(): + channel_name = f'test-RTN19b-detach-{random_id()}' + captured_detach_messages = [] + connection_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_count + connection_count += 1 + conn.respond_with_success(CONNECTED_MESSAGE) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg['channel'])) + elif msg['action'] == ProtocolMessageAction.DETACH: + captured_detach_messages.append({'msg': msg, 'connection': connection_count}) + # The first connection leaves the channel DETACHING + if connection_count >= 2: + mock_ws.send_to_client({'action': int(ProtocolMessageAction.DETACHED), + 'channel': msg['channel']}) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt, + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + detach_task = asyncio.ensure_future(channel.detach()) + await await_channel_state(channel, ChannelState.DETACHING) + + first_transport_detaches = [m for m in captured_detach_messages if m['connection'] == 1] + assert len(first_transport_detaches) == 1 + + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + mock_ws.simulate_disconnect() + await reconnected + + await asyncio.wait_for(detach_task, STATE_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + + second_transport_detaches = [m for m in captured_detach_messages if m['connection'] == 2] + assert len(second_transport_detaches) >= 1 + assert second_transport_detaches[0]['msg']['channel'] == channel_name diff --git a/test/uts/realtime/unit/channels/channel_publish_test.py b/test/uts/realtime/unit/channels/channel_publish_test.py new file mode 100644 index 00000000..73d4ac41 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_publish_test.py @@ -0,0 +1,771 @@ +"""Derived from uts/realtime/unit/channels/channel_publish.md in ably/specification. + +Spec points: RTL6, RTL6a, RTL6c, RTL6c1, RTL6c2, RTL6c4, RTL6c5, RTL6i, RTL6i1, RTL6i2, +RTL6i3, RTL6j + +The RTN7d, RTN7e, RTN19a, RTN19a2 and RTN19b sections of the same specification are +derived in `channel_publish_pending_test.py`, which imports the helpers below. + +Two translations recur through the file. + +`RealtimeChannel.publish()` resolves on the ACK (RTL6b, `ably/realtime/channel.py:442`), +so where a specification's mock records a MESSAGE without answering it, an ACK is added: +the awaited publish would otherwise never return. Where a specification says in as many +words not to acknowledge a message, it is left unacknowledged and the publish is driven +as a task. + +`RealtimeChannel.publish()` takes its arguments positionally; the keyword form the +specifications write, which `RestChannel.publish()` does accept, raises `ValueError` here. +""" + +import asyncio +import json +import uuid + +import msgpack +import pytest + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.message import Message +from ably.types.operations import PublishResult +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + next_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import spec_error +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def attached_message(channel_name): + return {'action': int(ProtocolMessageAction.ATTACHED), 'channel': channel_name} + + +def ack_message(protocol_message, serials=('serial',)): + """An ACK for one ProtocolMessage, carrying `serials` as its RTL6j result.""" + return { + 'action': int(ProtocolMessageAction.ACK), + 'msgSerial': protocol_message['msgSerial'], + 'count': 1, + 'res': [{'serials': list(serials)}], + } + + +def nack_message(protocol_message, error): + return { + 'action': int(ProtocolMessageAction.NACK), + 'msgSerial': protocol_message['msgSerial'], + 'count': 1, + 'error': error, + } + + +def attaching_server(mock_ws, captured_messages=None, serials=('serial',), ack=True): + """The handler most of these specifications set on the mock. + + An ATTACH is answered with ATTACHED, and each MESSAGE is recorded in + `captured_messages` and — unless the specification asks for it to be left + pending — acknowledged. + """ + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg['channel'])) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + if captured_messages is not None: + captured_messages.append(msg) + if ack: + mock_ws.send_to_client(ack_message(msg, serials)) + + return on_message_from_client + + +def published(mock_ws): + """Every MESSAGE ProtocolMessage the client has sent.""" + return [msg for msg in mock_ws.messages_from_client + if msg['action'] == ProtocolMessageAction.MESSAGE] + + +async def advance_until_suspended(client, clock, step=2000, limit=80): + """Moves notional time on until the connection enters SUSPENDED. + + The specifications advance 2000ms up to 15 times, which assumes the + `connectionStateTtl` client option shortens the suspend timer. ably-python + discards that option (`ably/types/options.py:64`) and the suspend timer reads + `Defaults.connection_state_ttl` (120000) directly + (`ably/realtime/connectionmanager.py:745`), so reaching SUSPENDED takes the + full two minutes of notional time. See + [deviations.md](../../../deviations.md). + """ + for _ in range(limit): + await clock.advance(step) + if client.connection.state == ConnectionState.SUSPENDED: + return + raise AssertionError( + f'Connection did not reach SUSPENDED; it was {client.connection.state}') + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, as every test here starts.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL6i1/publish-name-and-data-0 +async def test_rtl6i1_publish_name_and_data(): + channel_name = f'test-RTL6i1-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.publish('greeting', 'hello') + + assert len(captured_messages) == 1 + assert captured_messages[0]['action'] == ProtocolMessageAction.MESSAGE + assert captured_messages[0]['channel'] == channel_name + assert len(captured_messages[0]['messages']) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'greeting' + assert captured_messages[0]['messages'][0]['data'] == 'hello' + + +# UTS: realtime/unit/RTL6i2/publish-message-array-0 +async def test_rtl6i2_publish_message_array(): + channel_name = f'test-RTL6i2-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.publish([ + Message(name='event1', data='data1'), + Message(name='event2', data='data2'), + Message(name='event3', data='data3'), + ]) + + # A single ProtocolMessage carries the whole array + assert len(captured_messages) == 1 + assert len(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' + + +# UTS: realtime/unit/RTL6i3/null-fields-json-0 +async def test_rtl6i3_null_fields_json(): + channel_name = f'test-RTL6i3-json-{random_id()}' + captured_frames = [] + + def on_text_data_frame(text): + decoded = json.loads(text) + if decoded['action'] == ProtocolMessageAction.MESSAGE: + captured_frames.append(decoded) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_text_data_frame=on_text_data_frame) + mock_ws.on_message_from_client = attaching_server(mock_ws) + client = await connected_client(mock_ws, use_binary_protocol=False) + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.publish('click', None) + await channel.publish(None, 'payload') + await channel.publish(None, None) + + assert len(captured_frames) == 3 + + first = captured_frames[0]['messages'][0] + assert first['name'] == 'click' + assert 'data' not in first + + second = captured_frames[1]['messages'][0] + assert 'name' not in second + assert second['data'] == 'payload' + + third = captured_frames[2]['messages'][0] + assert 'name' not in third + assert 'data' not in third + + +# UTS: realtime/unit/RTL6i3/null-fields-msgpack-1 +async def test_rtl6i3_null_fields_msgpack(): + channel_name = f'test-RTL6i3-msgpack-{random_id()}' + captured_frames = [] + + def on_binary_data_frame(raw): + decoded = msgpack.unpackb(raw, raw=False) + if decoded['action'] == ProtocolMessageAction.MESSAGE: + captured_frames.append(decoded) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_binary_data_frame=on_binary_data_frame) + mock_ws.on_message_from_client = attaching_server(mock_ws) + client = await connected_client(mock_ws, use_binary_protocol=True) + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.publish('click', None) + await channel.publish(None, 'payload') + await channel.publish(None, None) + + assert len(captured_frames) == 3 + + first = captured_frames[0]['messages'][0] + assert first['name'] == 'click' + assert 'data' not in first + + second = captured_frames[1]['messages'][0] + assert 'name' not in second + assert second['data'] == 'payload' + + third = captured_frames[2]['messages'][0] + assert 'name' not in third + assert 'data' not in third + + +# UTS: realtime/unit/RTL6c1/publish-when-attached-0 +async def test_rtl6c1_publish_when_attached(): + channel_name = f'test-RTL6c1-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + assert client.connection.state == ConnectionState.CONNECTED + assert channel.state == ChannelState.ATTACHED + + await channel.publish('test', 'immediate') + + assert len(captured_messages) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'test' + assert captured_messages[0]['messages'][0]['data'] == 'immediate' + + +# UTS: realtime/unit/RTL6c1/publish-when-attaching-1 +async def test_rtl6c1_publish_when_attaching(): + channel_name = f'test-RTL6c1-attaching-{random_id()}' + captured_messages = [] + + def on_message_from_client(msg): + # An ATTACH goes unanswered, so the channel stays ATTACHING + if msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append(msg) + mock_ws.send_to_client(ack_message(msg)) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_task = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING) + + await channel.publish('while-attaching', 'data') + + # ATTACHING is neither SUSPENDED nor FAILED, so the message goes out at once + assert len(captured_messages) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'while-attaching' + + attach_task.cancel() + + +# UTS: realtime/unit/RTL6c1/publish-when-initialized-2 +async def test_rtl6c1_publish_when_initialized(): + channel_name = f'test-RTL6c1-init-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.publish('before-attach', 'data') + + assert len(captured_messages) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'before-attach' + + +# UTS: realtime/unit/RTL6c2/queued-when-connecting-0 +async def test_rtl6c2_queued_when_connecting(): + channel_name = f'test-RTL6c2-connecting-{random_id()}' + captured_messages = [] + attempts = [] + + mock_ws = MockWebSocket( + # The attempt is left unanswered, so the connection stays CONNECTING. + # The specification recovers it afterwards with + # `await_connection_attempt()`, which registers its waiter when called + # and so would wait for a second attempt; the handler keeps this one. + on_connection_attempt=attempts.append) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await poll_until(lambda: len(attempts) == 1, description='a connection attempt is in flight') + assert client.connection.state == ConnectionState.CONNECTING + + publish_task = asyncio.ensure_future(channel.publish('queued', 'waiting')) + await settle() + + assert captured_messages == [] + + attempts[0].respond_with_success(CONNECTED_MESSAGE) + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(publish_task, 5) + + assert len(captured_messages) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'queued' + assert captured_messages[0]['messages'][0]['data'] == 'waiting' + + +# UTS: realtime/unit/RTL6c2/queued-when-disconnected-1 +async def test_rtl6c2_queued_when_disconnected(): + channel_name = f'test-RTL6c2-disconnected-{random_id()}' + captured_messages = [] + attempt_count = 0 + + def on_connection_attempt(conn): + nonlocal attempt_count + attempt_count += 1 + # RTN15a retries a drop from CONNECTED with no time passing, so + # DISCONNECTED is not a state the connection rests in. Failing that + # retry holds it there long enough to publish into it. + if attempt_count == 2: + conn.respond_with_dns_error() + else: + conn.respond_with_success(CONNECTED_MESSAGE) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + # A retry timeout longer than the test keeps the connection in DISCONNECTED + client = await connected_client(mock_ws, disconnected_retry_timeout=60000) + channel = client.channels.get(channel_name) + + state_changes = [] + + def record(change): + state_changes.append(change.current) + + client.connection.on(record) + + mock_ws.simulate_disconnect() + await poll_until( + lambda: client.connection.state == ConnectionState.DISCONNECTED and attempt_count == 2, + description='the connection has settled in DISCONNECTED') + + assert ConnectionState.DISCONNECTED in state_changes + + publish_task = asyncio.ensure_future(channel.publish('during-disconnect', 'queued')) + await settle() + + message_count_before = len(captured_messages) + assert message_count_before == 0 + + reconnected = asyncio.ensure_future(next_connection_state(client, ConnectionState.CONNECTED)) + client.connect() + await reconnected + await asyncio.wait_for(publish_task, 5) + + assert len(captured_messages) > message_count_before + queued = [msg for msg in captured_messages + if msg['messages'][0]['name'] == 'during-disconnect'] + assert len(queued) == 1 + + +# UTS: realtime/unit/RTL6c2/queued-when-initialized-2 +async def test_rtl6c2_queued_when_initialized(): + channel_name = f'test-RTL6c2-init-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + assert client.connection.state == ConnectionState.INITIALIZED + + publish_task = asyncio.ensure_future(channel.publish('pre-connect', 'early')) + await settle() + + assert captured_messages == [] + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(publish_task, 5) + + assert len(captured_messages) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'pre-connect' + + +# UTS: realtime/unit/RTL6c4/fails-conn-suspended-0 +async def test_rtl6c4_fails_conn_suspended(): + channel_name = f'test-RTL6c4-suspended-{random_id()}' + clock = FakeClock() + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_refused()) + client = realtime_client(mock_ws, clock=clock, disconnected_retry_timeout=1000) + channel = client.channels.get(channel_name) + + client.connect() + await advance_until_suspended(client, clock) + + assert client.connection.state == ConnectionState.SUSPENDED + + with pytest.raises(AblyException) as error: + await channel.publish('fail', 'should-error') + + assert error.value.code is not None + + +# UTS: realtime/unit/RTL6c4/fails-conn-closed-1 +async def test_rtl6c4_fails_conn_closed(): + channel_name = f'test-RTL6c4-closed-{random_id()}' + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await client.close() + assert client.connection.state == ConnectionState.CLOSED + + with pytest.raises(AblyException) as error: + await channel.publish('fail', 'should-error') + + assert error.value.code is not None + + +# UTS: realtime/unit/RTL6c4/fails-conn-failed-2 +async def test_rtl6c4_fails_conn_failed(): + channel_name = f'test-RTL6c4-failed-{random_id()}' + fatal_error = { + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': 80000, 'statusCode': 400, 'message': 'Fatal error'}, + } + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_error(fatal_error, then_close=True)) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + with pytest.raises(AblyException) as error: + await channel.publish('fail', 'should-error') + + assert error.value.code is not None + + +# UTS: realtime/unit/RTL6c4/fails-channel-suspended-3 +async def test_rtl6c4_fails_channel_suspended(): + channel_name = f'test-RTL6c4-ch-suspended-{random_id()}' + captured_messages = [] + clock = FakeClock() + + def on_message_from_client(msg): + # An ATTACH goes unanswered, so the channel's state timer suspends it + if msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append(msg) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws, clock=clock, realtime_request_timeout=100) + channel = client.channels.get(channel_name) + + attach_task = asyncio.ensure_future(channel.attach()) + await settle() + await clock.advance(150) + + with pytest.raises(AblyException): + await attach_task + + assert channel.state == ChannelState.SUSPENDED + + with pytest.raises(AblyException) as error: + await channel.publish('fail', 'should-error') + + assert error.value.code is not None + assert captured_messages == [] + + +# UTS: realtime/unit/RTL6c4/fails-channel-failed-4 +async def test_rtl6c4_fails_channel_failed(): + channel_name = f'test-RTL6c4-ch-failed-{random_id()}' + captured_messages = [] + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel_name, + 'error': {'code': 40160, 'statusCode': 401, 'message': 'Not permitted'}, + }) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append(msg) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await channel.attach() + + assert channel.state == ChannelState.FAILED + + with pytest.raises(AblyException) as error: + await channel.publish('fail', 'should-error') + + assert error.value.code is not None + assert captured_messages == [] + + +# UTS: realtime/unit/RTL6c2/fails-no-queue-messages-3 +async def test_rtl6c2_fails_no_queue_messages(): + channel_name = f'test-RTL6c2-noqueue-{random_id()}' + attempts = [] + # The attempt is left unanswered, so the connection stays CONNECTING + mock_ws = MockWebSocket(on_connection_attempt=attempts.append) + client = realtime_client(mock_ws, queue_messages=False) + channel = client.channels.get(channel_name) + + client.connect() + await poll_until(lambda: len(attempts) == 1, description='a connection attempt is in flight') + assert client.connection.state == ConnectionState.CONNECTING + + with pytest.raises(AblyException) as error: + await channel.publish('fail', 'should-error') + + assert error.value.code is not None + + # The attempt is answered so that teardown does not wait it out + attempts[0].respond_with_success(CONNECTED_MESSAGE) + + +# UTS: realtime/unit/RTL6c5/no-implicit-attach-0 +async def test_rtl6c5_no_implicit_attach(): + channel_name = f'test-RTL6c5-{random_id()}' + captured_messages = [] + attach_messages = [] + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append(msg) + mock_ws.send_to_client(ack_message(msg)) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.publish('no-attach', 'test') + + # RTL6c1: CONNECTED and a channel that is neither SUSPENDED nor FAILED + assert len(captured_messages) == 1 + + assert channel.state == ChannelState.INITIALIZED + assert attach_messages == [] + + +# UTS: realtime/unit/RTL6c2/queued-messages-order-4 +async def test_rtl6c2_queued_messages_order(): + channel_name = f'test-RTL6c2-order-{random_id()}' + captured_messages = [] + attempts = [] + # The attempt is left unanswered, so the connection stays CONNECTING + mock_ws = MockWebSocket(on_connection_attempt=attempts.append) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + client.connect() + await poll_until(lambda: len(attempts) == 1, description='a connection attempt is in flight') + assert client.connection.state == ConnectionState.CONNECTING + + publishes = [ + asyncio.ensure_future(channel.publish('first', '1')), + asyncio.ensure_future(channel.publish('second', '2')), + asyncio.ensure_future(channel.publish('third', '3')), + ] + await settle() + + assert captured_messages == [] + + attempts[0].respond_with_success(CONNECTED_MESSAGE) + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(asyncio.gather(*publishes), 5) + + assert len(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' + + +# UTS: realtime/unit/RTL6i1/publish-message-object-1 +# SPEC ERROR RTL6i1: an object payload is asserted to travel unstringified. RSL4c3 and +# RSL4d3, which RTL6a defers to, both require it to be stringified and carry +# `encoding: "json"` — which is what ably-python sends. The same fault is recorded for +# `rest/unit/channel/publish.md:129` in deviations.md. Fix the specification first. +@spec_error +async def test_rtl6i1_publish_message_object(): + channel_name = f'test-RTL6i1-obj-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.publish(Message(name='custom', data={'key': 'value'})) + + assert len(captured_messages) == 1 + assert len(captured_messages[0]['messages']) == 1 + assert captured_messages[0]['messages'][0]['name'] == 'custom' + assert captured_messages[0]['messages'][0]['data'] == {'key': 'value'} + + +# UTS: realtime/unit/RTL6j/publish-result-serials-0 +async def test_rtl6j_publish_result_serials(): + channel_name = f'test-RTL6j-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server(mock_ws, captured_messages, serials=['abc123']) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + result = await channel.publish('greeting', 'hello') + + # RTN7b: the first message on a connection carries msgSerial 0 + assert len(captured_messages) == 1 + assert captured_messages[0]['msgSerial'] == 0 + + assert isinstance(result, PublishResult) + assert len(result.serials) == 1 + assert result.serials[0] == 'abc123' + + +# UTS: realtime/unit/RTL6j/batch-publish-serials-1 +async def test_rtl6j_batch_publish_serials(): + channel_name = f'test-RTL6j-batch-{random_id()}' + captured_messages = [] + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE)) + mock_ws.on_message_from_client = attaching_server( + mock_ws, captured_messages, serials=['serial-1', None, 'serial-3']) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + result = await channel.publish([ + Message(name='event1', data='data1'), + Message(name='event2', data='data2'), + Message(name='event3', data='data3'), + ]) + + assert len(captured_messages) == 1 + assert len(captured_messages[0]['messages']) == 3 + + assert isinstance(result, PublishResult) + assert len(result.serials) == 3 + assert result.serials[0] == 'serial-1' + # PBR2a: a conflated message carries no serial + assert result.serials[1] is None + assert result.serials[2] == 'serial-3' + + +# UTS: realtime/unit/RTL6j/incrementing-msg-serial-2 +async def test_rtl6j_incrementing_msg_serial(): + channel_name = f'test-RTL6j-serial-{random_id()}' + captured_messages = [] + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + captured_messages.append(msg) + mock_ws.send_to_client(ack_message(msg, [f'serial-{msg["msgSerial"]}'])) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + first = await channel.publish('first', '1') + second = await channel.publish('second', '2') + third = await channel.publish('third', '3') + + assert len(captured_messages) == 3 + assert captured_messages[0]['msgSerial'] == 0 + assert captured_messages[1]['msgSerial'] == 1 + assert captured_messages[2]['msgSerial'] == 2 + + assert first.serials[0] == 'serial-0' + assert second.serials[0] == 'serial-1' + assert third.serials[0] == 'serial-2' + + +# UTS: realtime/unit/RTL6j/nack-results-error-3 +async def test_rtl6j_nack_results_error(): + channel_name = f'test-RTL6j-nack-{random_id()}' + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.MESSAGE: + mock_ws.send_to_client(nack_message( + msg, {'code': 40160, 'statusCode': 401, 'message': 'Publish rejected'})) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + with pytest.raises(AblyException) as error: + await channel.publish('rejected', 'data') + + assert error.value.code == 40160 + assert error.value.message == 'Publish rejected' + diff --git a/test/uts/realtime/unit/channels/channel_server_initiated_detach_test.py b/test/uts/realtime/unit/channels/channel_server_initiated_detach_test.py new file mode 100644 index 00000000..4c0a2dde --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_server_initiated_detach_test.py @@ -0,0 +1,418 @@ +"""Derived from uts/realtime/unit/channels/channel_server_initiated_detach.md in ably/specification. + +Spec points: RTL13, RTL13a, RTL13b, RTL13c +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from test.uts.helpers.client import await_connection_state, poll_until, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# The same connection with the idle timer switched off, so that a test driving a +# `FakeClock` sees only the timers it is interested in +CONNECTED_MESSAGE_NO_IDLE = connected_message( + 'connection-id', connectionKey='connection-key', maxIdleInterval=0) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def attached_message(channel_name, **fields): + """An ATTACHED for `channel_name`, as the server confirms an attach.""" + return {'action': ProtocolMessageAction.ATTACHED, 'channel': channel_name, **fields} + + +def server_detached_message(channel_name, code, message): + """The unsolicited DETACHED the server sends to drop a channel.""" + return { + 'action': ProtocolMessageAction.DETACHED, + 'channel': channel_name, + 'error': {'code': code, 'statusCode': 500, 'message': message}, + } + + +def states_of(state_changes): + return [change.current for change in state_changes] + + +def contains_in_order(states, expected): + """Whether `expected` appears in `states` in order, gaps allowed.""" + remaining = list(expected) + for state in states: + if remaining and state == remaining[0]: + remaining.pop(0) + return not remaining + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL13a/attached-reattach-triggered-0 +async def test_rtl13a_attached_reattach_triggered(): + channel_name = 'test-RTL13a-attached' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + channel_state_changes = [] + + def record(change): + channel_state_changes.append(change) + + channel.on(record) + + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Server detached channel')) + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'a reattach') + await poll_until( + lambda: channel.state == ChannelState.ATTACHED, OPERATION_TIMEOUT, 'the channel reattached') + + assert len(attach_messages) == 2 + assert len(channel_state_changes) >= 2 + assert channel_state_changes[0].current == ChannelState.ATTACHING + assert channel_state_changes[0].previous == ChannelState.ATTACHED + # RTL13a carries the DETACHED message's error onto the ATTACHING state + # change; ably-python requests ATTACHING with no reason, so it arrives null + assert channel_state_changes[0].reason is None + assert channel_state_changes[1].current == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTL13a/suspended-reattach-triggered-1 +async def test_rtl13a_suspended_reattach_triggered(): + channel_name = 'test-RTL13a-suspended' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + # The second attach is left unanswered, so that its state timer + # takes the channel to SUSPENDED + if len(attach_messages) != 2: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=100, channel_retry_timeout=60000) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Detach 1')) + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'the first reattach') + assert channel.state == ChannelState.ATTACHING + + await clock.advance(150) + assert channel.state == ChannelState.SUSPENDED + + mock_ws.send_to_client(server_detached_message(channel_name, 90199, 'Detach 2')) + await poll_until(lambda: len(attach_messages) == 3, OPERATION_TIMEOUT, 'the second reattach') + await poll_until( + lambda: channel.state == ChannelState.ATTACHED, OPERATION_TIMEOUT, 'the channel reattached') + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 3 + + +# UTS: realtime/unit/RTL13b/failed-reattach-suspended-retry-0 +async def test_rtl13b_failed_reattach_suspended_retry(): + channel_name = 'test-RTL13b' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + # The reattach the server-initiated DETACHED triggers is left + # unanswered, so that it times out into SUSPENDED + if len(attach_messages) != 2: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=100, channel_retry_timeout=200) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + channel_state_changes = [] + + def record(change): + channel_state_changes.append(change) + + channel.on(record) + + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Server detached')) + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'the reattach') + assert channel.state == ChannelState.ATTACHING + + await clock.advance(150) + assert channel.state == ChannelState.SUSPENDED + + await clock.advance(250) + await poll_until( + lambda: channel.state == ChannelState.ATTACHED, OPERATION_TIMEOUT, 'the retry to attach') + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 3 + assert contains_in_order(states_of(channel_state_changes), [ + ChannelState.ATTACHING, + ChannelState.SUSPENDED, + ChannelState.ATTACHING, + ChannelState.ATTACHED, + ]) + + +# UTS: realtime/unit/RTL13b/attaching-detached-to-suspended-1 +async def test_rtl13b_attaching_detached_to_suspended(): + channel_name = 'test-RTL13b-attaching' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + # The first attach is left unanswered, holding the channel in + # ATTACHING for the server-initiated DETACHED to arrive into + if len(attach_messages) != 1: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=500, channel_retry_timeout=200) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + attach_future = asyncio.ensure_future(channel.attach()) + await poll_until(lambda: len(attach_messages) == 1, OPERATION_TIMEOUT, 'the ATTACH sent') + assert channel.state == ChannelState.ATTACHING + + channel_state_changes = [] + + def record(change): + channel_state_changes.append(change) + + channel.on(record) + + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Server detached')) + await poll_until( + lambda: channel.state == ChannelState.SUSPENDED, OPERATION_TIMEOUT, 'the channel suspended') + assert len(attach_messages) == 1 + + await clock.advance(250) + await poll_until( + lambda: channel.state == ChannelState.ATTACHED, OPERATION_TIMEOUT, 'the retry to attach') + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 2 + assert channel_state_changes[0].current == ChannelState.SUSPENDED + assert channel_state_changes[0].previous == ChannelState.ATTACHING + # RTL13b carries the DETACHED message's error onto the SUSPENDED state + # change; ably-python notifies SUSPENDED with no reason, so it arrives null + assert channel_state_changes[0].reason is None + + # A pending attach reads the reason off that state change and re-raises it, + # so a null reason surfaces as a TypeError rather than an AblyException + with pytest.raises(TypeError): + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + +# UTS: realtime/unit/RTL13b/repeated-failure-cycle-2 +async def test_rtl13b_repeated_failure_cycle(): + channel_name = 'test-RTL13b-repeat' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + # The second and third attaches are left unanswered, so that each + # times out into SUSPENDED and is retried + if len(attach_messages) not in (2, 3): + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=100, channel_retry_timeout=200) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert len(attach_messages) == 1 + + channel_state_changes = [] + + def record(change): + channel_state_changes.append(change) + + channel.on(record) + + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Detach')) + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'the reattach') + assert channel.state == ChannelState.ATTACHING + + await clock.advance(150) + assert channel.state == ChannelState.SUSPENDED + + # The retry falls due 200ms into this 250ms window and the attach it sends + # times out 100ms later, on the instant the window closes, so the channel is + # back in SUSPENDED by the time the advance returns + await clock.advance(250) + await poll_until(lambda: len(attach_messages) == 3, OPERATION_TIMEOUT, 'the first retry') + + await clock.advance(150) + assert channel.state == ChannelState.SUSPENDED + + await clock.advance(250) + await poll_until( + lambda: channel.state == ChannelState.ATTACHED, OPERATION_TIMEOUT, 'the second retry') + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 4 + assert contains_in_order(states_of(channel_state_changes), [ + ChannelState.ATTACHING, + ChannelState.SUSPENDED, + ChannelState.ATTACHING, + ChannelState.SUSPENDED, + ChannelState.ATTACHING, + ChannelState.ATTACHED, + ]) + + +# UTS: realtime/unit/RTL13c/retry-cancelled-disconnected-0 +async def test_rtl13c_retry_cancelled_disconnected(): + channel_name = 'test-RTL13c' + attach_messages = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + # Only the first attach is answered; every reattach times out + if len(attach_messages) == 1: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client( + mock_ws, clock=clock, realtime_request_timeout=100, channel_retry_timeout=200) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert len(attach_messages) == 1 + + mock_ws.send_to_client(server_detached_message(channel_name, 90198, 'Detach')) + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'the reattach') + assert channel.state == ChannelState.ATTACHING + + await clock.advance(150) + assert channel.state == ChannelState.SUSPENDED + + # A reconnect left unanswered keeps the connection out of CONNECTED for the + # rest of the test + mock_ws.on_connection_attempt = lambda conn: None + mock_ws.simulate_disconnect() + await settle() + await clock.advance(200) + assert client.connection.state != ConnectionState.CONNECTED + + attach_count_after_disconnect = len(attach_messages) + + await clock.advance(500) + + assert len(attach_messages) == attach_count_after_disconnect + assert channel.state == ChannelState.SUSPENDED + + +# UTS: realtime/unit/RTL13a/detaching-not-server-initiated-2 +async def test_rtl13a_detaching_not_server_initiated(): + channel_name = 'test-RTL13-detaching' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(msg.get('channel'))) + elif msg.get('action') == ProtocolMessageAction.DETACH: + mock_ws.send_to_client({ + 'action': ProtocolMessageAction.DETACHED, + 'channel': msg.get('channel'), + }) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.DETACHED + assert len(attach_messages) == 1 diff --git a/test/uts/realtime/unit/channels/channel_state_events_test.py b/test/uts/realtime/unit/channels/channel_state_events_test.py new file mode 100644 index 00000000..8d800808 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_state_events_test.py @@ -0,0 +1,405 @@ +"""Derived from uts/realtime/unit/channels/channel_state_events.md in ably/specification. + +Spec points: RTL2, RTL2a, RTL2b, RTL2d, RTL2g, RTL2i, RTL4c, RTL24, TH1, TH2, TH3, TH5, TH6 +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState, ChannelStateChange +from ably.types.flags import Flag +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def channel_error_message(channel, code, message, status_code=None): + """An ERROR protocol message scoped to `channel`.""" + error = {'code': code, 'message': message} + if status_code is not None: + error['statusCode'] = status_code + return {'action': int(ProtocolMessageAction.ERROR), 'channel': channel, 'error': error} + + +def echo_attached(mock_ws, **attached_fields): + """A handler confirming each ATTACH with an ATTACHED for the same channel.""" + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg.get('channel'), **attached_fields)) + return on_message_from_client + + +def capture_last(channel, event): + """Keeps the most recent state change `channel` emits for `event`. + + `ChannelStateChange` carries no `event` attribute, so the event a change was + emitted for is the key it is registered against. A single-element list + stands in for the specification's nullable `captured_change`. + """ + captured = [] + + def record(change): + captured.append(change) + + channel.on(event, record) + return captured + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL2b/channel-state-attribute-0 +async def test_rtl2b_channel_state_attribute(): + channel_name = 'test-RTL2b' + + client = realtime_client() + channel = client.channels.get(channel_name) + + assert isinstance(channel.state, ChannelState) + assert channel.state == ChannelState.INITIALIZED + + +# UTS: realtime/unit/RTL2b/initial-state-initialized-1 +async def test_rtl2b_initial_state_initialized(): + channel_name = 'test-RTL2b-init' + + client = realtime_client() + + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + +# UTS: realtime/unit/RTL2a/state-change-events-emitted-0 +async def test_rtl2a_state_change_events_emitted(): + channel_name = 'test-RTL2a' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + state_changes = [] + + def record(change): + state_changes.append(change) + + channel.on(record) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.on_message_from_client = echo_attached(mock_ws) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert len(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 + + +# UTS: realtime/unit/RTL2d/state-change-object-structure-0 +async def test_rtl2d_state_change_object_structure(): + channel_name = 'test-RTL2d' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + # TH5 has the change carry the event that generated it; ably-python's + # ChannelStateChange is (previous, current, resumed, reason), so the event is + # read from the key the listener is registered against. See + # deviations.md + captured = capture_last(channel, ChannelState.ATTACHING) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert captured + captured_change = captured[0] + assert isinstance(captured_change, ChannelStateChange) + assert captured_change.current == ChannelState.ATTACHING + assert captured_change.previous == ChannelState.INITIALIZED + + +# UTS: realtime/unit/RTL2d/state-change-error-reason-1 +async def test_rtl2d_state_change_error_reason(): + channel_name = 'test-RTL2d-error' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client( + channel_error_message(msg.get('channel'), 40160, 'Channel denied', 401)) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + captured = capture_last(channel, ChannelState.FAILED) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert captured + captured_change = captured[0] + assert captured_change.current == ChannelState.FAILED + assert captured_change.reason is not None + assert captured_change.reason.code == 40160 + assert captured_change.reason.message == 'Channel denied' + + +# UTS: realtime/unit/RTL2/filtered-event-subscription-0 +async def test_rtl2_filtered_event_subscription(): + channel_name = 'test-RTL2-filtered' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + attached_events = [] + + def record(change): + attached_events.append(change) + + channel.on(ChannelState.ATTACHED, record) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert len(attached_events) == 1 + assert attached_events[0].current == ChannelState.ATTACHED + # The event which produced the change is the key the listener is registered + # against, ATTACHED here + + +# UTS: realtime/unit/RTL2g/update-event-condition-change-0 +async def test_rtl2g_update_event_condition_change(): + channel_name = 'test-RTL2g' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + update_events = [] + + def record(change): + update_events.append(change) + + channel.on('update', record) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + # A further ATTACHED without the RESUMED flag stands for a loss of message + # continuity, which RTL12 reports as an UPDATE rather than a state change + mock_ws.send_to_client(attached_message(channel_name)) + await settle() + + assert channel.state == ChannelState.ATTACHED + assert len(update_events) == 1 + assert update_events[0].current == ChannelState.ATTACHED + assert update_events[0].previous == ChannelState.ATTACHED + assert update_events[0].resumed is False + + +# UTS: realtime/unit/RTL2g/no-duplicate-state-events-1 +async def test_rtl2g_no_duplicate_state_events(): + channel_name = 'test-RTL2g-nodup' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + all_events = [] + + def record_all(change): + all_events.append(change) + + # The specification filters `all_events` on `event == attached` to separate + # the ATTACHED state event from the RTL12 UPDATE. Without an `event` + # attribute the two are told apart by the key they arrive on + attached_state_events = [] + + def record_attached(change): + attached_state_events.append(change) + + channel.on(record_all) + channel.on(ChannelState.ATTACHED, record_attached) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + initial_count = len(all_events) + assert initial_count >= 2 + + mock_ws.send_to_client(attached_message(channel_name)) + await settle() + + assert len(attached_state_events) == 1 + + +# UTS: realtime/unit/RTL2i/has-backlog-flag-true-0 +@deviation +async def test_rtl2i_has_backlog_flag_true(): + channel_name = 'test-RTL2i' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws, flags=int(Flag.HAS_BACKLOG)) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + captured = capture_last(channel, ChannelState.ATTACHED) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert captured + assert captured[0].has_backlog is True + + +# UTS: realtime/unit/RTL2i/has-backlog-flag-false-1 +async def test_rtl2i_has_backlog_flag_false(): + channel_name = 'test-RTL2i-false' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + captured = capture_last(channel, ChannelState.ATTACHED) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert captured + # RTL2i makes `hasBacklog` optional, and the specification accepts false or + # null. ably-python's ChannelStateChange carries no such attribute + has_backlog = getattr(captured[0], 'has_backlog', None) + assert has_backlog is False or has_backlog is None + + +# UTS: realtime/unit/RTL2d/resumed-flag-propagated-2 +async def test_rtl2d_resumed_flag_propagated(): + channel_name = 'test-RTL2d-resumed' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attached(mock_ws, flags=int(Flag.RESUMED)) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + captured = capture_last(channel, ChannelState.ATTACHED) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert captured + assert captured[0].resumed is True + + +# UTS: realtime/unit/RTL24/error-reason-populated-0 +async def test_rtl24_error_reason_populated(): + channel_name = 'test-errorReason' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client( + channel_error_message(msg.get('channel'), 40160, 'Not authorized', 401)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.FAILED + assert channel.error_reason is not None + assert channel.error_reason.code == 40160 + assert channel.error_reason.message == 'Not authorized' + + +# UTS: realtime/unit/RTL4c/error-reason-cleared-attach-0 +async def test_rtl4c_error_reason_cleared_attach(): + channel_name = 'test-errorReason-clear' + attach_count = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_count.append(msg) + if len(attach_count) == 1: + mock_ws.send_to_client(channel_error_message(msg.get('channel'), 40160, 'Denied')) + else: + mock_ws.send_to_client(attached_message(msg.get('channel'))) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.FAILED + assert channel.error_reason is not None + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + assert channel.state == ChannelState.ATTACHED + assert channel.error_reason is None diff --git a/test/uts/realtime/unit/channels/channel_subscribe_test.py b/test/uts/realtime/unit/channels/channel_subscribe_test.py new file mode 100644 index 00000000..5bdecddf --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_subscribe_test.py @@ -0,0 +1,709 @@ +"""Derived from uts/realtime/unit/channels/channel_subscribe.md in ably/specification. + +Spec points: RTL7, RTL7a, RTL7b, RTL7f, RTL7g, RTL7h, RTL8, RTL8a, RTL8b, RTL8c, RTL17, +RTL22, RTL22a, RTL22b, RTL22c, RTL22d, MFI1, MFI2, MFI2a, MFI2b, MFI2c, MFI2d, MFI2e +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + MockWebSocket, + attached_message, + connected_message, + detached_message, +) + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +def message_protocol_message(channel_name, messages, **fields): + """A MESSAGE protocol message carrying `messages` on `channel_name`.""" + return { + 'action': int(ProtocolMessageAction.MESSAGE), + 'channel': channel_name, + 'messages': messages, + **fields, + } + + +def message_filter(**criteria): + """The specification's `MessageFilter` (MFI1), built from `criteria`.""" + from ably.types.messagefilter import MessageFilter + + return MessageFilter(**criteria) + + +def attaching_mock(channel_name, attach_messages=None, detach_messages=None): + """A mock which connects and confirms attaches and detaches for `channel_name`. + + Each ATTACH and DETACH is recorded in the list given for it, so that a test + can count the protocol messages the channel sent. + """ + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + if attach_messages is not None: + attach_messages.append(msg) + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + if detach_messages is not None: + detach_messages.append(msg) + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the channel tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +def recorder(received): + """A subscribe listener appending each message it is given to `received`.""" + def record(message): + received.append(message) + return record + + +# UTS: realtime/unit/RTL7a/subscribe-all-messages-0 +async def test_rtl7a_subscribe_all_messages(): + # The specification withholds the implicit attach with + # `RealtimeChannelOptions(attachOnSubscribe: false)`; the channel is attached + # first instead, which makes the attach `subscribe` awaits a no-op. + channel_name = 'test-RTL7a' + received = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(recorder(received)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'event1', 'data': 'data1'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'event2', 'data': 'data2'}])) + await poll_until(lambda: len(received) == 2, description='both messages are delivered') + + assert received[0].name == 'event1' + assert received[0].data == 'data1' + assert received[1].name == 'event2' + assert received[1].data == 'data2' + + +# UTS: realtime/unit/RTL7a/multiple-messages-per-protocol-1 +async def test_rtl7a_multiple_messages_per_protocol(): + channel_name = 'test-RTL7a-multi' + received = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(recorder(received)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'name': 'batch1', 'data': 'first'}, + {'name': 'batch2', 'data': 'second'}, + {'name': 'batch3', 'data': 'third'}, + ])) + await poll_until(lambda: len(received) == 3, description='all three messages are delivered') + + assert received[0].name == 'batch1' + assert received[1].name == 'batch2' + assert received[2].name == 'batch3' + + +# UTS: realtime/unit/RTL7b/name-filtered-subscribe-0 +async def test_rtl7b_name_filtered_subscribe(): + channel_name = 'test-RTL7b' + received = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe('target', recorder(received)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'other', 'data': 'should-not-receive'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'target', 'data': 'should-receive'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': None, 'data': 'no-name-should-not-receive'}])) + await poll_until(lambda: len(received) == 1, description='the matching message is delivered') + await settle() + + assert len(received) == 1 + assert received[0].name == 'target' + assert received[0].data == 'should-receive' + + +# UTS: realtime/unit/RTL7b/multiple-name-subscriptions-1 +async def test_rtl7b_multiple_name_subscriptions(): + channel_name = 'test-RTL7b-multi' + alpha_messages = [] + beta_messages = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe('alpha', recorder(alpha_messages)) + await channel.subscribe('beta', recorder(beta_messages)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'name': 'alpha', 'data': 'a1'}, + {'name': 'beta', 'data': 'b1'}, + {'name': 'alpha', 'data': 'a2'}, + {'name': 'gamma', 'data': 'g1'}, + ])) + await poll_until(lambda: len(alpha_messages) == 2, description='both alpha messages are delivered') + await settle() + + assert len(alpha_messages) == 2 + assert alpha_messages[0].data == 'a1' + assert alpha_messages[1].data == 'a2' + + assert len(beta_messages) == 1 + assert beta_messages[0].data == 'b1' + + +# UTS: realtime/unit/RTL7g/implicit-attach-initialized-0 +async def test_rtl7g_implicit_attach_initialized(): + channel_name = 'test-RTL7g' + attach_messages = [] + received = [] + + mock_ws = attaching_mock(channel_name, attach_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.subscribe(recorder(received)) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'test', 'data': 'hello'}])) + await poll_until(lambda: len(received) == 1, description='the message reaches the listener') + + +# UTS: realtime/unit/RTL7g/implicit-attach-detached-1 +async def test_rtl7g_implicit_attach_detached(): + channel_name = 'test-RTL7g-detached' + attach_messages = [] + + mock_ws = attaching_mock(channel_name, attach_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.detach() + assert channel.state == ChannelState.DETACHED + assert len(attach_messages) == 1 + + await channel.subscribe(lambda message: None) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 2 + + +# UTS: realtime/unit/RTL7g/listener-registered-attach-fails-2 +async def test_rtl7g_listener_registered_attach_fails(): + channel_name = 'test-RTL7g-fail' + received = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def reject_attach(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel_name, + 'error': {'code': 40160, 'statusCode': 401, 'message': 'Not permitted'}, + }) + + mock_ws.on_message_from_client = reject_attach + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + # The implicit attach is awaited by `subscribe`, so its rejection surfaces + # as the exception the specification leaves to the attach result + with pytest.raises(AblyException): + await channel.subscribe(recorder(received)) + + assert channel.state == ChannelState.FAILED + + def accept_attach(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = accept_attach + await channel.attach() + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'test', 'data': 'after-reattach'}])) + await poll_until(lambda: len(received) == 1, description='the registered listener is called') + + assert received[0].data == 'after-reattach' + + +# UTS: realtime/unit/RTL7h/no-attach-on-subscribe-0 +@deviation +async def test_rtl7h_no_attach_on_subscribe(): + from ably.types.channeloptions import ChannelOptions + + channel_name = 'test-RTL7h' + attach_messages = [] + + mock_ws = attaching_mock(channel_name, attach_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name, ChannelOptions(attach_on_subscribe=False)) + + assert channel.state == ChannelState.INITIALIZED + + await channel.subscribe(lambda message: None) + + assert channel.state == ChannelState.INITIALIZED + assert len(attach_messages) == 0 + + +# UTS: realtime/unit/RTL7g/no-attach-when-attached-3 +async def test_rtl7g_no_attach_when_attached(): + channel_name = 'test-RTL7g-already' + attach_messages = [] + + mock_ws = attaching_mock(channel_name, attach_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + assert len(attach_messages) == 1 + + await channel.subscribe(lambda message: None) + + assert channel.state == ChannelState.ATTACHED + assert len(attach_messages) == 1 + + +# UTS: realtime/unit/RTL7g/no-attach-when-attaching-4 +async def test_rtl7g_no_attach_when_attaching(): + channel_name = 'test-RTL7g-attaching' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def record_attach(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + + mock_ws.on_message_from_client = record_attach + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_task = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + assert len(attach_messages) == 1 + + # `subscribe` awaits the attach already in flight, so it is driven as a task + subscribe_task = asyncio.ensure_future(channel.subscribe(lambda message: None)) + await settle() + + assert channel.state == ChannelState.ATTACHING + assert len(attach_messages) == 1 + + attach_task.cancel() + subscribe_task.cancel() + + +# UTS: realtime/unit/RTL17/no-delivery-when-not-attached-0 +@deviation +async def test_rtl17_no_delivery_when_not_attached(): + channel_name = 'test-RTL17' + received = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=lambda msg: None, + ) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + # The listener is registered before `subscribe` awaits the attach the mock + # never confirms, so the channel stays ATTACHING with a subscriber on it + subscribe_task = asyncio.ensure_future(channel.subscribe(recorder(received))) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'premature', 'data': 'should-not-deliver'}])) + await settle() + + assert len(received) == 0 + + subscribe_task.cancel() + + +# UTS: realtime/unit/RTL7f/no-echo-messages-0 +@deviation +async def test_rtl7f_no_echo_messages(): + channel_name = 'test-RTL7f' + connection_id = 'conn-self-123' + received = [] + + mock_ws = attaching_mock(channel_name) + mock_ws.on_connection_attempt = lambda conn: conn.respond_with_success( + connected_message(connection_id, connectionKey='key-456')) + client = await connected_client(mock_ws, echo_messages=False) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(recorder(received)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'echo', 'data': 'from-self'}], connectionId=connection_id)) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'remote', 'data': 'from-other'}], connectionId='conn-other-789')) + await poll_until(lambda: len(received) == 1, description='the remote message is delivered') + await settle() + + assert len(received) == 1 + assert received[0].name == 'remote' + assert received[0].data == 'from-other' + + +# UTS: realtime/unit/RTL8a/unsubscribe-specific-listener-0 +async def test_rtl8a_unsubscribe_specific_listener(): + channel_name = 'test-RTL8a' + messages_a = [] + messages_b = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + listener_a = recorder(messages_a) + listener_b = recorder(messages_b) + + await channel.subscribe(listener_a) + await channel.subscribe(listener_b) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'msg1', 'data': 'first'}])) + await poll_until(lambda: len(messages_a) == 1 and len(messages_b) == 1, + description='both listeners see the first message') + + channel.unsubscribe(listener_a) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'msg2', 'data': 'second'}])) + await poll_until(lambda: len(messages_b) == 2, description='the second message is delivered') + await settle() + + assert len(messages_a) == 1 + assert len(messages_b) == 2 + assert messages_b[1].name == 'msg2' + + +# UTS: realtime/unit/RTL8b/unsubscribe-named-listener-0 +@deviation +async def test_rtl8b_unsubscribe_named_listener(): + channel_name = 'test-RTL8b' + received = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + listener = recorder(received) + + await channel.subscribe('alpha', listener) + await channel.subscribe('beta', listener) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'name': 'alpha', 'data': 'a1'}, + {'name': 'beta', 'data': 'b1'}, + ])) + await poll_until(lambda: len(received) == 2, description='both subscriptions are live') + + channel.unsubscribe('alpha', listener) + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'name': 'alpha', 'data': 'a2'}, + {'name': 'beta', 'data': 'b2'}, + ])) + await poll_until(lambda: len(received) == 3, description='the beta message is delivered') + await settle() + + assert len(received) == 3 + assert received[2].name == 'beta' + assert received[2].data == 'b2' + + +# UTS: realtime/unit/RTL8c/unsubscribe-all-listeners-0 +async def test_rtl8c_unsubscribe_all_listeners(): + channel_name = 'test-RTL8c' + messages_all = [] + messages_named = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(recorder(messages_all)) + await channel.subscribe('specific', recorder(messages_named)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'specific', 'data': 'first'}])) + await poll_until(lambda: len(messages_all) == 1 and len(messages_named) == 1, + description='both listeners see the first message') + + channel.unsubscribe() + + mock_ws.send_to_client(message_protocol_message(channel_name, [ + {'name': 'specific', 'data': 'second'}, + {'name': 'other', 'data': 'third'}, + ])) + await settle() + + assert len(messages_all) == 1 + assert len(messages_named) == 1 + + +# UTS: realtime/unit/RTL8a/unsubscribe-noop-not-subscribed-1 +async def test_rtl8a_unsubscribe_noop_not_subscribed(): + channel_name = 'test-RTL8a-noop' + received = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + active_listener = recorder(received) + + def unused_listener(message): + pass + + await channel.subscribe(active_listener) + + channel.unsubscribe(unused_listener) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'test', 'data': 'still-works'}])) + await poll_until(lambda: len(received) == 1, description='the surviving listener is called') + + assert received[0].data == 'still-works' + + +# UTS: realtime/unit/RTL22a/filter-matching-name-0 +@deviation +async def test_rtl22a_filter_matching_name(): + channel_name = 'test-RTL22a-name' + filtered = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(message_filter(name='target-event'), recorder(filtered)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'target-event', 'data': 'match-1'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'other-event', 'data': 'no-match'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'target-event', 'data': 'match-2'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': None, 'data': 'no-name'}])) + await poll_until(lambda: len(filtered) == 2, description='both matching messages are delivered') + await settle() + + assert len(filtered) == 2 + assert filtered[0].name == 'target-event' + assert filtered[0].data == 'match-1' + assert filtered[1].name == 'target-event' + assert filtered[1].data == 'match-2' + + +# UTS: realtime/unit/RTL22a/filter-matching-ref-timeserial-1 +@deviation +async def test_rtl22a_filter_matching_ref_timeserial(): + channel_name = 'test-RTL22a-ref-timeserial' + filtered = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe( + message_filter(ref_timeserial='abc123@1700000000000-0'), recorder(filtered)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'reply', 'data': 'match', + 'extras': {'ref': {'timeserial': 'abc123@1700000000000-0', 'type': 'com.ably.reply'}}, + }])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'reply', 'data': 'no-match', + 'extras': {'ref': {'timeserial': 'xyz789@1700000000000-0', 'type': 'com.ably.reply'}}, + }])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'plain', 'data': 'no-ref'}])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'reaction', 'data': 'match-2', + 'extras': {'ref': {'timeserial': 'abc123@1700000000000-0', 'type': 'com.ably.reaction'}}, + }])) + await poll_until(lambda: len(filtered) == 2, description='both matching messages are delivered') + await settle() + + assert len(filtered) == 2 + assert filtered[0].data == 'match' + assert filtered[1].data == 'match-2' + + +# UTS: realtime/unit/RTL22b/filter-isref-false-0 +@deviation +async def test_rtl22b_filter_isref_false(): + channel_name = 'test-RTL22b-isref-false' + filtered = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(message_filter(is_ref=False), recorder(filtered)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'plain', 'data': 'no-extras'}])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'reply', 'data': 'has-ref', + 'extras': {'ref': {'timeserial': 'abc123@1700000000000-0', 'type': 'com.ably.reply'}}, + }])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'annotated', 'data': 'extras-no-ref', + 'extras': {'headers': {'custom-key': 'custom-value'}}, + }])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'reaction', 'data': 'also-has-ref', + 'extras': {'ref': {'timeserial': 'xyz789@1700000000000-0', 'type': 'com.ably.reaction'}}, + }])) + await poll_until(lambda: len(filtered) == 2, description='both ref-less messages are delivered') + await settle() + + assert len(filtered) == 2 + assert filtered[0].name == 'plain' + assert filtered[0].data == 'no-extras' + assert filtered[1].name == 'annotated' + assert filtered[1].data == 'extras-no-ref' + + +# UTS: realtime/unit/RTL22c/filter-multiple-criteria-0 +@deviation +async def test_rtl22c_filter_multiple_criteria(): + channel_name = 'test-RTL22c-multi' + filtered = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe( + message_filter(name='comment', ref_type='com.ably.reply'), recorder(filtered)) + + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'comment', 'data': 'both-match', + 'extras': {'ref': {'timeserial': 'abc@1700000000000-0', 'type': 'com.ably.reply'}}, + }])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'comment', 'data': 'name-only', + 'extras': {'ref': {'timeserial': 'def@1700000000000-0', 'type': 'com.ably.reaction'}}, + }])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'update', 'data': 'type-only', + 'extras': {'ref': {'timeserial': 'ghi@1700000000000-0', 'type': 'com.ably.reply'}}, + }])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'update', 'data': 'neither'}])) + mock_ws.send_to_client(message_protocol_message(channel_name, [{ + 'name': 'comment', 'data': 'both-match-2', + 'extras': {'ref': {'timeserial': 'jkl@1700000000000-0', 'type': 'com.ably.reply'}}, + }])) + await poll_until(lambda: len(filtered) == 2, description='both fully matching messages are delivered') + await settle() + + assert len(filtered) == 2 + assert filtered[0].data == 'both-match' + assert filtered[1].data == 'both-match-2' + + +# UTS: realtime/unit/RTL22a/filter-matching-clientid-2 +@deviation +async def test_rtl22a_filter_matching_clientid(): + channel_name = 'test-RTL22a-clientid' + filtered = [] + + mock_ws = attaching_mock(channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + await channel.attach() + + await channel.subscribe(message_filter(client_id='user-42'), recorder(filtered)) + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'chat', 'data': 'hello', 'clientId': 'user-42'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'chat', 'data': 'hi', 'clientId': 'user-99'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'system', 'data': 'broadcast'}])) + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'chat', 'data': 'world', 'clientId': 'user-42'}])) + await poll_until(lambda: len(filtered) == 2, description='both messages from user-42 are delivered') + await settle() + + assert len(filtered) == 2 + assert filtered[0].data == 'hello' + assert filtered[0].client_id == 'user-42' + assert filtered[1].data == 'world' + assert filtered[1].client_id == 'user-42' diff --git a/test/uts/realtime/unit/channels/channel_update_delete_message_test.py b/test/uts/realtime/unit/channels/channel_update_delete_message_test.py new file mode 100644 index 00000000..6af97755 --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_update_delete_message_test.py @@ -0,0 +1,246 @@ +"""Derived from uts/realtime/unit/channels/channel_update_delete_message.md in ably/specification. + +Spec points: RTL32, RTL32a, RTL32b, RTL32b1, RTL32b2, RTL32c, RTL32d, RTL32e + +An update, delete or append is a MESSAGE protocol message and so requires an ACK; the +mock answers each one, as the specification's own handlers do. The specification writes +the ACK's `res` as a single object, while the protocol carries one entry per +ProtocolMessage, so the ACKs below wrap it in an array. +""" + +import uuid + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.message import Message, MessageAction +from ably.types.operations import MessageOperation, UpdateDeleteResult +from ably.util.exceptions import AblyException +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +ACK_ACTION = 1 +NACK_ACTION = 2 + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def ack(msg, serials=('version-serial-1',)): + return { + 'action': ACK_ACTION, + 'msgSerial': msg['msgSerial'], + 'count': 1, + 'res': [{'serials': list(serials)}], + } + + +def nack(msg, code, message): + return { + 'action': NACK_ACTION, + 'msgSerial': msg['msgSerial'], + 'count': 1, + 'error': {'code': code, 'statusCode': code // 100, 'message': message}, + } + + +def updating_mock(channel_name, captured_messages=None, answer=ack, serials=('version-serial-1',)): + """A mock which connects, attaches `channel_name` and answers each MESSAGE. + + `answer` builds the ACK or NACK the mock replies with, so that a test can pin + the version serial the ACK carries or reject the operation outright. + """ + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if captured_messages is not None: + captured_messages.append(msg) + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.MESSAGE: + mock_ws.send_to_client(answer(msg)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def attached_channel(mock_ws, channel_name, **kwargs): + """A channel attached over `mock_ws`, which every test here starts from.""" + client = realtime_client(mock_ws, **kwargs) + channel = client.channels.get(channel_name) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + return channel + + +def sent_messages(captured_messages): + """The MESSAGE protocol messages among those the client sent.""" + return [msg for msg in captured_messages if msg.get('action') == ProtocolMessageAction.MESSAGE] + + +# UTS: realtime/unit/RTL32b/update-message-action-0 +async def test_rtl32b_update_message_action(): + channel_name = f'test-RTL32-update-{random_id()}' + captured_messages = [] + + mock_ws = updating_mock(channel_name, captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.update_message(Message(serial='msg-serial-1', name='updated', data='new-data')) + + message_pms = sent_messages(captured_messages) + assert len(message_pms) == 1 + + message_pm = message_pms[0] + assert message_pm['channel'] == channel_name + assert len(message_pm['messages']) == 1 + + msg = message_pm['messages'][0] + assert msg['action'] == MessageAction.MESSAGE_UPDATE + assert msg['serial'] == 'msg-serial-1' + assert msg['name'] == 'updated' + assert msg['data'] == 'new-data' + + +# UTS: realtime/unit/RTL32b/delete-message-action-1 +async def test_rtl32b_delete_message_action(): + channel_name = f'test-RTL32-delete-{random_id()}' + captured_messages = [] + + mock_ws = updating_mock(channel_name, captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.delete_message(Message(serial='msg-serial-1')) + + msg = sent_messages(captured_messages)[0]['messages'][0] + assert msg['action'] == MessageAction.MESSAGE_DELETE + assert msg['serial'] == 'msg-serial-1' + + +# UTS: realtime/unit/RTL32b/append-message-action-2 +async def test_rtl32b_append_message_action(): + channel_name = f'test-RTL32-append-{random_id()}' + captured_messages = [] + + mock_ws = updating_mock(channel_name, captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.append_message( + Message(serial='msg-serial-1', data='appended-data'), + operation=MessageOperation(description='appended content'), + ) + + msg = sent_messages(captured_messages)[0]['messages'][0] + assert msg['action'] == MessageAction.MESSAGE_APPEND + assert msg['serial'] == 'msg-serial-1' + assert msg['data'] == 'appended-data' + + +# UTS: realtime/unit/RTL32b2/version-from-operation-0 +async def test_rtl32b2_version_from_operation(): + channel_name = f'test-RTL32b2-{random_id()}' + captured_messages = [] + + mock_ws = updating_mock(channel_name, captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.update_message( + Message(serial='msg-serial-1', data='v2'), + operation=MessageOperation(description='edited content', metadata={'reason': 'typo'}), + ) + await channel.update_message(Message(serial='msg-serial-2', data='v2')) + + message_pms = sent_messages(captured_messages) + assert len(message_pms) == 2 + + msg_with_op = message_pms[0]['messages'][0] + assert msg_with_op['version']['description'] == 'edited content' + assert msg_with_op['version']['metadata']['reason'] == 'typo' + + msg_without_op = message_pms[1]['messages'][0] + assert 'version' not in msg_without_op + + +# UTS: realtime/unit/RTL32c/no-message-mutation-0 +async def test_rtl32c_no_message_mutation(): + channel_name = f'test-RTL32c-{random_id()}' + + mock_ws = updating_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name) + + original_message = Message(serial='msg-serial-1', name='original', data='original-data') + await channel.update_message(original_message) + + assert original_message.name == 'original' + assert original_message.data == 'original-data' + assert original_message.serial == 'msg-serial-1' + assert original_message.action is None + + +# UTS: realtime/unit/RTL32d/ack-returns-result-0 +async def test_rtl32d_ack_returns_result(): + channel_name = f'test-RTL32d-{random_id()}' + version_serial = '01770000000000-000@abcdef:000' + + mock_ws = updating_mock(channel_name, answer=lambda msg: ack(msg, serials=(version_serial,))) + channel = await attached_channel(mock_ws, channel_name) + + result = await channel.update_message(Message(serial='msg-serial-1', data='updated')) + + assert isinstance(result, UpdateDeleteResult) + assert result.version_serial == version_serial + + +# UTS: realtime/unit/RTL32d/nack-returns-error-1 +async def test_rtl32d_nack_returns_error(): + channel_name = f'test-RTL32d-nack-{random_id()}' + + mock_ws = updating_mock( + channel_name, answer=lambda msg: nack(msg, 40160, 'Not permitted')) + channel = await attached_channel(mock_ws, channel_name) + + with pytest.raises(AblyException) as excinfo: + await channel.update_message(Message(serial='msg-serial-1', data='updated')) + + assert excinfo.value.code == 40160 + + +# UTS: realtime/unit/RTL32e/params-in-protocol-message-0 +async def test_rtl32e_params_in_protocol_message(): + channel_name = f'test-RTL32e-{random_id()}' + captured_messages = [] + + mock_ws = updating_mock(channel_name, captured_messages) + channel = await attached_channel(mock_ws, channel_name) + + await channel.update_message( + Message(serial='msg-serial-1', data='v2'), + params={'key1': 'value1', 'key2': 'value2'}, + ) + + message_pm = sent_messages(captured_messages)[0] + assert message_pm['params']['key1'] == 'value1' + assert message_pm['params']['key2'] == 'value2' + + +# UTS: realtime/unit/RTL32a/serial-validation-required-0 +async def test_rtl32a_serial_validation_required(): + channel_name = f'test-RTL32a-{random_id()}' + + mock_ws = updating_mock(channel_name) + channel = await attached_channel(mock_ws, channel_name) + + with pytest.raises(AblyException) as empty_serial: + await channel.update_message(Message(serial='', data='v2')) + assert empty_serial.value.code == 40003 + + with pytest.raises(AblyException) as missing_serial: + await channel.delete_message(Message(data='v2')) + assert missing_serial.value.code == 40003 diff --git a/test/uts/realtime/unit/channels/channel_when_state_test.py b/test/uts/realtime/unit/channels/channel_when_state_test.py new file mode 100644 index 00000000..76bd651e --- /dev/null +++ b/test/uts/realtime/unit/channels/channel_when_state_test.py @@ -0,0 +1,160 @@ +"""Derived from uts/realtime/unit/channels/channel_when_state_test.md in ably/specification. + +Spec points: RTL25, RTL25a, RTL25b +""" + +import asyncio + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + MockWebSocket, + attached_message, + connected_message, + detached_message, +) + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + +# What the specification's WAIT(200) allows for a resolution that must not happen +NON_RESOLUTION_WINDOW = 0.2 + + +def echo_attach_and_detach(mock_ws): + """A handler confirming each ATTACH with an ATTACHED and each DETACH with a DETACHED.""" + def on_message_from_client(msg): + action = msg.get('action') + if action == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(msg.get('channel'), flags=0)) + elif action == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(msg.get('channel'))) + return on_message_from_client + + +def when_state(channel, state): + """The specification's `channel.whenState(state)`. + + RTL25 puts `whenState` on RealtimeChannel, mirroring `Connection#whenState` + (RTN26). ably-python has `Connection._when_state` but nothing on + RealtimeChannel, so this raises AttributeError. See + deviations.md + """ + return channel.when_state(state) + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTL25a/resolves-immediately-current-0 +@deviation +async def test_rtl25a_resolves_immediately_current(): + channel_name = 'test-RTL25a-immediate' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + result = await asyncio.wait_for(when_state(channel, ChannelState.ATTACHED), OPERATION_TIMEOUT) + + assert result is None + + +# UTS: realtime/unit/RTL25b/waits-for-state-change-0 +@deviation +async def test_rtl25b_waits_for_state_change(): + channel_name = 'test-RTL25b-deferred' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + when_attached = asyncio.ensure_future(when_state(channel, ChannelState.ATTACHED)) + await settle() + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + result = await asyncio.wait_for(when_attached, OPERATION_TIMEOUT) + + assert result is not None + assert result.current == ChannelState.ATTACHED + assert result.previous in (ChannelState.INITIALIZED, ChannelState.ATTACHING) + + +# UTS: realtime/unit/RTL25b/fires-once-only-1 +@deviation +async def test_rtl25b_fires_once_only(): + channel_name = 'test-RTL25b-once' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + attach_count = [] + + def count_attached(change): + attach_count.append(change) + + channel.once(ChannelState.ATTACHED, count_attached) + + when_attached = asyncio.ensure_future(when_state(channel, ChannelState.ATTACHED)) + await settle() + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + result = await asyncio.wait_for(when_attached, OPERATION_TIMEOUT) + assert result is not None + assert len(attach_count) == 1 + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await settle() + + assert len(attach_count) == 1 + + +# UTS: realtime/unit/RTL25a/past-state-does-not-resolve-1 +@deviation +async def test_rtl25a_past_state_does_not_resolve(): + channel_name = 'test-RTL25a-past' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = echo_attach_and_detach(mock_ws) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + # ATTACHING was passed through on the way here; whenState reads the current + # state, not the states already visited + when_attaching = asyncio.ensure_future(when_state(channel, ChannelState.ATTACHING)) + await asyncio.sleep(NON_RESOLUTION_WINDOW) + + assert not when_attaching.done() + when_attaching.cancel() diff --git a/test/uts/realtime/unit/channels/channels_collection_test.py b/test/uts/realtime/unit/channels/channels_collection_test.py new file mode 100644 index 00000000..1e434fed --- /dev/null +++ b/test/uts/realtime/unit/channels/channels_collection_test.py @@ -0,0 +1,200 @@ +"""Derived from uts/realtime/unit/channels/channels_collection.md in ably/specification. + +Spec points: RTS1, RTS2, RTS3a, RTS4a + +The specification's `channels.exists(name)` and `channels.names` are spelled +`name in channels` and iteration over the collection in ably-python, and +`channels.release(name)` is synchronous. Reading an attribute the collection +does not define creates a channel of that name (`Channels.__getattr__` in +`ably/rest/channel.py`), so these tests never name one that is not there. +""" + +import asyncio + +from ably.realtime.channel import Channels as RealtimeChannels +from ably.realtime.channel import RealtimeChannel +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE, + MockWebSocket, + attached_message, + detached_message, +) + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 1.0 + + +# UTS: realtime/unit/RTS1/channels-collection-accessible-0 +async def test_rts1_channels_collection_accessible(): + client = realtime_client() + + channels = client.channels + + assert isinstance(channels, RealtimeChannels) + assert channels is not None + + +# UTS: realtime/unit/RTS2/channel-exists-check-0 +async def test_rts2_channel_exists_check(): + channel_name = 'test-RTS2' + other_channel_name = 'test-RTS2-other' + client = realtime_client() + + # `exists(name)` in the specification; `Channels.__contains__` here + exists_before = channel_name in client.channels + + client.channels.get(channel_name) + + exists_after = channel_name in client.channels + exists_other = other_channel_name in client.channels + + assert exists_before is False + assert exists_after is True + assert exists_other is False + + +# UTS: realtime/unit/RTS2/iterate-channels-1 +async def test_rts2_iterate_channels(): + channel_name_a = 'test-RTS2-a' + channel_name_b = 'test-RTS2-b' + channel_name_c = 'test-RTS2-c' + client = realtime_client() + + client.channels.get(channel_name_a) + client.channels.get(channel_name_b) + client.channels.get(channel_name_c) + + # The specification's `channels.names`; iterating the collection yields the + # channels themselves, each of which carries its name + names = [channel.name for channel in client.channels] + + assert channel_name_a in names + assert channel_name_b in names + assert channel_name_c in names + assert len(names) == 3 + + +# UTS: realtime/unit/RTS3a/get-creates-new-channel-0 +async def test_rts3a_get_creates_new_channel(): + channel_name = 'test-RTS3a' + client = realtime_client() + + channel = client.channels.get(channel_name) + + assert isinstance(channel, RealtimeChannel) + assert channel.name == channel_name + assert (channel_name in client.channels) is True + + +# UTS: realtime/unit/RTS3a/get-returns-existing-channel-1 +async def test_rts3a_get_returns_existing_channel(): + channel_name = 'test-RTS3a-existing' + client = realtime_client() + + channel1 = client.channels.get(channel_name) + channel2 = client.channels.get(channel_name) + + assert channel1 is channel2 + assert channel1.name == channel_name + assert channel2.name == channel_name + + +# UTS: realtime/unit/RTS3a/subscript-operator-channel-2 +async def test_rts3a_subscript_operator_channel(): + channel_name = 'test-RTS3a-subscript' + client = realtime_client() + + channel1 = client.channels[channel_name] + channel2 = client.channels.get(channel_name) + channel3 = client.channels[channel_name] + + assert channel1 is channel2 + assert channel2 is channel3 + assert channel1.name == channel_name + + +# UTS: realtime/unit/RTS4a/release-removes-channel-0 +async def test_rts4a_release_removes_channel(): + channel_name = 'test-RTS4a' + client = realtime_client() + + client.channels.get(channel_name) + assert (channel_name in client.channels) is True + + # `release` is synchronous here, so there is nothing to await + client.channels.release(channel_name) + + assert (channel_name in client.channels) is False + + +# UTS: realtime/unit/RTS4a/release-nonexistent-noop-1 +async def test_rts4a_release_nonexistent_noop(): + channel_name = 'test-RTS4a-nonexistent' + client = realtime_client() + + client.channels.release(channel_name) + + assert (channel_name in client.channels) is False + + +# UTS: realtime/unit/RTS4a/release-detaches-attached-2 +@deviation +async def test_rts4a_release_detaches_attached(): + # DEVIATION: RTS4a has release detach the channel before dropping it. + # `Channels.release` (`ably/realtime/channel.py:1012`) only deletes the entry from + # its dict, so no DETACH is sent and the channel is left attached in the Ably service. + channel_name = 'test-RTS4a-attached' + messages_from_client = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + messages_from_client.append(msg) + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg.get('action') == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel = client.channels.get(channel_name) + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.ATTACHED + + state_before_release = channel.state + + client.channels.release(channel_name) + await asyncio.sleep(0) + + assert state_before_release == ChannelState.ATTACHED + assert (channel_name in client.channels) is False + detach_messages = [m for m in messages_from_client if m.get('action') == ProtocolMessageAction.DETACH] + assert len(detach_messages) == 1 + assert channel.state == ChannelState.DETACHED + + +# UTS: realtime/unit/RTS3a/get-after-release-new-3 +async def test_rts3a_get_after_release_new(): + channel_name = 'test-RTS3a-release' + client = realtime_client() + + channel1 = client.channels.get(channel_name) + + client.channels.release(channel_name) + + channel2 = client.channels.get(channel_name) + + assert channel1 is not channel2 + assert channel2.name == channel_name + assert (channel_name in client.channels) is True diff --git a/test/uts/realtime/unit/channels/message_field_population_test.py b/test/uts/realtime/unit/channels/message_field_population_test.py new file mode 100644 index 00000000..8efcd7df --- /dev/null +++ b/test/uts/realtime/unit/channels/message_field_population_test.py @@ -0,0 +1,230 @@ +"""Derived from uts/realtime/unit/channels/message_field_population.md in ably/specification. + +Spec points: TM2a, TM2c, TM2f +""" + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import ( + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def message_protocol_message(channel_name, messages, **fields): + """A MESSAGE protocol message carrying `messages` on `channel_name`.""" + return { + 'action': int(ProtocolMessageAction.MESSAGE), + 'channel': channel_name, + 'messages': messages, + **fields, + } + + +def attaching_mock(channel_name): + """A mock which connects and confirms an attach for `channel_name`.""" + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg.get('action') == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def subscribed_channel(mock_ws, channel_name, received): + """A connected client whose `channel_name` is attached with `received` subscribed. + + The specification subscribes before connecting; `subscribe` here awaits the + implicit attach, which needs a connection, so the client connects first. + """ + client = realtime_client(mock_ws) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + channel = client.channels.get(channel_name) + + def record(message): + received.append(message) + + await channel.subscribe(record) + return client, channel + + +# UTS: realtime/unit/TM2a/id-from-protocol-message-0 +async def test_tm2a_id_from_protocol_message(): + channel_name = 'test-TM2a-id' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [ + {'name': 'first', 'data': 'a'}, + {'name': 'second', 'data': 'b'}, + {'name': 'third', 'data': 'c'}, + ], + id='abc123:5', + connectionId='abc123', + timestamp=1700000000000, + )) + await poll_until(lambda: len(received) == 3, description='three messages are delivered') + + assert received[0].id == 'abc123:5:0' + assert received[1].id == 'abc123:5:1' + assert received[2].id == 'abc123:5:2' + + +# UTS: realtime/unit/TM2a/existing-id-not-overwritten-1 +async def test_tm2a_existing_id_not_overwritten(): + channel_name = 'test-TM2a-existing' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [{'id': 'my-custom-id', 'name': 'msg', 'data': 'hello'}], + id='proto-id:0', + )) + await poll_until(lambda: len(received) == 1, description='the message is delivered') + + assert received[0].id == 'my-custom-id' + + +# UTS: realtime/unit/TM2a/no-id-without-protocol-id-2 +@deviation +async def test_tm2a_no_id_without_protocol_id(): + channel_name = 'test-TM2a-no-proto-id' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [{'name': 'msg', 'data': 'hello'}], + connectionId='abc123', + )) + await poll_until(lambda: len(received) == 1, description='the message is delivered') + + assert received[0].id is None + + +# UTS: realtime/unit/TM2c/connectionid-from-protocol-0 +async def test_tm2c_connectionid_from_protocol(): + channel_name = 'test-TM2c-connId' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [{'name': 'msg', 'data': 'hello'}], + id='msg:0', + connectionId='server-conn-xyz', + )) + await poll_until(lambda: len(received) == 1, description='the message is delivered') + + assert received[0].connection_id == 'server-conn-xyz' + + +# UTS: realtime/unit/TM2c/existing-connectionid-kept-1 +async def test_tm2c_existing_connectionid_kept(): + channel_name = 'test-TM2c-existing' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [{'connectionId': 'msg-conn', 'name': 'msg', 'data': 'hello'}], + id='msg:0', + connectionId='proto-conn', + )) + await poll_until(lambda: len(received) == 1, description='the message is delivered') + + assert received[0].connection_id == 'msg-conn' + + +# UTS: realtime/unit/TM2f/timestamp-from-protocol-0 +async def test_tm2f_timestamp_from_protocol(): + channel_name = 'test-TM2f-timestamp' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [{'name': 'msg', 'data': 'hello'}], + id='msg:0', + timestamp=1700000000000, + )) + await poll_until(lambda: len(received) == 1, description='the message is delivered') + + assert received[0].timestamp == 1700000000000 + + +# UTS: realtime/unit/TM2f/existing-timestamp-kept-1 +async def test_tm2f_existing_timestamp_kept(): + channel_name = 'test-TM2f-existing' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [{'timestamp': 1600000000000, 'name': 'msg', 'data': 'hello'}], + id='msg:0', + timestamp=1700000000000, + )) + await poll_until(lambda: len(received) == 1, description='the message is delivered') + + assert received[0].timestamp == 1600000000000 + + +# UTS: realtime/unit/TM2a/all-fields-populated-together-3 +async def test_tm2a_all_fields_populated_together(): + channel_name = 'test-TM2-all-fields' + received = [] + + mock_ws = attaching_mock(channel_name) + await subscribed_channel(mock_ws, channel_name, received) + + mock_ws.send_to_client(message_protocol_message( + channel_name, + [ + {'name': 'first', 'data': 'a'}, + {'name': 'second', 'data': 'b'}, + ], + id='connId:7', + connectionId='connId', + timestamp=1700000000000, + )) + await poll_until(lambda: len(received) == 2, description='two messages are delivered') + + assert received[0].id == 'connId:7:0' + assert received[0].connection_id == 'connId' + assert received[0].timestamp == 1700000000000 + assert received[0].name == 'first' + assert received[0].data == 'a' + + assert received[1].id == 'connId:7:1' + assert received[1].connection_id == 'connId' + assert received[1].timestamp == 1700000000000 + assert received[1].name == 'second' + assert received[1].data == 'b' diff --git a/test/uts/realtime/unit/client/__init__.py b/test/uts/realtime/unit/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/client/realtime_client_test.py b/test/uts/realtime/unit/client/realtime_client_test.py new file mode 100644 index 00000000..2e765a4c --- /dev/null +++ b/test/uts/realtime/unit/client/realtime_client_test.py @@ -0,0 +1,341 @@ +"""Derived from uts/realtime/unit/client/realtime_client.md in ably/specification. + +Spec points: RTC1a, RTC1b, RTC1c, RTC1f, RTC2, RTC3, RTC4, RTC12, RTC13, RTC15, +RTC16, RTC17 +""" + +import json + +import pytest + +from ably.realtime.channel import Channels, RealtimeChannel +from ably.realtime.connection import Connection, ConnectionState +from ably.rest.auth import Auth +from ably.rest.push import Push, PushAdmin +from ably.transport.websockettransport import ProtocolMessageAction +from ably.util.exceptions import AblyException +from ably.util.helper import get_random_id +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import CLOSED_MESSAGE, MockEventType, MockWebSocket, connected_message + +SPEC_KEY = 'appId.keyId:keySecret' + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def succeeding_mock(): + return MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + +def encode_recovery_key(connection_key, msg_serial, channel_serials): + """The RTN16 recovery key: a JSON object carrying the previous connection's state.""" + return json.dumps({ + 'connectionKey': connection_key, + 'msgSerial': msg_serial, + 'channelSerials': channel_serials, + }) + + +# UTS: realtime/unit/RTC12/constructor-string-detection-0 +async def test_rtc12_constructor_string_detection(): + # NOTE: the spec refers this test to `uts/test/realtime/unit/client/client_options.md` + # for RSC1/RSC1a/RSC1c. No such file exists in the specification repository, and + # neither do derived RSC1 tests, so the three cases the spec lists in its own body + # are what is asserted here. See deviations.md. + mock_ws = succeeding_mock() + + # An API key string carries a `:` and selects basic auth + client = realtime_client(mock_ws, key=SPEC_KEY) + assert client.auth.auth_mechanism == Auth.Method.BASIC + assert client.options.key_name == 'appId.keyId' + assert client.options.key_secret == 'keySecret' + + # DEVIATION: the spec requires a string with no `:` to be detected as a token and + # to select token auth. ably-python reads a string argument only as an API key and + # rejects one that does not split in two. + with pytest.raises(AblyException) as excinfo: + realtime_client(mock_ws, key='token-string-with-no-delimiter') + assert excinfo.value.code == 40101 + + # An empty string is an error, as the spec requires + with pytest.raises(AblyException) as excinfo: + realtime_client(mock_ws, key='') + assert excinfo.value.code == 40101 + + +# UTS: realtime/unit/RTC12/invalid-arguments-error-1 +async def test_rtc12_invalid_arguments_error(): + mock_ws = succeeding_mock() + + # DEVIATION: the spec expects error code 40106 when no valid credentials are + # provided. ably-python rejects the options in the constructor instead, with a + # plain ValueError that carries no Ably error code. This mirrors the REST suite's + # `RSC1b/no-auth-method-error-0`. + with pytest.raises(ValueError) as excinfo: + realtime_client(mock_ws, key=None) + + assert 'key is missing' in str(excinfo.value) + + assert mock_ws.connection_attempts == [] + + +# UTS: realtime/unit/RTC2/connection-attribute-0 +async def test_rtc2_connection_attribute(): + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=False) + + assert client.connection is not None + assert isinstance(client.connection, Connection) + # The spec's type assertion, rendered for a weakly typed language as the + # interface the Connection is required to carry + assert callable(client.connection.connect) + assert callable(client.connection.close) + assert callable(client.connection.on) + + assert client.connection.state == ConnectionState.INITIALIZED + + +# UTS: realtime/unit/RTC3/channels-attribute-0 +async def test_rtc3_channels_attribute(): + channel_name = f'test-RTC3-{get_random_id()}' + + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=False) + + assert client.channels is not None + assert isinstance(client.channels, Channels) + + channel = client.channels.get(channel_name) + assert isinstance(channel, RealtimeChannel) + assert channel.name == channel_name + + +# UTS: realtime/unit/RTC4/auth-attribute-0 +async def test_rtc4_auth_attribute(): + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=False) + + assert client.auth is not None + assert isinstance(client.auth, Auth) + assert callable(client.auth.authorize) + assert callable(client.auth.request_token) + + +# UTS: realtime/unit/RTC13/push-attribute-0 +async def test_rtc13_push_attribute(): + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=False) + + assert client.push is not None + assert isinstance(client.push, Push) + assert client.push.admin is not None + assert isinstance(client.push.admin, PushAdmin) + + +# UTS: realtime/unit/RTC17/client-id-attribute-0 +async def test_rtc17_client_id_attribute(): + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, client_id='explicit-client-id', auto_connect=False) + + assert client.client_id == 'explicit-client-id' + + # DEVIATION: the spec asserts `client.clientId == client.auth.clientId`. + # `AblyRealtime.client_id` reads the client options, while `Auth.client_id` is + # held at None for a realtime client until the server confirms one in a CONNECTED + # message, so the two disagree before the connection is established. + assert client.auth.client_id is None + + +# UTS: realtime/unit/RTC1a/echo-messages-option-0 +# DEVIATION: ably-python has no `echo_messages` option and sends no `echo` query +# parameter. See deviations.md. +@deviation +async def test_rtc1a_echo_messages_option(): + # RTC1a_1: echoMessages defaults to true + mock_ws = MockWebSocket() + realtime_client(mock_ws, key=SPEC_KEY, auto_connect=True) + + pending = await mock_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert 'echo' in pending.url.query_params + assert pending.url.query_params['echo'] == 'true' + + # RTC1a_2: echoMessages set to false + other_ws = MockWebSocket() + realtime_client(other_ws, key=SPEC_KEY, auto_connect=True, echo_messages=False) + + pending = await other_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert pending.url.query_params['echo'] == 'false' + + +# UTS: realtime/unit/RTC1b/auto-connect-option-0 +async def test_rtc1b_auto_connect_option(): + # RTC1b_1: autoConnect defaults to true. `realtime_client` defaults it to false, + # so the option's own default is named here + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=True) + + await await_connection_state(client, ConnectionState.CONNECTED) + assert len(mock_ws.connection_attempts) >= 1 + + # RTC1b_2: autoConnect set to false + idle_ws = succeeding_mock() + idle_client = realtime_client(idle_ws, key=SPEC_KEY, auto_connect=False) + + assert idle_client.connection.state == ConnectionState.INITIALIZED + assert len(idle_ws.connection_attempts) == 0 + + await settle() + + assert idle_client.connection.state == ConnectionState.INITIALIZED + assert len(idle_ws.connection_attempts) == 0 + + # RTC1b_3: explicit connect after autoConnect false + idle_client.connection.connect() + await await_connection_state(idle_client, ConnectionState.CONNECTED) + + assert len(idle_ws.events_of_type(MockEventType.CONNECTION_ATTEMPT)) == 1 + assert idle_client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTC1c/recover-option-0 +# DEVIATION: the `recover` option is stored and never read, so no `recover` query +# parameter is ever sent. See deviations.md. +@deviation +async def test_rtc1c_recover_option(): + recovery_key = encode_recovery_key('previous-connection-key', 5, {'channel1': 'serial1'}) + + # RTC1c_1: the recover string is sent in the connection request + mock_ws = MockWebSocket() + realtime_client(mock_ws, key=SPEC_KEY, auto_connect=True, recover=recovery_key) + + pending = await mock_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert 'recover' in pending.url.query_params + assert pending.url.query_params['recover'] == 'previous-connection-key' + + # RTC1c_2: the recover option is cleared after the first attempt (RTN16k) + states = [] + resuming_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + resuming_client = realtime_client( + resuming_ws, key=SPEC_KEY, auto_connect=True, + recover=encode_recovery_key('previous-connection-key', 5, {})) + resuming_client.connection.on(lambda change: states.append(change.current)) + + await await_connection_state(resuming_client, ConnectionState.CONNECTED) + + resuming_ws.simulate_disconnect() + await settle() + + assert ConnectionState.DISCONNECTED in states + assert len(resuming_ws.connection_attempts) >= 2 + assert 'recover' not in resuming_ws.connection_attempts[1].url.query_params + + # RTC1c_3: an invalid recovery key is handled gracefully + invalid_ws = MockWebSocket() + realtime_client(invalid_ws, key=SPEC_KEY, auto_connect=True, + recover='invalid-not-a-valid-recovery-key') + + pending = await invalid_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert 'recover' not in pending.url.query_params + + +# UTS: realtime/unit/RTC1f/transport-params-option-0 +async def test_rtc1f_transport_params_option(): + # RTC1f_1: transportParams are included in the connection URL + mock_ws = MockWebSocket() + realtime_client(mock_ws, key=SPEC_KEY, auto_connect=True, transport_params={ + 'customParam': 'customValue', + 'anotherParam': '123', + }) + + pending = await mock_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert pending.url.query_params['customParam'] == 'customValue' + assert pending.url.query_params['anotherParam'] == '123' + + # RTC1f_2: transportParams carrying values of other types + typed_ws = MockWebSocket() + realtime_client(typed_ws, key=SPEC_KEY, auto_connect=True, transport_params={ + 'stringParam': 'hello', + 'numberParam': 42, + 'boolTrueParam': True, + 'boolFalseParam': False, + }) + + pending = await typed_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert pending.url.query_params['stringParam'] == 'hello' + assert pending.url.query_params['numberParam'] == '42' + # DEVIATION: the spec requires booleans to be stringified as "true" and "false". + # The query string is built with `urllib.parse.urlencode`, which renders a bool + # through `str()`, giving Python's capitalised spelling. + assert pending.url.query_params['boolTrueParam'] == 'True' + assert pending.url.query_params['boolFalseParam'] == 'False' + + # RTC1f1: user-specified transportParams override library defaults + override_ws = MockWebSocket() + realtime_client(override_ws, key=SPEC_KEY, auto_connect=True, transport_params={ + 'v': '3', + 'heartbeats': 'false', + }) + + pending = await override_ws.await_connection_attempt() + pending.respond_with_success(CONNECTED_MESSAGE) + + assert pending.url.query_params['v'] == '3' + assert pending.url.query_params['heartbeats'] == 'false' + + +# UTS: realtime/unit/RTC15/connect-method-0 +async def test_rtc15_connect_method(): + states = [] + mock_ws = succeeding_mock() + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=False) + client.connection.on(lambda change: states.append(change.current)) + + assert client.connection.state == ConnectionState.INITIALIZED + + client.connect() + + await await_connection_state(client, ConnectionState.CONNECTED) + + assert ConnectionState.CONNECTING in states + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTC16/close-method-0 +async def test_rtc16_close_method(): + def on_message_from_client(message): + if message['action'] == int(ProtocolMessageAction.CLOSE): + mock_ws.send_to_client(CLOSED_MESSAGE) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client, + ) + client = realtime_client(mock_ws, key=SPEC_KEY, auto_connect=True) + + await await_connection_state(client, ConnectionState.CONNECTED) + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + await client.close() + + assert ConnectionState.CLOSING in states + assert client.connection.state == ConnectionState.CLOSED diff --git a/test/uts/realtime/unit/client/realtime_request_test.py b/test/uts/realtime/unit/client/realtime_request_test.py new file mode 100644 index 00000000..3a0fe289 --- /dev/null +++ b/test/uts/realtime/unit/client/realtime_request_test.py @@ -0,0 +1,50 @@ +"""Derived from uts/realtime/unit/client/realtime_request.md in ably/specification. + +Spec points: RTC9 +""" + +from test.uts.helpers.client import realtime_client +from test.uts.helpers.mock_http import MockHttpClient + +SPEC_KEY = 'appId.keyId:keySecret' + +ITEMS = [ + {'id': 'msg1', 'name': 'event1', 'data': 'data1'}, + {'id': 'msg2', 'name': 'event2', 'data': 'data2'}, +] + + +# UTS: realtime/unit/RTC9/request-proxies-rest-0 +async def test_rtc9_request_proxies_rest(): + # The specification directs uts/rest/unit/request.md (RSC19) at a realtime client + # in place of a REST one. `AblyRealtime` subclasses `AblyRest`, so the same HTTP + # mock serves it, and this mirrors that suite's `RSC19f/supports-http-methods-0` + # and `RSC19d/response-items-decoded-5`. + captured_requests = [] + + def on_request(request): + captured_requests.append(request) + request.respond_with(200, ITEMS) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + # `auto_connect` is left at `realtime_client`'s false, so no websocket is opened + client = realtime_client(mock_http=mock_http, key=SPEC_KEY) + + # NOTE: request() takes version as a string in ably-python; the spec writes `version: 3` + response = await client.request('GET', '/channels/test/messages', version='3') + + assert response.status_code == 200 + assert response.success is True + + items = response.items + assert len(items) == 2 + assert items[0]['id'] == 'msg1' + assert items[1]['id'] == 'msg2' + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == 'GET' + assert request.url.path == '/channels/test/messages' diff --git a/test/uts/realtime/unit/client/realtime_stats_test.py b/test/uts/realtime/unit/client/realtime_stats_test.py new file mode 100644 index 00000000..ad9d8a44 --- /dev/null +++ b/test/uts/realtime/unit/client/realtime_stats_test.py @@ -0,0 +1,61 @@ +"""Derived from uts/realtime/unit/client/realtime_stats.md in ably/specification. + +Spec points: RTC5, RTC5a, RTC5b +""" + +from ably.http.paginatedresult import PaginatedResult +from test.uts.helpers.client import realtime_client +from test.uts.helpers.mock_http import MockHttpClient + +STATS_DATA = [ + { + 'intervalId': '2024-01-01:00:00', + 'unit': 'hour', + 'all': { + 'messages': {'count': 100, 'data': 5000}, + 'all': {'count': 100, 'data': 5000}, + }, + }, + { + 'intervalId': '2024-01-01:01:00', + 'unit': 'hour', + 'all': { + 'messages': {'count': 150, 'data': 7500}, + 'all': {'count': 150, 'data': 7500}, + }, + }, +] + + +# UTS: realtime/unit/RTC5/stats-proxies-rest-0 +async def test_rtc5_stats_proxies_rest(): + # The specification directs uts/rest/unit/stats.md (RSC6) at a realtime client + # in place of a REST one. `AblyRealtime` subclasses `AblyRest`, so the same HTTP + # mock serves it, and this mirrors that suite's `RSC6a/returns-paginated-stats-0`. + captured_requests = [] + + def on_request(request): + captured_requests.append(request) + request.respond_with(200, STATS_DATA) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + # `auto_connect` is left at `realtime_client`'s false, so no websocket is opened + client = realtime_client(mock_http=mock_http) + + result = await client.stats() + + assert isinstance(result, PaginatedResult) + assert len(result.items) == 2 + + # NOTE: the spec spells the field intervalId; ably-python exposes it as interval_id. + assert result.items[0].interval_id == '2024-01-01:00:00' + assert result.items[0].unit == 'hour' + assert result.items[1].interval_id == '2024-01-01:01:00' + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == 'GET' + assert request.path == '/stats' diff --git a/test/uts/realtime/unit/client/realtime_time_test.py b/test/uts/realtime/unit/client/realtime_time_test.py new file mode 100644 index 00000000..64c1fa8e --- /dev/null +++ b/test/uts/realtime/unit/client/realtime_time_test.py @@ -0,0 +1,40 @@ +"""Derived from uts/realtime/unit/client/realtime_time.md in ably/specification. + +Spec points: RTC6, RTC6a +""" + +from test.uts.helpers.client import realtime_client +from test.uts.helpers.mock_http import MockHttpClient + +SERVER_TIME_MS = 1704067200000 + + +# UTS: realtime/unit/RTC6/time-proxies-rest-0 +async def test_rtc6_time_proxies_rest(): + # The specification directs uts/rest/unit/time.md (RSC16) at a realtime client + # in place of a REST one. `AblyRealtime` subclasses `AblyRest`, so the same + # HTTP mock serves it, and this mirrors that suite's `RSC16/returns-server-time-0`. + captured_requests = [] + + def on_request(request): + captured_requests.append(request) + request.respond_with(200, [SERVER_TIME_MS]) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + # `auto_connect` is left at `realtime_client`'s false, so no websocket is opened + client = realtime_client(mock_http=mock_http) + + result = await client.time() + + # NOTE: the REST spec asserts the result IS DateTime. Its stated requirement + # allows "a DateTime or timestamp", and time() returns milliseconds since the epoch. + assert isinstance(result, (int, float)) + assert result == SERVER_TIME_MS + + assert len(captured_requests) == 1 + request = captured_requests[0] + assert request.method == 'GET' + assert request.path == '/time' diff --git a/test/uts/realtime/unit/client/realtime_timeouts_test.py b/test/uts/realtime/unit/client/realtime_timeouts_test.py new file mode 100644 index 00000000..6b0d3eba --- /dev/null +++ b/test/uts/realtime/unit/client/realtime_timeouts_test.py @@ -0,0 +1,182 @@ +"""Derived from uts/realtime/unit/client/realtime_timeouts.md in ably/specification. + +Spec points: RTC7 +""" + +import asyncio + +import pytest + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.util.exceptions import AblyException +from ably.util.helper import get_random_id +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +SPEC_KEY = 'appId.keyId:keySecret' + +CONNECTED_MESSAGE = connected_message( + 'connection-id', connectionKey='connection-key', maxIdleInterval=15000, connectionStateTtl=120000) + +# The CONNECTED message the reconnection test uses. `maxIdleInterval` of zero leaves +# the transport's idle timer unscheduled, which keeps the only timers on the fake +# clock the ones this test is about. +IDLE_FREE_CONNECTED_MESSAGE = connected_message( + 'connection-id', connectionKey='connection-key', maxIdleInterval=0, connectionStateTtl=120000) + +CUSTOM_REQUEST_TIMEOUT = 500 + + +def attached_message(channel_name): + return { + 'action': int(ProtocolMessageAction.ATTACHED), + 'channel': channel_name, + 'flags': 0, + } + + +# UTS: realtime/unit/RTC7/attach-request-timeout-0 +async def test_rtc7_attach_request_timeout(): + channel_name = f'test-RTC7-attach-{get_random_id()}' + + # An ATTACH draws no response, so the channel's state timer is what ends the attach + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + clock = FakeClock() + client = realtime_client(mock_ws, clock=clock, key=SPEC_KEY, auto_connect=False, + realtime_request_timeout=CUSTOM_REQUEST_TIMEOUT) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # The attach runs as a task so that the clock can be advanced underneath it + attaching = asyncio.create_task(channel.attach()) + await settle() + + assert channel.state == ChannelState.ATTACHING + + await clock.advance(600) + + with pytest.raises(AblyException) as excinfo: + await attaching + + assert excinfo.value is not None + # RTL4f: an attach timeout leaves the channel SUSPENDED + assert channel.state == ChannelState.SUSPENDED + + +# UTS: realtime/unit/RTC7/detach-request-timeout-1 +async def test_rtc7_detach_request_timeout(): + channel_name = f'test-RTC7-detach-{get_random_id()}' + ignore_detach = False + + def on_message_from_client(message): + if message['action'] == int(ProtocolMessageAction.ATTACH): + mock_ws.send_to_client(attached_message(channel_name)) + if message['action'] == int(ProtocolMessageAction.DETACH) and ignore_detach: + # No response, so the channel's state timer is what ends the detach + pass + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=on_message_from_client, + ) + clock = FakeClock() + client = realtime_client(mock_ws, clock=clock, key=SPEC_KEY, auto_connect=False, + realtime_request_timeout=CUSTOM_REQUEST_TIMEOUT) + channel = client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await channel.attach() + + ignore_detach = True + + detaching = asyncio.create_task(channel.detach()) + await settle() + + assert channel.state == ChannelState.DETACHING + + await clock.advance(600) + + with pytest.raises(AblyException) as excinfo: + await detaching + + assert excinfo.value is not None + # RTL5f: a detach timeout returns the channel to ATTACHED + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTC7/disconnected-retry-timeout-2 +async def test_rtc7_disconnected_retry_timeout(): + connection_attempt_count = 0 + + def on_connection_attempt(conn): + nonlocal connection_attempt_count + connection_attempt_count += 1 + if connection_attempt_count == 1: + conn.respond_with_success(IDLE_FREE_CONNECTED_MESSAGE) + else: + # The spec refuses the attempt. `ws_connect` catches only WebSocketException + # and socket.gaierror, so a refused connection is left to the transition + # timer and takes `realtime_request_timeout` to surface; a DNS failure + # fails fast and reaches the retry logic the same way. See + # deviations.md. + conn.respond_with_dns_error() + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + # RTN17j's connectivity check is what the mock HTTP client keeps off the network + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=lambda request: request.respond_with(200, 'yes', {'Content-Type': 'text/plain'}), + ) + clock = FakeClock() + client = realtime_client(mock_ws, mock_http=mock_http, clock=clock, key=SPEC_KEY, + auto_connect=False, disconnected_retry_timeout=2000, fallback_hosts=[]) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + assert connection_attempt_count == 1 + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + # RTN15a makes the first retry after a drop from CONNECTED immediate, so the + # timer-driven retry this test is about is the one after that + mock_ws.simulate_disconnect() + await settle() + + assert ConnectionState.DISCONNECTED in states + count_after_immediate = connection_attempt_count + assert count_after_immediate > 1 + + await clock.advance(1500) + assert connection_attempt_count == count_after_immediate + + await clock.advance(1500) + + assert connection_attempt_count > count_after_immediate + + +# UTS: realtime/unit/RTC7/default-timeouts-applied-3 +async def test_rtc7_default_timeouts_applied(): + client = realtime_client(key=SPEC_KEY, auto_connect=False) + + assert client.options.realtime_request_timeout == 10000 + assert client.options.disconnected_retry_timeout == 15000 + assert client.options.suspended_retry_timeout == 30000 + + # DEVIATION: the spec asserts httpOpenTimeout == 4000 and httpRequestTimeout == + # 10000 on the options. ably-python leaves both unset on the options and holds the + # defaults on the HTTP layer, in seconds rather than milliseconds. See + # deviations.md. + assert client.options.http_open_timeout is None + assert client.options.http_request_timeout is None + assert client.http.http_open_timeout == 4 + assert client.http.http_request_timeout == 10 diff --git a/test/uts/realtime/unit/connection/__init__.py b/test/uts/realtime/unit/connection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/connection/auto_connect_test.py b/test/uts/realtime/unit/connection/auto_connect_test.py new file mode 100644 index 00000000..29e8e12f --- /dev/null +++ b/test/uts/realtime/unit/connection/auto_connect_test.py @@ -0,0 +1,66 @@ +"""Derived from uts/realtime/unit/connection/auto_connect_test.md in ably/specification. + +Spec points: RTN3 +""" + +from ably.realtime.connection import ConnectionState +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +# UTS: realtime/unit/RTN3/auto-connect-true-0 +async def test_rtn3_auto_connect_true(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + # `auto_connect` is the option's own default, and this test is about the + # default, so it is named rather than left to `realtime_client` + client = realtime_client(mock_ws, auto_connect=True) + + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.state == ConnectionState.CONNECTED + # The specification asserts `client.connection.id`; ably-python exposes the + # connection id on the connection manager instead + assert client.connection.connection_manager.connection_id == 'connection-id' + + +# UTS: realtime/unit/RTN3/auto-connect-false-1 +async def test_rtn3_auto_connect_false(): + connection_attempted = [] + + def on_connection_attempt(conn): + connection_attempted.append(conn) + conn.respond_with_success(CONNECTED_MESSAGE) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auto_connect=False) + + await settle() + + assert connection_attempted == [] + assert client.connection.state == ConnectionState.INITIALIZED + + +# UTS: realtime/unit/RTN3/explicit-connect-after-false-2 +async def test_rtn3_explicit_connect_after_false(): + connection_attempted = [] + + def on_connection_attempt(conn): + connection_attempted.append(conn) + conn.respond_with_success(CONNECTED_MESSAGE) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, auto_connect=False) + + assert client.connection.state == ConnectionState.INITIALIZED + assert connection_attempted == [] + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(connection_attempted) == 1 + assert client.connection.state == ConnectionState.CONNECTED diff --git a/test/uts/realtime/unit/connection/backoff_jitter_test.py b/test/uts/realtime/unit/connection/backoff_jitter_test.py new file mode 100644 index 00000000..51a484c6 --- /dev/null +++ b/test/uts/realtime/unit/connection/backoff_jitter_test.py @@ -0,0 +1,248 @@ +"""Derived from uts/realtime/unit/connection/backoff_jitter_test.md in ably/specification. + +Spec points: RTB1, RTB1a, RTB1b + +The specification reads the retry delay from `ConnectionStateChange.retryIn` and +`ChannelStateChange.retryIn`, and tests the backoff and jitter coefficients through +functions of their own. ably-python has neither the attribute nor the functions, so +each delay is measured here as the notional time between the state change that +schedules a retry and the state change the retry produces. A `FakeClock` is what +makes that measurable: the retry runs on the timer seam, and a timer's callback runs +with the clock reading exactly the time the timer was due, so the measurement is +exact rather than sampled. +""" + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +# Short enough that a refused attempt, which reaches no failure path of its own and +# so ends on the transition timer, does not crowd out the retry delays being measured +REQUEST_TIMEOUT = 50 + +# The connection is suspended once it has been disconnected for +# `Defaults.connection_state_ttl`, after which retries move to +# `suspended_retry_timeout`; every sample has to be taken before that +CONNECTION_STATE_TTL = 120000 + + +def expected_backoff(n): + """RTB1a: the backoff coefficient for the nth retry, 1-indexed.""" + return min((n + 2) / 3, 2) + + +def retry_delays(transitions, scheduling_state, retrying_state): + """The notional milliseconds each `scheduling_state` waited before the + `retrying_state` that followed it. + + A retry made with no delay at all is left out: RTN15a reconnects immediately + from the DISCONNECTED that follows a dropped connection, so that one carries no + retry delay to measure. + """ + delays = [] + scheduled_at = None + for at, state in transitions: + if state == scheduling_state: + scheduled_at = at + elif state == retrying_state and scheduled_at is not None: + if at > scheduled_at: + delays.append(at - scheduled_at) + scheduled_at = None + return delays + + +async def measure_disconnected_retry_delays(count, disconnected_retry_timeout): + """Drives a connection through repeated failed reconnections, returning the + delay before each retry.""" + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(CONNECTED_MESSAGE) + else: + conn.respond_with_refused() + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + clock = FakeClock() + client = realtime_client( + mock_ws, clock=clock, key='appId.keyId:keySecret', use_binary_protocol=False, + disconnected_retry_timeout=disconnected_retry_timeout, + realtime_request_timeout=REQUEST_TIMEOUT, + ) + + transitions = [] + client.connection.on(lambda change: transitions.append((clock.now, change.current))) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.simulate_disconnect() + await settle() + + while clock.now < CONNECTION_STATE_TTL: + await clock.advance(disconnected_retry_timeout * 2 + REQUEST_TIMEOUT) + delays = retry_delays(transitions, ConnectionState.DISCONNECTED, ConnectionState.CONNECTING) + if len(delays) >= count: + return delays + raise AssertionError(f'Only {len(delays)} retry delays were observed before the connection ' + 'state ttl expired') + + +# UTS: realtime/unit/RTB1a/backoff-coefficient-sequence-0 +@deviation +async def test_rtb1a_backoff_coefficient_sequence(): + # The specification calls a backoff coefficient function for retries 1 to 10. + # ably-python has no such function, so the coefficient of each retry is read + # back from the delay it was scheduled with + retry_timeout = 2000 + delays = await measure_disconnected_retry_delays(10, retry_timeout) + + coefficients = [delay / retry_timeout for delay in delays[:10]] + + # Each coefficient carries RTB1b's jitter, so it lands in [0.8, 1.0] of the + # backoff the specification gives as an exact value + assert expected_backoff(1) * 0.8 <= coefficients[0] <= expected_backoff(1) + assert expected_backoff(2) * 0.8 <= coefficients[1] <= expected_backoff(2) + assert expected_backoff(3) * 0.8 <= coefficients[2] <= expected_backoff(3) + assert expected_backoff(4) * 0.8 <= coefficients[3] <= expected_backoff(4) + + for coefficient in coefficients[3:]: + assert 2.0 * 0.8 <= coefficient <= 2.0 + + +# UTS: realtime/unit/RTB1b/jitter-coefficient-range-0 +@deviation +async def test_rtb1b_jitter_coefficient_range(): + # The specification samples a jitter generator 1000 times. ably-python has no + # such generator, so the jitter is read back from the delay of each retry, which + # costs a reconnection cycle apiece; 40 samples still separate a uniform + # distribution from a degenerate one, and fit inside the connection state ttl + sample_count = 40 + retry_timeout = 2000 + delays = await measure_disconnected_retry_delays(sample_count, retry_timeout) + + # Every retry from the fourth on has a backoff coefficient of 2, so the delay + # divided by twice the retry timeout is the jitter coefficient alone + jitter_values = [delay / (retry_timeout * 2.0) for delay in delays[3:sample_count]] + assert len(jitter_values) > 0 + + for jitter in jitter_values: + assert jitter >= 0.8 + assert jitter <= 1.0 + + mean = sum(jitter_values) / len(jitter_values) + assert mean >= 0.85 + assert mean <= 0.95 + + assert max(jitter_values) - min(jitter_values) > 0.05 + + +# UTS: realtime/unit/RTB1/disconnected-retry-delay-0 +@deviation +async def test_rtb1_disconnected_retry_delay(): + disconnected_retry_timeout = 2000 + retry_delays_observed = await measure_disconnected_retry_delays(5, disconnected_retry_timeout) + + assert len(retry_delays_observed) >= 5 + + assert retry_delays_observed[0] >= disconnected_retry_timeout * 1.0 * 0.8 + assert retry_delays_observed[0] <= disconnected_retry_timeout * 1.0 + + assert retry_delays_observed[1] >= disconnected_retry_timeout * (4.0 / 3.0) * 0.8 + assert retry_delays_observed[1] <= disconnected_retry_timeout * (4.0 / 3.0) + + assert retry_delays_observed[2] >= disconnected_retry_timeout * (5.0 / 3.0) * 0.8 + assert retry_delays_observed[2] <= disconnected_retry_timeout * (5.0 / 3.0) + + assert retry_delays_observed[3] >= disconnected_retry_timeout * 2.0 * 0.8 + assert retry_delays_observed[3] <= disconnected_retry_timeout * 2.0 + + assert retry_delays_observed[4] >= disconnected_retry_timeout * 2.0 * 0.8 + assert retry_delays_observed[4] <= disconnected_retry_timeout * 2.0 + + +# UTS: realtime/unit/RTB1/suspended-channel-retry-delay-1 +@deviation +async def test_rtb1_suspended_channel_retry_delay(): + channel_name = 'test-RTB1-channel' + channel_retry_timeout = 3000 + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + clock = FakeClock() + client = realtime_client( + mock_ws, clock=clock, key='appId.keyId:keySecret', use_binary_protocol=False, + channel_retry_timeout=channel_retry_timeout, + ) + + attach_count = [] + + def on_message_from_client(message): + if message.get('action') == int(ProtocolMessageAction.ATTACH): + attach_count.append(message) + if len(attach_count) == 1: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ATTACHED), + 'channel': message.get('channel'), + 'flags': 0, + }) + else: + # RTL13b: a re-attach that is refused suspends the channel + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.DETACHED), + 'channel': message.get('channel'), + 'error': {'code': 90001, 'statusCode': 500, + 'message': 'Channel re-attach failed'}, + }) + + mock_ws.on_message_from_client = on_message_from_client + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel = client.channels.get(channel_name) + + transitions = [] + channel.on(lambda change: transitions.append((clock.now, change.current))) + + await channel.attach() + assert channel.state == ChannelState.ATTACHED + + # The specification sends an ERROR on the channel to trigger the re-attach. + # ably-python fails a channel outright on an ERROR, so the re-attach is + # triggered by a server-initiated DETACHED instead + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.DETACHED), + 'channel': channel_name, + 'error': {'code': 90001, 'statusCode': 500, 'message': 'Channel error'}, + }) + await settle() + + delays = [] + for _ in range(30): + await clock.advance(7000) + delays = retry_delays(transitions, ChannelState.SUSPENDED, ChannelState.ATTACHING) + if len(delays) >= 4: + break + + assert len(delays) >= 4 + + assert delays[0] >= channel_retry_timeout * 1.0 * 0.8 + assert delays[0] <= channel_retry_timeout * 1.0 + + assert delays[1] >= channel_retry_timeout * (4.0 / 3.0) * 0.8 + assert delays[1] <= channel_retry_timeout * (4.0 / 3.0) + + assert delays[2] >= channel_retry_timeout * (5.0 / 3.0) * 0.8 + assert delays[2] <= channel_retry_timeout * (5.0 / 3.0) + + assert delays[3] >= channel_retry_timeout * 2.0 * 0.8 + assert delays[3] <= channel_retry_timeout * 2.0 diff --git a/test/uts/realtime/unit/connection/connection_failures_test.py b/test/uts/realtime/unit/connection/connection_failures_test.py new file mode 100644 index 00000000..b2ea02c7 --- /dev/null +++ b/test/uts/realtime/unit/connection/connection_failures_test.py @@ -0,0 +1,449 @@ +"""Derived from uts/realtime/unit/connection/connection_failures_test.md in ably/specification. + +Spec points: RTN14h, RTN15, RTN15a, RTN15b, RTN15c, RTN15d, RTN15e, RTN15h, RTN15j +""" + +import time + +from ably.realtime.connection import ConnectionState +from ably.types.tokendetails import TokenDetails +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import ERROR_MESSAGE, MockWebSocket, connected_message + +DISCONNECTED_ACTION = 6 + +TOKEN_ERROR = { + 'action': DISCONNECTED_ACTION, + 'error': {'code': 40142, 'statusCode': 401, 'message': 'Token expired'}, +} + + +def token_response(token): + """The body an Ably token request returns.""" + issued = int(time.time() * 1000) + return { + 'token': token, + 'keyName': 'appId.keyId', + 'issued': issued, + 'expires': issued + 3600000, + 'capability': '{"*":["*"]}', + } + + +# UTS: realtime/unit/RTN15h1/token-error-no-renew-0 +async def test_rtn15h1_token_error_no_renew(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, token='some_token_string') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.send_to_client_and_close(TOKEN_ERROR) + + await await_connection_state(client, ConnectionState.FAILED, timeout=2.0) + + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + # DEVIATION: see deviations.md. The specification asserts the + # DISCONNECTED message's own 40142/401; ably-python reports the failed renewal + # instead, which RSA4a2 gives as 40171 + assert client.connection.error_reason.code == 40171 + assert client.connection.error_reason.status_code == 403 + + +# UTS: realtime/unit/RTN15h2/token-error-renew-success-0 +async def test_rtn15h2_token_error_renew_success(): + token_requests = [] + + def on_request(request): + if '/keys/' in request.path: + token_requests.append(request) + request.respond_with(200, token_response(f'renewed_token_{len(token_requests)}')) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + else: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1-renewed')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, mock_http=mock_http, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + first_connection_id = client.connection.connection_manager.connection_id + first_connection_key = client.connection.connection_details.connection_key + + mock_ws.send_to_client_and_close(TOKEN_ERROR) + + await await_connection_state(client, ConnectionState.CONNECTING, timeout=2.0) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.state == ConnectionState.CONNECTED + + # UTS SPEC ERROR: the specification asserts two token requests, "initial + renewal", + # having set the client up with a key. RSA4 has a key-authenticated realtime client + # connect with basic auth, so the renewal is the only token request it makes + assert len(token_requests) == 1 + + # The specification reads `client.connection.id` and `client.connection.key`; + # ably-python carries those on the connection manager and the connection details + assert client.connection.connection_manager.connection_id == first_connection_id + assert client.connection.connection_details.connection_key != first_connection_key + assert client.connection.connection_details.connection_key == 'key-1-renewed' + + +# UTS: realtime/unit/RTN15h2/token-error-renew-fails-1 +async def test_rtn15h2_token_error_renew_fails(): + calls = [] + + async def auth_callback(token_params): + calls.append(token_params) + if len(calls) == 1: + return TokenDetails(token='valid-token-1', expires=int(time.time() * 1000) + 3600000) + raise Exception('Unable to renew token') + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-1', connectionKey='key-1')), + ) + client = realtime_client(mock_ws, auth_callback=auth_callback) + + # RTN15h2i's DISCONNECTED is transient: RTN15a retries it immediately, and the + # mock answers that retry with the cached token, so the state changes are + # recorded and asserted on rather than waited for + states = [] + client.connection.on(lambda change: states.append(change)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.send_to_client_and_close(TOKEN_ERROR) + + await await_connection_state(client, ConnectionState.CONNECTING, timeout=5.0) + + disconnected = [change for change in states if change.current == ConnectionState.DISCONNECTED] + assert len(disconnected) == 1 + assert len(calls) == 2 + assert disconnected[0].reason is not None + assert client.connection.error_reason is not None + + +# UTS: realtime/unit/RTN15h3/non-token-error-resume-0 +@deviation +async def test_rtn15h3_non_token_error_resume(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + original_connection_id = client.connection.connection_manager.connection_id + + mock_ws.send_to_client_and_close({ + 'action': DISCONNECTED_ACTION, + 'error': {'code': 80003, 'statusCode': 503, 'message': 'Service unavailable'}, + }) + + await await_connection_state(client, ConnectionState.CONNECTING, timeout=2.0) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.state == ConnectionState.CONNECTED + assert client.connection.connection_manager.connection_id == original_connection_id + assert len(connection_attempts) == 2 + assert connection_attempts[1].url.query_params['resume'] == 'key-1' + + +# UTS: realtime/unit/RTN15j/error-empty-channel-failed-0 +async def test_rtn15j_error_empty_channel_failed(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.send_to_client_and_close(ERROR_MESSAGE(50000, 'Internal error')) + + await await_connection_state(client, ConnectionState.FAILED, timeout=2.0) + + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 50000 + assert client.connection.error_reason.status_code == 500 + + +# UTS: realtime/unit/RTN15a/unexpected-transport-disconnect-0 +async def test_rtn15a_unexpected_transport_disconnect(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + original_connection_id = client.connection.connection_manager.connection_id + + mock_ws.simulate_disconnect() + + # The specification awaits DISCONNECTED. RTN15a leaves it again through + # `loop.call_soon`, so it is read from the recorded sequence + await await_connection_state(client, ConnectionState.CONNECTING) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert states == [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ] + assert client.connection.state == ConnectionState.CONNECTED + assert client.connection.connection_manager.connection_id == original_connection_id + assert len(connection_attempts) == 2 + + +# UTS: realtime/unit/RTN15b/successful-resume-0 +async def test_rtn15b_successful_resume(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + else: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1-updated')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.connection_manager.connection_id == 'connection-1' + + mock_ws.simulate_disconnect() + + await await_connection_state(client, ConnectionState.CONNECTING) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.connection_manager.connection_id == 'connection-1' + assert client.connection.connection_details.connection_key == 'key-1-updated' + assert connection_attempts[1].url.query_params['resume'] == 'key-1' + assert len(connection_attempts) == 2 + + +# UTS: realtime/unit/RTN15c7/failed-resume-new-id-0 +async def test_rtn15c7_failed_resume_new_id(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + else: + message = connected_message('connection-2', connectionKey='key-2') + message['error'] = {'code': 80008, 'statusCode': 400, + 'message': 'Unable to recover connection'} + conn.respond_with_success(message) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + original_connection_id = client.connection.connection_manager.connection_id + + mock_ws.simulate_disconnect() + + await await_connection_state(client, ConnectionState.CONNECTING) + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.connection_manager.connection_id == 'connection-2' + assert client.connection.connection_manager.connection_id != original_connection_id + assert client.connection.connection_details.connection_key == 'key-2' + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 80008 + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTN15e/connection-key-updated-0 +async def test_rtn15e_connection_key_updated(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + else: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1-updated')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.simulate_disconnect() + + await await_connection_state(client, ConnectionState.CONNECTING) + await await_connection_state(client, ConnectionState.CONNECTED) + + # The specification reads `client.connection.key`; ably-python carries the + # connection key on the connection details + assert client.connection.connection_details.connection_key == 'key-1-updated' + + +# UTS: realtime/unit/RTN14h/resume-after-ttl-0 +@deviation +async def test_rtn14h_resume_after_ttl(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success( + connected_message('connection-1', connectionKey='key-1', connectionStateTtl=5000)) + else: + conn.respond_with_refused() + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + clock = FakeClock() + client = realtime_client( + mock_ws, clock=clock, key='appId.keyId:keySecret', + disconnected_retry_timeout=1000, suspended_retry_timeout=2000, + # A refused connection reaches no failure path of its own, so each attempt + # ends on the transition timer; a short one keeps the retry cycle turning + realtime_request_timeout=1000, + ) + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.simulate_disconnect() + await settle() + + # The specification advances 15 times against a 5000ms connectionStateTtl. + # ably-python suspends on the 120000ms default instead, so the window is + # widened to reach the suspension the assertions are about + for _ in range(60): + await clock.advance(2500) + + assert ConnectionState.SUSPENDED in states + + reconnect_attempts = connection_attempts[1:] + assert len(reconnect_attempts) > 0 + for attempt in reconnect_attempts: + assert attempt.url.query_params['resume'] == 'key-1' + + +# UTS: realtime/unit/RTN15c5/token-error-during-resume-0 +async def test_rtn15c5_token_error_during_resume(): + token_requests = [] + + def on_request(request): + if '/keys/' in request.path: + token_requests.append(request) + request.respond_with(200, token_response('renewed_token')) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + elif len(connection_attempts) == 2: + conn.respond_with_success() + conn.send_to_client(ERROR_MESSAGE(40142, 'Token expired')) + else: + conn.respond_with_success(connected_message('connection-2', connectionKey='key-2')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + # A token error met while CONNECTING is retried on the retry timer rather than + # immediately, so the retry interval is kept short + client = realtime_client(mock_ws, mock_http=mock_http, key='appId.keyId:keySecret', + disconnected_retry_timeout=100) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.simulate_disconnect() + + await await_connection_state(client, ConnectionState.CONNECTING) + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + assert client.connection.state == ConnectionState.CONNECTED + + # UTS SPEC ERROR: as in RTN15h2's renewal test, the specification counts an + # initial token request a key-authenticated client does not make + assert len(token_requests) == 1 + + assert len(connection_attempts) == 3 + + +# UTS: realtime/unit/RTN15c4/fatal-error-during-resume-0 +async def test_rtn15c4_fatal_error_during_resume(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_success(connected_message('connection-1', connectionKey='key-1')) + else: + conn.respond_with_success() + conn.send_to_client(ERROR_MESSAGE(50000, 'Internal server error')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.simulate_disconnect() + + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 50000 + assert len(connection_attempts) == 2 diff --git a/test/uts/realtime/unit/connection/connection_id_key_test.py b/test/uts/realtime/unit/connection/connection_id_key_test.py new file mode 100644 index 00000000..adfcd9aa --- /dev/null +++ b/test/uts/realtime/unit/connection/connection_id_key_test.py @@ -0,0 +1,207 @@ +"""Derived from uts/realtime/unit/connection/connection_id_key_test.md in ably/specification. + +Spec points: RTN8, RTN8a, RTN8b, RTN8d, RTN9, RTN9a, RTN9b, RTN9d +""" + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +FATAL_ERROR_MESSAGE = { + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': 80000, 'statusCode': 400, 'message': 'Fatal error'}, +} + + +def connection_id(client): + """The specifications' `connection.id`. + + ably-python has no `Connection#id`; the value the CONNECTED message carries + is held on the connection manager. See + [deviations.md](../../../deviations.md). + """ + return client.connection.connection_manager.connection_id + + +def connection_key(client): + """The specifications' `connection.key`. + + ably-python has no `Connection#key`; the value is reached through the + connection details, which are themselves cleared whenever the key would be. + See [deviations.md](../../../deviations.md). + """ + details = client.connection.connection_details + return details.connection_key if details is not None else None + + +def respond_with(connection_id, connection_key): + def on_connection_attempt(conn): + conn.respond_with_success(connected_message(connection_id, connectionKey=connection_key)) + + return on_connection_attempt + + +def respond_with_numbered(attempts): + """Hands each successive attempt its own connection id and key.""" + + def on_connection_attempt(conn): + attempts.append(conn) + index = len(attempts) + conn.respond_with_success( + connected_message(f'conn-id-{index}', connectionKey=f'conn-key-{index}')) + + return on_connection_attempt + + +# UTS: realtime/unit/RTN8a/id-unset-until-connected-0 +async def test_rtn8a_id_unset_until_connected(): + mock_ws = MockWebSocket(on_connection_attempt=respond_with('unique-conn-id-1', 'conn-key-1')) + client = realtime_client(mock_ws) + + assert connection_id(client) is None + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert connection_id(client) == 'unique-conn-id-1' + + +# UTS: realtime/unit/RTN9a/key-unset-until-connected-0 +async def test_rtn9a_key_unset_until_connected(): + mock_ws = MockWebSocket(on_connection_attempt=respond_with('unique-conn-id-1', 'conn-key-1')) + client = realtime_client(mock_ws) + + assert connection_key(client) is None + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert connection_key(client) == 'conn-key-1' + + +# UTS: realtime/unit/RTN8b/id-unique-per-connection-0 +async def test_rtn8b_id_unique_per_connection(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=respond_with_numbered(attempts)) + client1 = realtime_client(mock_ws) + client2 = realtime_client(mock_ws) + + client1.connect() + await await_connection_state(client1, ConnectionState.CONNECTED) + + client2.connect() + await await_connection_state(client2, ConnectionState.CONNECTED) + + assert connection_id(client1) != connection_id(client2) + assert connection_id(client1) == 'conn-id-1' + assert connection_id(client2) == 'conn-id-2' + + +# UTS: realtime/unit/RTN9b/key-unique-per-connection-0 +async def test_rtn9b_key_unique_per_connection(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=respond_with_numbered(attempts)) + client1 = realtime_client(mock_ws) + client2 = realtime_client(mock_ws) + + client1.connect() + await await_connection_state(client1, ConnectionState.CONNECTED) + + client2.connect() + await await_connection_state(client2, ConnectionState.CONNECTED) + + assert connection_key(client1) != connection_key(client2) + assert connection_key(client1) == 'conn-key-1' + assert connection_key(client2) == 'conn-key-2' + + +# UTS: realtime/unit/RTN8d/id-null-after-closed-0 +async def test_rtn8d_id_null_after_closed(): + mock_ws = MockWebSocket(on_connection_attempt=respond_with('conn-id-1', 'conn-key-1')) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert connection_id(client) == 'conn-id-1' + + await client.close() + await await_connection_state(client, ConnectionState.CLOSED) + + assert connection_id(client) is None + + +# UTS: realtime/unit/RTN9d/key-null-after-closed-0 +async def test_rtn9d_key_null_after_closed(): + mock_ws = MockWebSocket(on_connection_attempt=respond_with('conn-id-1', 'conn-key-1')) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert connection_key(client) == 'conn-key-1' + + await client.close() + await await_connection_state(client, ConnectionState.CLOSED) + + assert connection_key(client) is None + + +# UTS: realtime/unit/RTN8d/id-key-null-after-failed-1 +async def test_rtn8d_id_key_null_after_failed(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_error(FATAL_ERROR_MESSAGE), + ) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert connection_id(client) is None + assert connection_key(client) is None + + +# UTS: realtime/unit/RTN8d/id-key-retained-in-suspended-2 +@deviation +async def test_rtn8d_id_key_retained_in_suspended(): + attempts = [] + + def on_connection_attempt(conn): + attempts.append(conn) + if len(attempts) == 1: + conn.respond_with_success(connected_message('conn-id-1', connectionKey='conn-key-1')) + else: + conn.respond_with_refused() + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + clock = FakeClock() + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=1000, suspended_retry_timeout=100) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert connection_id(client) == 'conn-id-1' + assert connection_key(client) == 'conn-key-1' + + # The retry that follows SUSPENDED leaves that state again within the + # window being advanced, so the id and key are read as the connection + # enters SUSPENDED rather than once the window has run out + at_suspended = {} + + def on_suspended(change): + at_suspended['id'] = connection_id(client) + at_suspended['key'] = connection_key(client) + + client.connection.once(ConnectionState.SUSPENDED, on_suspended) + + mock_ws.simulate_disconnect() + await settle() + await clock.advance(121000) + + assert at_suspended, 'the connection never reached SUSPENDED' + assert at_suspended['id'] == 'conn-id-1' + assert at_suspended['key'] == 'conn-key-1' diff --git a/test/uts/realtime/unit/connection/connection_open_failures_test.py b/test/uts/realtime/unit/connection/connection_open_failures_test.py new file mode 100644 index 00000000..af1e7393 --- /dev/null +++ b/test/uts/realtime/unit/connection/connection_open_failures_test.py @@ -0,0 +1,295 @@ +"""Derived from uts/realtime/unit/connection/connection_open_failures_test.md in ably/specification. + +Spec points: RTN14, RTN14a, RTN14b, RTN14c, RTN14d, RTN14e, RTN14f, RTN14g +""" + +import time + +from ably.realtime.connection import ConnectionState +from ably.types.tokendetails import TokenDetails +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import ERROR_MESSAGE, MockWebSocket, connected_message + +# The suspend timer runs on `Defaults.connection_state_ttl`, which no client option +# reaches, so the tests about suspension advance notional time to it +CONNECTION_STATE_TTL = 120000 + +TOKEN_ERROR = ERROR_MESSAGE(40142, 'Token expired') + + +def token_response(token): + """The body an Ably token request returns.""" + issued = int(time.time() * 1000) + return { + 'token': token, + 'keyName': 'appId.keyId', + 'issued': issued, + 'expires': issued + 3600000, + 'capability': '{"*":["*"]}', + } + + +# UTS: realtime/unit/RTN14a/invalid-key-failed-0 +async def test_rtn14a_invalid_key_failed(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_error( + ERROR_MESSAGE(40005, 'Invalid key')), + ) + client = realtime_client(mock_ws, key='invalid.key:secret') + + client.connect() + + await await_connection_state(client, ConnectionState.CONNECTING) + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40005 + assert client.connection.error_reason.status_code == 400 + + # The specification reads `client.connection.id` and `client.connection.key`; + # ably-python carries those on the connection manager and the connection details + assert client.connection.connection_manager.connection_id is None + assert client.connection.connection_details is None + + +# UTS: realtime/unit/RTN14b/token-error-with-renewal-0 +async def test_rtn14b_token_error_with_renewal(): + token_requests = [] + + def on_request(request): + if '/keys/' in request.path: + token_requests.append(request) + request.respond_with(200, token_response(f'renewed_token_{len(token_requests)}')) + + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_error(TOKEN_ERROR) + else: + conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + # A token error met while CONNECTING is retried on the retry timer rather than + # immediately, so the retry interval is kept short + client = realtime_client(mock_ws, mock_http=mock_http, key='appId.keyId:keySecret', + disconnected_retry_timeout=100) + + client.connect() + + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + assert client.connection.state == ConnectionState.CONNECTED + + # UTS SPEC ERROR: the specification asserts two token requests, "initial + renewal", + # having set the client up with a key. RSA4 has a key-authenticated realtime client + # connect with basic auth, so the renewal is the only token request it makes + assert len(token_requests) == 1 + + assert len(connection_attempts) == 2 + + +# UTS: realtime/unit/RTN14b/token-renewal-fails-1 +async def test_rtn14b_token_renewal_fails(): + calls = [] + + async def auth_callback(token_params): + calls.append(token_params) + if len(calls) == 1: + return TokenDetails(token='initial-token', expires=int(time.time() * 1000) + 3600000) + raise Exception('Unable to renew token') + + def on_connection_attempt(conn): + # The specification's setup sends the ERROR without establishing the + # connection first; a message can only reach the client behind one + conn.respond_with_error(TOKEN_ERROR) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + # The retry that follows the failed renewal would ask the auth callback again, + # so it is held off until the assertions have run + client = realtime_client(mock_ws, auth_callback=auth_callback, + disconnected_retry_timeout=60000) + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.state == ConnectionState.DISCONNECTED + assert len(calls) == 2 + assert client.connection.error_reason is not None + assert states == [ConnectionState.CONNECTING, ConnectionState.DISCONNECTED] + + +# UTS: realtime/unit/RSA4a/token-error-no-renewal-0 +async def test_rsa4a_token_error_no_renewal(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_error(TOKEN_ERROR), + ) + client = realtime_client(mock_ws, token='expired_token_string') + + client.connect() + + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40171 + + +# UTS: realtime/unit/RTN14c/connection-timeout-0 +async def test_rtn14c_connection_timeout(): + # The attempt succeeds and the server sends nothing, so the connection is left + # to the transition timer + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_success()) + clock = FakeClock() + client = realtime_client(mock_ws, clock=clock, key='appId.keyId:keySecret', + realtime_request_timeout=1000) + + client.connect() + + await await_connection_state(client, ConnectionState.CONNECTING) + + await clock.advance(1100) + + assert client.connection.state == ConnectionState.DISCONNECTED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 50003 + + +# UTS: realtime/unit/RTN14d/retry-recoverable-failure-0 +async def test_rtn14d_retry_recoverable_failure(): + connection_attempts = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + if len(connection_attempts) == 1: + conn.respond_with_refused() + else: + conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + clock = FakeClock() + # DEVIATION: see deviations.md. A refused connection reaches + # no failure path of its own, so the first attempt ends on the transition timer + # rather than at once, and the test advances to it + client = realtime_client(mock_ws, clock=clock, key='appId.keyId:keySecret', + disconnected_retry_timeout=1000, realtime_request_timeout=1000) + + client.connect() + await settle() + + # The refusal itself moves nothing: the attempt is still outstanding + assert client.connection.state == ConnectionState.CONNECTING + + await clock.advance(1100) + + assert client.connection.state == ConnectionState.DISCONNECTED + # The reason is the transition timer's, not the refused connection's + assert client.connection.error_reason.code == 50003 + + await clock.advance(1100) + + assert client.connection.state == ConnectionState.CONNECTED + assert len(connection_attempts) == 2 + + +# UTS: realtime/unit/RTN14e/disconnected-to-suspended-0 +async def test_rtn14e_disconnected_to_suspended(): + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_refused()) + clock = FakeClock() + client = realtime_client(mock_ws, clock=clock, key='appId.keyId:keySecret', + disconnected_retry_timeout=1000, realtime_request_timeout=1000) + + states = [] + client.connection.on(lambda change: states.append(change.current)) + + client.connect() + + await clock.advance(1100) + + assert client.connection.state == ConnectionState.DISCONNECTED + + # The specification advances past a 5000ms connectionStateTtl it sets for the + # test. ably-python takes the TTL from its own defaults, so the advance is to that + await clock.advance(CONNECTION_STATE_TTL + 100) + + assert client.connection.state == ConnectionState.SUSPENDED + assert client.connection.error_reason is not None + + +# UTS: realtime/unit/RTN14f/suspended-retries-indefinitely-0 +async def test_rtn14f_suspended_retries_indefinitely(): + states = [] + connection_attempts = [] + attempts_when_suspended = [] + + def on_connection_attempt(conn): + connection_attempts.append(conn) + # The specification fails the first two attempts and succeeds on the third, + # counting on suspension arriving after one. ably-python suspends on its own + # 120000ms connectionStateTtl, so every attempt up to the first retry from + # SUSPENDED fails instead + if not attempts_when_suspended or len(connection_attempts) <= attempts_when_suspended[0] + 1: + conn.respond_with_refused() + else: + conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + clock = FakeClock() + client = realtime_client(mock_ws, clock=clock, key='appId.keyId:keySecret', + disconnected_retry_timeout=500, suspended_retry_timeout=1000, + realtime_request_timeout=1000) + + def on_state_change(change): + states.append(change.current) + if change.current == ConnectionState.SUSPENDED and not attempts_when_suspended: + attempts_when_suspended.append(len(connection_attempts)) + + client.connection.on(on_state_change) + + client.connect() + + for _ in range(60): + await clock.advance(2500) + if client.connection.state == ConnectionState.CONNECTED: + break + + assert ConnectionState.SUSPENDED in states + assert client.connection.state == ConnectionState.CONNECTED + # Attempts were made from SUSPENDED: one that failed, and the one that connected + assert len(connection_attempts) >= attempts_when_suspended[0] + 2 + assert len(connection_attempts) >= 3 + + +# UTS: realtime/unit/RTN14g/error-empty-channel-failed-0 +async def test_rtn14g_error_empty_channel_failed(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_error( + ERROR_MESSAGE(50000, 'Internal server error')), + ) + client = realtime_client(mock_ws, key='appId.keyId:keySecret') + + client.connect() + + await await_connection_state(client, ConnectionState.FAILED) + await settle() + + assert client.connection.state == ConnectionState.FAILED + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 50000 + assert client.connection.error_reason.status_code == 500 + assert client.connection.error_reason.message == 'Internal server error' diff --git a/test/uts/realtime/unit/connection/connection_ping_test.py b/test/uts/realtime/unit/connection/connection_ping_test.py new file mode 100644 index 00000000..1c3aaad7 --- /dev/null +++ b/test/uts/realtime/unit/connection/connection_ping_test.py @@ -0,0 +1,409 @@ +"""Derived from uts/realtime/unit/connection/connection_ping_test.md in ably/specification. + +Spec points: RTN13, RTN13a, RTN13b, RTN13c, RTN13d, RTN13e + +`ping()`'s own timeout does not run on the timer seam — `ConnectionManager.ping` +awaits `asyncio.wait_for(..., realtime_request_timeout / 1000)` on the loop +clock — so the tests that wait one out use a short real `realtime_request_timeout` +rather than a `FakeClock`. The fake clock is still installed where a +specification advances past `connectionStateTtl` to reach SUSPENDED, which no +client option reaches. +""" + +import asyncio + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import ( + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + ERROR_MESSAGE, + MockWebSocket, + connected_message, +) + +HEARTBEAT = int(ProtocolMessageAction.HEARTBEAT) + +# Long enough that a ping which is meant to resolve is never cut short, short +# enough that a ping which is meant to time out does so quickly +REQUEST_TIMEOUT = 2000 +SHORT_REQUEST_TIMEOUT = 300 + +# `Defaults.connection_state_ttl` is 120 s and is read directly by the suspend +# timer, which is the one interval no client option reaches +CONNECTION_STATE_TTL = 120000 + + +def connected(connection_id='conn-id-1', connection_key='conn-key-1', max_idle_interval=15000): + """The CONNECTED message the specifications answer their attempts with.""" + return connected_message( + connection_id, connectionKey=connection_key, + maxIdleInterval=max_idle_interval, connectionStateTtl=CONNECTION_STATE_TTL) + + +def echo_heartbeats(mock_websocket): + """A message handler answering each HEARTBEAT with one carrying the same id. + + This is the server side of RTN13a. The handler runs inside `transport.send`, + so the echo is always back before the ping's timeout can run. + """ + def on_message_from_client(message): + if message.get('action') == HEARTBEAT: + mock_websocket.send_to_client({'action': HEARTBEAT, 'id': message.get('id')}) + return on_message_from_client + + +def heartbeats_sent(mock_websocket): + """Every HEARTBEAT the client has sent.""" + return [message for message in mock_websocket.messages_from_client + if message.get('action') == HEARTBEAT] + + +async def ping_outcome(ping_task): + """What a ping produced, as a `(duration, error)` pair. + + The task is always awaited, so an assertion that fails afterwards cannot + leave its exception unretrieved. + """ + try: + return await ping_task, None + except Exception as error: + return None, error + + +# UTS: realtime/unit/RTN13a/ping-heartbeat-roundtrip-0 +async def test_rtn13a_ping_heartbeat_roundtrip(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected())) + mock_ws.on_message_from_client = echo_heartbeats(mock_ws) + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + duration = await client.connection.ping() + + assert duration is not None + assert duration >= 0 + assert len(heartbeats_sent(mock_ws)) == 1 + + +# UTS: realtime/unit/RTN13e/heartbeat-random-id-0 +async def test_rtn13e_heartbeat_random_id(): + captured_ids = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected())) + + def on_message_from_client(message): + if message.get('action') == HEARTBEAT: + captured_ids.append(message.get('id')) + # A heartbeat carrying another ping's id is no answer to this one + mock_ws.send_to_client({'action': HEARTBEAT, 'id': 'wrong-id'}) + mock_ws.send_to_client({'action': HEARTBEAT, 'id': message.get('id')}) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + duration = await client.connection.ping() + + assert duration is not None + assert duration >= 0 + assert captured_ids[0] is not None + assert len(captured_ids[0]) > 0 + + +# UTS: realtime/unit/RTN13e/no-id-heartbeat-ignored-1 +async def test_rtn13e_no_id_heartbeat_ignored(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected())) + + def on_message_from_client(message): + if message.get('action') == HEARTBEAT: + # A server-initiated heartbeat carries no id and answers no ping + mock_ws.send_to_client({'action': HEARTBEAT}) + mock_ws.send_to_client({'action': HEARTBEAT, 'id': message.get('id')}) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + duration = await client.connection.ping() + + assert duration is not None + assert duration >= 0 + + +# UTS: realtime/unit/RTN13e/concurrent-pings-unique-ids-2 +async def test_rtn13e_concurrent_pings_unique_ids(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected())) + mock_ws.on_message_from_client = echo_heartbeats(mock_ws) + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + first = asyncio.ensure_future(client.connection.ping()) + second = asyncio.ensure_future(client.connection.ping()) + + duration1 = await first + duration2 = await second + + assert duration1 is not None + assert duration2 is not None + + sent = heartbeats_sent(mock_ws) + assert len(sent) == 2 + assert sent[0]['id'] != sent[1]['id'] + + +# UTS: realtime/unit/RTN13c/ping-timeout-0 +async def test_rtn13c_ping_timeout(): + # The server answers no HEARTBEAT, so the ping runs out its + # `realtime_request_timeout`, which is real time here rather than advanced + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected())) + client = realtime_client(mock_ws, realtime_request_timeout=SHORT_REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + duration, error = await ping_outcome(asyncio.ensure_future(client.connection.ping())) + + assert error is not None + assert 'timeout' in str(error).lower() + + +# UTS: realtime/unit/RTN13b/ping-error-initialized-0 +async def test_rtn13b_ping_error_initialized(): + client = realtime_client(MockWebSocket()) + + assert client.connection.state == ConnectionState.INITIALIZED + + duration, error = await ping_outcome(asyncio.ensure_future(client.connection.ping())) + + assert error is not None + + +# UTS: realtime/unit/RTN13b/ping-error-suspended-1 +async def test_rtn13b_ping_error_suspended(): + clock = FakeClock() + # The specification fails the attempt with a refused connection; ably-python + # catches only a websocket error or a name resolution failure, so a refused + # one produces no state change until the transition timer ends it. See + # deviations.md + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_dns_error()) + # A retry timeout past `connectionStateTtl` leaves the suspend timer as the + # only thing the advance below fires + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=CONNECTION_STATE_TTL * 2, + suspended_retry_timeout=CONNECTION_STATE_TTL * 2) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + await clock.advance(CONNECTION_STATE_TTL + 1000) + + assert client.connection.state == ConnectionState.SUSPENDED + + duration, error = await ping_outcome(asyncio.ensure_future(client.connection.ping())) + + assert error is not None + + +# UTS: realtime/unit/RTN13b/ping-error-closed-2 +async def test_rtn13b_ping_error_closed(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected())) + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await client.close() + assert client.connection.state == ConnectionState.CLOSED + + duration, error = await ping_outcome(asyncio.ensure_future(client.connection.ping())) + + assert error is not None + + +# UTS: realtime/unit/RTN13b/ping-error-failed-3 +async def test_rtn13b_ping_error_failed(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_error( + ERROR_MESSAGE(80000, 'Fatal error', status_code=400))) + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + duration, error = await ping_outcome(asyncio.ensure_future(client.connection.ping())) + + assert error is not None + + +# UTS: realtime/unit/RTN13d/ping-deferred-connecting-0 +async def test_rtn13d_ping_deferred_connecting(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=attempts.append) + mock_ws.on_message_from_client = echo_heartbeats(mock_ws) + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + # `connect()` sets CONNECTING before the attempt is scheduled, so the attempt + # itself is what says the connection is under way + await poll_until(lambda: len(attempts) == 1, description='a connection attempt') + assert client.connection.state == ConnectionState.CONNECTING + + ping_task = asyncio.ensure_future(client.connection.ping()) + await settle() + + assert len(heartbeats_sent(mock_ws)) == 0 + + attempts[0].respond_with_success(connected()) + await await_connection_state(client, ConnectionState.CONNECTED) + + duration, error = await ping_outcome(ping_task) + + assert error is None + assert duration is not None + assert duration >= 0 + assert len(heartbeats_sent(mock_ws)) == 1 + + +# UTS: realtime/unit/RTN13d/ping-deferred-disconnected-1 +@deviation +async def test_rtn13d_ping_deferred_disconnected(): + clock = FakeClock() + attempts = [] + + def on_connection_attempt(conn): + attempts.append(conn) + # The specification drops an established connection to reach DISCONNECTED. + # RTN15a retries such a drop with no delay, so the state cannot be held + # long enough to ping from; a failed first attempt settles there instead + if len(attempts) == 1: + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected('conn-id-2', 'conn-key-2', max_idle_interval=0)) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + mock_ws.on_message_from_client = echo_heartbeats(mock_ws) + client = realtime_client(mock_ws, clock=clock, disconnected_retry_timeout=500, + realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + ping_task = asyncio.ensure_future(client.connection.ping()) + await settle() + + # RTN13d: a ping requested while DISCONNECTED waits for the connection. + # ably-python admits only CONNECTED and CONNECTING and errors at once + deferred = not ping_task.done() + + await clock.advance(600) + await await_connection_state(client, ConnectionState.CONNECTED) + + duration, error = await ping_outcome(ping_task) + + assert deferred, 'RTN13d: the ping errored instead of waiting for the connection' + assert error is None + assert duration is not None + assert duration >= 0 + assert len(heartbeats_sent(mock_ws)) == 1 + + +# UTS: realtime/unit/RTN13b/deferred-ping-error-failed-4 +async def test_rtn13b_deferred_ping_error_failed(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=attempts.append) + client = realtime_client(mock_ws, realtime_request_timeout=REQUEST_TIMEOUT) + + client.connect() + await poll_until(lambda: len(attempts) == 1, description='a connection attempt') + assert client.connection.state == ConnectionState.CONNECTING + + ping_task = asyncio.ensure_future(client.connection.ping()) + await settle() + + attempts[0].respond_with_error(ERROR_MESSAGE(80000, 'Fatal error', status_code=400)) + await await_connection_state(client, ConnectionState.FAILED) + + duration, error = await ping_outcome(ping_task) + + assert error is not None + + +# UTS: realtime/unit/RTN13b/deferred-ping-error-suspended-5 +@deviation +async def test_rtn13b_deferred_ping_error_suspended(): + clock = FakeClock() + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_dns_error()) + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=CONNECTION_STATE_TTL * 2, + suspended_retry_timeout=CONNECTION_STATE_TTL * 2) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + ping_task = asyncio.ensure_future(client.connection.ping()) + await settle() + + # RTN13d: the ping waits for the connection rather than failing where the + # specification expects it to be deferred + deferred = not ping_task.done() + + await clock.advance(CONNECTION_STATE_TTL + 1000) + assert client.connection.state == ConnectionState.SUSPENDED + + duration, error = await ping_outcome(ping_task) + + assert deferred, 'RTN13d: the ping errored instead of waiting for the connection' + assert error is not None + + +# UTS: realtime/unit/RTN13c/deferred-ping-timeout-1 +@deviation +async def test_rtn13c_deferred_ping_timeout(): + clock = FakeClock() + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=attempts.append) + # The clock holds the CONNECTING transition timer, leaving the real interval + # below as the only thing the connection waits on + client = realtime_client(mock_ws, clock=clock, realtime_request_timeout=400) + timeout_in_secs = 0.4 + + client.connect() + await poll_until(lambda: len(attempts) == 1, description='a connection attempt') + assert client.connection.state == ConnectionState.CONNECTING + + ping_task = asyncio.ensure_future(client.connection.ping()) + + # The connection takes most of the ping's timeout to establish itself + await asyncio.sleep(timeout_in_secs * 0.75) + attempts[0].respond_with_success(connected(max_idle_interval=0)) + await await_connection_state(client, ConnectionState.CONNECTED) + connected_at = asyncio.get_running_loop().time() + + duration, error = await ping_outcome(ping_task) + failed_after = asyncio.get_running_loop().time() - connected_at + + assert error is not None + assert 'timeout' in str(error).lower() + # RTN13c with RTN13d: a deferred ping's timeout runs from the HEARTBEAT going + # out, not from the call. ably-python starts `wait_for` when `ping()` is + # called, so the ping expires while the connection is still establishing + assert failed_after >= timeout_in_secs * 0.9 diff --git a/test/uts/realtime/unit/connection/connection_recovery_test.py b/test/uts/realtime/unit/connection/connection_recovery_test.py new file mode 100644 index 00000000..abc92660 --- /dev/null +++ b/test/uts/realtime/unit/connection/connection_recovery_test.py @@ -0,0 +1,313 @@ +"""Derived from uts/realtime/unit/connection/connection_recovery_test.md in ably/specification. + +Spec points: RTN16d, RTN16f, RTN16f1, RTN16g, RTN16g1, RTN16g2, RTN16g3, RTN16i, +RTN16j, RTN16k, RTN16l + +RTN16 connection recovery is absent from ably-python: `recover` is a client +option with a property and a setter but is read nowhere in the library, there is +no `recover` connect parameter and no recovery key to create or decode. Every +test here but the malformed-key one is therefore gated; see +deviations.md. +""" + +import asyncio +import json + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import await_channel_state, await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + ERROR_MESSAGE, + MockWebSocket, + ack, + attached_message, + await_published, + connected_message, +) + +ATTACH = int(ProtocolMessageAction.ATTACH) + +CONNECTION_STATE_TTL = 120000 + + +def connected(connection_id, connection_key, max_idle_interval=15000, connection_state_ttl=CONNECTION_STATE_TTL): + """The CONNECTED message the specifications answer their attempts with.""" + return connected_message( + connection_id, connectionKey=connection_key, + maxIdleInterval=max_idle_interval, connectionStateTtl=connection_state_ttl) + + +def channel_serial(channel): + """The channel's `channelSerial`, which the library keeps privately. + + The specification reads it from an RTL15 `properties` object, which + ably-python does not expose. + """ + return channel._RealtimeChannel__channel_serial + + +def attach_with_serials(mock_websocket, serials): + """A message handler attaching each channel with the serial named for it.""" + def on_message_from_client(message): + if message.get('action') == ATTACH: + channel = message.get('channel') + mock_websocket.send_to_client( + attached_message(channel, channelSerial=serials[channel])) + return on_message_from_client + + +# UTS: realtime/unit/RTN16g/recovery-key-structure-0 +@deviation +async def test_rtn16g_recovery_key_structure(): + serials = {'channel-alpha': 'serial-a-001', 'channel-éàü-世界': 'serial-b-002'} + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected('connection-1', 'key-abc-123'))) + mock_ws.on_message_from_client = attach_with_serials(mock_ws, serials) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel_a = client.channels.get('channel-alpha') + channel_b = client.channels.get('channel-éàü-世界') + + await channel_a.attach() + await await_channel_state(channel_a, ChannelState.ATTACHED) + await channel_b.attach() + await await_channel_state(channel_b, ChannelState.ATTACHED) + + # RTN16g: `Connection#createRecoveryKey`. ably-python has no such method + recovery_key_string = client.connection.create_recovery_key() + + assert recovery_key_string is not None + + recovery_key = json.loads(recovery_key_string) + + assert recovery_key['connectionKey'] == 'key-abc-123' + assert recovery_key['msgSerial'] == 0 + assert recovery_key['channelSerials'] is not None + assert recovery_key['channelSerials']['channel-alpha'] == 'serial-a-001' + # RTN16g1: the serialisation carries any unicode channel name + assert recovery_key['channelSerials']['channel-éàü-世界'] == 'serial-b-002' + + re_parsed = json.loads(json.dumps(recovery_key)) + assert re_parsed['channelSerials']['channel-éàü-世界'] == 'serial-b-002' + + +# UTS: realtime/unit/RTN16g3/recovery-key-null-inactive-0 +@deviation +async def test_rtn16g3_recovery_key_null_inactive(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected('connection-1', 'key-1'))) + client = realtime_client(mock_ws) + + # Before connecting there is no connection key to recover with + assert client.connection.create_recovery_key() is None + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + assert client.connection.create_recovery_key() is not None + + close_task = asyncio.ensure_future(client.close()) + # `close()` requests CLOSING before it waits, so one turn of the loop is + # enough to observe the state the specification asserts in + await asyncio.sleep(0) + assert client.connection.state == ConnectionState.CLOSING + assert client.connection.create_recovery_key() is None + + await close_task + assert client.connection.state == ConnectionState.CLOSED + assert client.connection.create_recovery_key() is None + + # A connection which failed is not recoverable either + failed_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected('conn-f', 'key-f'))) + client_failed = realtime_client(failed_ws) + client_failed.connect() + await await_connection_state(client_failed, ConnectionState.CONNECTED) + + failed_ws.send_to_client_and_close(ERROR_MESSAGE(50000, 'Fatal error', status_code=500)) + await await_connection_state(client_failed, ConnectionState.FAILED) + assert client_failed.connection.create_recovery_key() is None + + # RTN16g3 with RTN8d/RTN9d: the connection key is kept through SUSPENDED, so + # a suspended connection is still recoverable + clock = FakeClock() + suspended_attempts = [] + + def on_connection_attempt(conn): + suspended_attempts.append(conn) + if len(suspended_attempts) == 1: + conn.respond_with_success(connected('conn-s', 'key-s', max_idle_interval=0)) + else: + conn.respond_with_dns_error() + + suspended_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client_suspended = realtime_client( + suspended_ws, clock=clock, disconnected_retry_timeout=500, + suspended_retry_timeout=CONNECTION_STATE_TTL * 2) + + client_suspended.connect() + await await_connection_state(client_suspended, ConnectionState.CONNECTED) + + suspended_ws.simulate_disconnect() + for _ in range(10): + if client_suspended.connection.state == ConnectionState.SUSPENDED: + break + await clock.advance(15000) + + assert client_suspended.connection.state == ConnectionState.SUSPENDED + assert client_suspended.connection.create_recovery_key() is not None + + +# UTS: realtime/unit/RTN16k/recover-query-param-0 +@deviation +async def test_rtn16k_recover_query_param(): + recovery_key = json.dumps({ + 'connectionKey': 'recovered-key-xyz', + 'msgSerial': 5, + 'channelSerials': {}, + }) + attempts = [] + + def on_connection_attempt(conn): + attempts.append(conn) + if len(attempts) == 1: + conn.respond_with_success(connected('recovered-conn-id', 'new-key-after-recovery')) + else: + conn.respond_with_success(connected('recovered-conn-id', 'resumed-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, recover=recovery_key) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + reconnected = [] + + def on_connected(change): + reconnected.append(change) + + client.connection.on(ConnectionState.CONNECTED, on_connected) + mock_ws.simulate_disconnect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # RTN16k: the first attempt carries the recovery key's connection key + assert attempts[0].url.query_params.get('recover') == 'recovered-key-xyz' + assert 'resume' not in attempts[0].url.query_params + + # Once connected the client resumes rather than recovers + assert attempts[1].url.query_params.get('resume') == 'new-key-after-recovery' + assert 'recover' not in attempts[1].url.query_params + + +# UTS: realtime/unit/RTN16f/recover-initializes-msgserial-0 +@deviation +async def test_rtn16f_recover_initializes_msgserial(): + recovery_key = json.dumps({ + 'connectionKey': 'old-key', + 'msgSerial': 42, + 'channelSerials': {'test-channel': 'ch-serial-1'}, + }) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected('recovered-conn', 'new-key'))) + mock_ws.on_message_from_client = attach_with_serials( + mock_ws, {'test-channel': 'ch-serial-updated'}) + client = realtime_client(mock_ws, recover=recovery_key) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel = client.channels.get('test-channel') + await channel.attach() + await await_channel_state(channel, ChannelState.ATTACHED) + + publish = asyncio.ensure_future(channel.publish('event', 'data')) + sent = await await_published(mock_ws) + mock_ws.send_to_client(ack(sent[0])) + await publish + + # RTN16f: the counter starts from the recovery key's msgSerial + assert sent[0]['msgSerial'] == 42 + + +# UTS: realtime/unit/RTN16f1/malformed-recovery-key-0 +async def test_rtn16f1_malformed_recovery_key(): + attempts = [] + + def on_connection_attempt(conn): + attempts.append(conn) + conn.respond_with_success(connected('fresh-conn', 'fresh-key')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, recover='this-is-not-valid-json!!!') + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert client.connection.state == ConnectionState.CONNECTED + # The specification reads `client.connection.id` and `client.connection.key`, + # which ably-python carries on the connection manager and the connection details + assert client.connection.connection_manager.connection_id == 'fresh-conn' + assert client.connection.connection_details.connection_key == 'fresh-key' + + # The malformed key reaches the connection parameters no more than a valid + # one would: `recover` is stored and never read + assert 'recover' not in attempts[0].url.query_params + assert 'resume' not in attempts[0].url.query_params + assert len(attempts) == 1 + + +# UTS: realtime/unit/RTN16j/recover-channel-serials-0 +@deviation +async def test_rtn16j_recover_channel_serials(): + recovery_key = json.dumps({ + 'connectionKey': 'old-key-abc', + 'msgSerial': 10, + 'channelSerials': { + 'channel-one': 'serial-1-abc', + 'channel-two': 'serial-2-def', + 'channel-üñîçöðé': 'serial-3-unicode', + }, + }) + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected('recovered-conn', 'new-key'))) + mock_ws.on_message_from_client = attach_with_serials( + mock_ws, {'channel-one': 'serial-1-abc-updated'}) + client = realtime_client(mock_ws, recover=recovery_key) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # RTN16j: the recovery key's channels are instantiated with their serials + channel_one = client.channels.get('channel-one') + channel_two = client.channels.get('channel-two') + channel_unicode = client.channels.get('channel-üñîçöðé') + + assert channel_serial(channel_one) == 'serial-1-abc' + assert channel_serial(channel_two) == 'serial-2-def' + assert channel_serial(channel_unicode) == 'serial-3-unicode' + + # RTN16i: they are instantiated, not attached + assert channel_one.state == ChannelState.INITIALIZED + assert channel_two.state == ChannelState.INITIALIZED + assert channel_unicode.state == ChannelState.INITIALIZED + + await channel_one.attach() + + attaches = [message for message in mock_ws.messages_from_client + if message.get('action') == ATTACH and message.get('channel') == 'channel-one'] + assert len(attaches) == 1 + assert attaches[0]['channelSerial'] == 'serial-1-abc' + + await await_channel_state(channel_one, ChannelState.ATTACHED) diff --git a/test/uts/realtime/unit/connection/error_reason_test.py b/test/uts/realtime/unit/connection/error_reason_test.py new file mode 100644 index 00000000..2ad7bca8 --- /dev/null +++ b/test/uts/realtime/unit/connection/error_reason_test.py @@ -0,0 +1,179 @@ +"""Derived from uts/realtime/unit/connection/error_reason_test.md in ably/specification. + +Spec points: RTN25 +""" + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import FakeClock, settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def error_message(code, status_code, message): + return { + 'action': int(ProtocolMessageAction.ERROR), + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def send_error_and_close(error): + def on_connection_attempt(conn): + conn.respond_with_success() + conn.send_to_client_and_close(error) + + return on_connection_attempt + + +# UTS: realtime/unit/RTN25/error-reason-on-failed-0 +async def test_rtn25_error_reason_on_failed(): + mock_ws = MockWebSocket( + on_connection_attempt=send_error_and_close(error_message(40005, 400, 'Invalid API key')), + ) + client = realtime_client(mock_ws, key='invalid.key:secret') + + assert client.connection.error_reason is None + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40005 + assert client.connection.error_reason.status_code == 400 + assert client.connection.error_reason.message == 'Invalid API key' + + +# UTS: realtime/unit/RTN25/error-reason-disconnected-1 +async def test_rtn25_error_reason_disconnected(): + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_refused()) + # A refused connect reaches DISCONNECTED when the connecting transition + # timer expires, so a short request timeout keeps that wait small + client = realtime_client(mock_ws, realtime_request_timeout=300) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.message is not None + + +# UTS: realtime/unit/RTN25/error-reason-suspended-2 +async def test_rtn25_error_reason_suspended(): + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_refused()) + clock = FakeClock() + client = realtime_client(mock_ws, clock=clock, disconnected_retry_timeout=500) + + client.connect() + await settle() + + # UTS SPEC ERROR: the specification advances past a 5000 ms + # connectionStateTtl. The interval the library suspends on is + # `Defaults.connection_state_ttl`, 120000 ms, which is also the default + # `features.md` gives for the attribute; 5000 ms would never suspend. + await clock.advance(120100) + + assert client.connection.state == ConnectionState.SUSPENDED + assert client.connection.error_reason is not None + assert client.connection.error_reason.message is not None + + +# UTS: realtime/unit/RTN25/error-reason-token-error-3 +async def test_rtn25_error_reason_token_error(): + mock_ws = MockWebSocket( + on_connection_attempt=send_error_and_close(error_message(40142, 401, 'Token expired')), + ) + client = realtime_client(mock_ws, token='expired_token') + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 40171 + + +# UTS: realtime/unit/RTN25/error-reason-cleared-on-connect-4 +async def test_rtn25_error_reason_cleared_on_connect(): + attempts = [] + + def on_connection_attempt(conn): + attempts.append(conn) + if len(attempts) == 1: + conn.respond_with_refused() + else: + conn.respond_with_success(CONNECTED_MESSAGE) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + clock = FakeClock() + client = realtime_client( + mock_ws, clock=clock, disconnected_retry_timeout=100, realtime_request_timeout=300) + + client.connect() + await settle() + await clock.advance(300) + + assert client.connection.state == ConnectionState.DISCONNECTED + failure_error = client.connection.error_reason + assert failure_error is not None + + await clock.advance(150) + await await_connection_state(client, ConnectionState.CONNECTED) + + # The specification asserts errorReason is null once connected while + # allowing an implementation to keep the last error instead. ably-python + # keeps it: `Connection` only ever replaces `error_reason` with a non-null + # reason, and clears it in `connect()`, which a retry does not go through. + assert client.connection.error_reason is failure_error + + +# UTS: realtime/unit/RTN25/error-reason-protocol-error-5 +async def test_rtn25_error_reason_protocol_error(): + mock_ws = MockWebSocket( + on_connection_attempt=send_error_and_close( + error_message(50000, 500, 'Internal server error')), + ) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == 50000 + assert client.connection.error_reason.status_code == 500 + assert client.connection.error_reason.message == 'Internal server error' + + +# UTS: realtime/unit/RTN25/error-reason-in-state-change-6 +async def test_rtn25_error_reason_in_state_change(): + mock_ws = MockWebSocket( + on_connection_attempt=send_error_and_close( + error_message(40003, 400, 'Access token invalid')), + ) + client = realtime_client(mock_ws) + + state_changes = [] + + # `EventEmitter.on` tests its listener with `inspect.isfunction`, so a + # bound built-in such as `list.append` is rejected + def on_failed(change): + state_changes.append(change) + + client.connection.on(ConnectionState.FAILED, on_failed) + + client.connect() + await await_connection_state(client, ConnectionState.FAILED) + await settle() + + assert len(state_changes) == 1 + + change = state_changes[0] + + assert change.reason is not None + assert change.reason.code == 40003 + assert change.reason.status_code == 400 + assert change.reason.message == 'Access token invalid' + + assert client.connection.error_reason is not None + assert client.connection.error_reason.code == change.reason.code + assert client.connection.error_reason.message == change.reason.message diff --git a/test/uts/realtime/unit/connection/fallback_hosts_test.py b/test/uts/realtime/unit/connection/fallback_hosts_test.py new file mode 100644 index 00000000..bcad6f43 --- /dev/null +++ b/test/uts/realtime/unit/connection/fallback_hosts_test.py @@ -0,0 +1,314 @@ +"""Derived from uts/realtime/unit/connection/fallback_hosts_test.md in ably/specification. + +Spec points: RTN17, RTN17e, RTN17f, RTN17f1, RTN17g, RTN17h, RTN17i, RTN17j + +Two adaptations run through the whole file, both recorded in +deviations.md. + +`ConnectionManager.check_connection` issues the RTN17j connectivity check with a +synchronous module-level `httpx.get`, which neither the client's HTTP layer nor +any other seam reaches. Every test here that leaves the client a fallback set +therefore replaces that function for the duration of the test, so nothing in the +batch touches the network. + +The specifications fail the primary host with `respond_with_refused()` or +`respond_with_timeout()`. `WebSocketTransport.ws_connect` catches only +`WebSocketException` and `socket.gaierror`, so neither produces the transport +failure that starts the fallback loop; an unresolvable host does, and is what +every test below fails the primary with. +""" + +import asyncio +import re +from types import SimpleNamespace + +import httpx + +from ably.realtime import connectionmanager +from ably.realtime.connection import ConnectionState +from ably.transport.defaults import Defaults +from test.uts.helpers.client import await_connection_state, poll_until, realtime_client +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +# REC1: the default endpoint gives the primary domain and the fallback set +PRIMARY_HOST = Defaults.get_hostname(Defaults.endpoint) +FALLBACK_PATTERN = re.compile(r'\.[abcde]\.fallback\.ably-realtime\.com$') + +CONNECTIVITY_CHECK = 'internet-up' + +DISCONNECTED_503 = { + 'action': 6, + 'error': {'code': 50003, 'statusCode': 503, 'message': 'Service temporarily unavailable'}, +} + + +def serve_connectivity_check(monkeypatch, body='yes'): + """Answers RTN17j's connectivity check in process, and records the calls. + + `check_connection` calls `httpx.get` directly, so the function itself is what + a test has to replace: a client-scoped seam cannot reach it, and left alone + it makes a real, blocking request on every failed connection. + """ + requests = [] + + def get(url, *args, **kwargs): + requests.append(SimpleNamespace(url=str(url), method='GET')) + return httpx.Response(200, text=body, request=httpx.Request('GET', url)) + + monkeypatch.setattr(connectionmanager.httpx, 'get', get) + return requests + + +def connected(connection_id='connection-id', connection_key='connection-key'): + """The CONNECTED message the specifications answer a fallback attempt with.""" + return connected_message( + connection_id, connectionKey=connection_key, + maxIdleInterval=15000, connectionStateTtl=120000) + + +def fallback_client(mock_websocket, **kwargs): + """A client left with the fallback set REC2 gives its endpoint. + + `realtime_client` defaults the set to empty, which every other derived test + wants and these tests are precisely about not having. + """ + kwargs.setdefault('fallback_hosts', None) + return realtime_client(mock_websocket, **kwargs) + + +# UTS: realtime/unit/RTN17i/prefer-primary-domain-0 +async def test_rtn17i_prefer_primary_domain(monkeypatch): + serve_connectivity_check(monkeypatch) + hosts = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + if len(hosts) == 1: + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected()) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = fallback_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + assert hosts[0] == PRIMARY_HOST + assert FALLBACK_PATTERN.search(hosts[1]) + + hosts.clear() + reconnected = [] + + def on_connected(change): + reconnected.append(change) + + def on_reconnection_attempt(conn): + hosts.append(conn.url.host) + conn.respond_with_success(connected('connection-id-2', 'connection-key-2')) + + mock_ws.on_connection_attempt = on_reconnection_attempt + client.connection.on(ConnectionState.CONNECTED, on_connected) + + # The specification waits out DISCONNECTED between the drop and the + # reconnection; RTN15a retries a drop from CONNECTED with no delay, so the + # state is gone before it can be waited on and the reconnection is what the + # test waits for + mock_ws.simulate_disconnect() + await poll_until(lambda: len(reconnected) > 0, timeout=10.0, + description='the connection to be re-established') + + assert len(hosts) >= 1 + # The primary domain is tried first even though it failed the last time + assert hosts[0] == PRIMARY_HOST + + +# UTS: realtime/unit/RTN17f/fallback-on-error-0 +async def test_rtn17f_fallback_on_error(monkeypatch): + serve_connectivity_check(monkeypatch) + hosts = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + if len(hosts) == 1: + # The specification writes "Host unresolvable" as the primary's + # failure; an unresolvable host is RSC15l's first condition + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected()) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = fallback_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + assert len(hosts) >= 2 + assert 'realtime.ably' in hosts[0] + assert 'fallback' in hosts[1] + + +# UTS: realtime/unit/RTN17f1/disconnected-5xx-fallback-0 +async def test_rtn17f1_disconnected_5xx_fallback(monkeypatch): + serve_connectivity_check(monkeypatch) + hosts = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + if len(hosts) == 1: + conn.respond_with_success() + conn.send_to_client_and_close(DISCONNECTED_503) + else: + conn.respond_with_success(connected()) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = fallback_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + assert len(hosts) >= 2 + assert 'realtime.ably' in hosts[0] + assert 'fallback' in hosts[1] + + +# UTS: realtime/unit/RTN17j/connectivity-check-before-fallback-0 +async def test_rtn17j_connectivity_check_before_fallback(monkeypatch): + http_requests = serve_connectivity_check(monkeypatch) + hosts = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + if len(hosts) == 1: + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected()) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = fallback_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=15.0) + + connectivity_checks = [request for request in http_requests + if CONNECTIVITY_CHECK in request.url] + assert len(connectivity_checks) >= 1 + assert connectivity_checks[0].method == 'GET' + assert len(hosts) >= 2 + + +# UTS: realtime/unit/RTN17g/empty-fallback-set-error-0 +async def test_rtn17g_empty_fallback_set_error(monkeypatch): + connectivity_checks = serve_connectivity_check(monkeypatch) + hosts = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + conn.respond_with_refused() + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + # REC2c2: a custom domain has no fallback set of its own + client = fallback_client( + mock_ws, realtime_host='custom.example.com', realtime_request_timeout=300, + disconnected_retry_timeout=60000) + + client.connect() + await await_connection_state(client, ConnectionState.DISCONNECTED, timeout=5.0) + + # Long enough for a fallback attempt to show up, were one going to be made + await asyncio.sleep(0.5) + + assert len(hosts) == 1 + assert hosts[0] == 'custom.example.com' + # An empty fallback set is answered immediately, with no connectivity check + assert connectivity_checks == [] + + +# UTS: realtime/unit/RTN17h/fallback-domains-from-rec2-0 +async def test_rtn17h_fallback_domains_from_rec2(monkeypatch): + serve_connectivity_check(monkeypatch) + hosts = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + if len(hosts) == 1: + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected()) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = fallback_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + assert len(hosts) >= 2 + fallback_host = hosts[1] + assert 'fallback.ably-realtime.com' in fallback_host + assert FALLBACK_PATTERN.search(fallback_host) + + +# UTS: realtime/unit/RTN17j/fallback-random-order-1 +async def test_rtn17j_fallback_random_order(monkeypatch): + serve_connectivity_check(monkeypatch) + fallback_orders = [] + + for _ in range(5): + hosts = [] + + def on_connection_attempt(conn, hosts=hosts): + hosts.append(conn.url.host) + if len(hosts) <= 3: + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected()) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = fallback_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=15.0) + + fallback_orders.append(tuple(hosts[1:])) + await client.close() + + assert len(set(fallback_orders)) >= 2 + + +# UTS: realtime/unit/RTN17e/http-uses-same-fallback-0 +async def test_rtn17e_http_uses_same_fallback(monkeypatch): + serve_connectivity_check(monkeypatch) + channel_name = 'test-RTN17e' + hosts = [] + http_requests = [] + + def on_connection_attempt(conn): + hosts.append(conn.url.host) + if len(hosts) == 1: + conn.respond_with_dns_error() + else: + conn.respond_with_success(connected()) + + def on_request(request): + http_requests.append(request) + request.respond_with(200, []) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + mock_http = MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request) + client = fallback_client(mock_ws, mock_http=mock_http) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED, timeout=10.0) + + connected_fallback_host = hosts[1] + + channel = client.channels.get(channel_name) + await channel.history() + + history_requests = [request for request in http_requests + if 'messages' in request.url.path] + assert len(history_requests) >= 1 + assert history_requests[0].url.host == connected_fallback_host diff --git a/test/uts/realtime/unit/connection/forwards_compatibility_test.py b/test/uts/realtime/unit/connection/forwards_compatibility_test.py new file mode 100644 index 00000000..78f287b1 --- /dev/null +++ b/test/uts/realtime/unit/connection/forwards_compatibility_test.py @@ -0,0 +1,173 @@ +"""Derived from uts/realtime/unit/connection/forwards_compatibility_test.md in ably/specification. + +Spec points: RTF1, RSF1 + +The specification injects its raw frames with `send_to_client_raw`, to keep a +ProtocolMessage constructor from stripping the unknown fields. Here a message is a +plain dictionary all the way to the wire, so `send_to_client` carries the unknown +fields as written and no separate method is needed. +""" + +from ably.realtime.channel import ChannelState +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import HEARTBEAT_MESSAGE, MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + +MESSAGE_ACTION = int(ProtocolMessageAction.MESSAGE) + + +def attach_responder(mock_ws): + """Answers every ATTACH the client sends with an ATTACHED for that channel.""" + def on_message_from_client(message): + if message.get('action') == int(ProtocolMessageAction.ATTACH): + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ATTACHED), + 'channel': message.get('channel'), + 'flags': 0, + }) + return on_message_from_client + + +# UTS: realtime/unit/RTF1/unrecognised-attributes-ignored-0 +async def test_rtf1_unrecognised_attributes_ignored(): + channel_name = 'test-RTF1-extra-attrs' + received_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws) + client = realtime_client(mock_ws, key='appId.keyId:keySecret', use_binary_protocol=False) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel = client.channels.get(channel_name) + # ably-python's `subscribe` attaches and resolves once attached, so it stands in + # for the specification's `subscribe` followed by `attach` + await channel.subscribe(lambda message: received_messages.append(message)) + + assert channel.state == ChannelState.ATTACHED + + mock_ws.send_to_client({ + 'action': MESSAGE_ACTION, + 'channel': channel_name, + 'messages': [ + { + 'name': 'test-event', + 'data': 'hello', + 'serial': 'msg-serial-1', + }, + ], + 'unknownField1': 'some-future-value', + 'unknownField2': 42, + 'unknownNestedObject': {'nestedKey': 'nestedValue'}, + 'unknownArray': [1, 2, 3], + }) + + await settle() + + assert len(received_messages) == 1 + assert received_messages[0].name == 'test-event' + assert received_messages[0].data == 'hello' + + assert client.connection.state == ConnectionState.CONNECTED + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTF1/unknown-action-handled-1 +async def test_rtf1_unknown_action_handled(): + channel_name = 'test-RTF1-unknown-action' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(message): + # Echoing the ping's heartbeat is what shows the read loop still carrying + # messages after the unknown action + if message.get('action') == int(ProtocolMessageAction.HEARTBEAT): + mock_ws.send_to_client({'action': int(ProtocolMessageAction.HEARTBEAT), + 'id': message.get('id')}) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, key='appId.keyId:keySecret', use_binary_protocol=False) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change.current)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # Action 254 is not defined in the current specification + mock_ws.send_to_client({ + 'action': 254, + 'channel': channel_name, + 'unknownPayload': 'future-feature-data', + }) + + mock_ws.send_to_client(HEARTBEAT_MESSAGE) + + await settle() + await client.connection.ping() + + assert client.connection.state == ConnectionState.CONNECTED + assert state_changes == [ConnectionState.CONNECTING, ConnectionState.CONNECTED] + assert ConnectionState.DISCONNECTED not in state_changes + assert ConnectionState.FAILED not in state_changes + + +# UTS: realtime/unit/RSF1/message-unrecognised-attrs-0 +async def test_rsf1_message_unrecognised_attrs(): + channel_name = 'test-RSF1-extra-attrs' + received_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + mock_ws.on_message_from_client = attach_responder(mock_ws) + client = realtime_client(mock_ws, key='appId.keyId:keySecret', use_binary_protocol=False) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + channel = client.channels.get(channel_name) + await channel.subscribe(lambda message: received_messages.append(message)) + + assert channel.state == ChannelState.ATTACHED + + mock_ws.send_to_client({ + 'action': MESSAGE_ACTION, + 'channel': channel_name, + 'messages': [ + { + 'name': 'event-1', + 'data': 'payload-1', + 'serial': 'serial-1', + 'futureField': 'future-value', + 'futureNumber': 99, + 'futureObject': {'nested': True}, + }, + { + 'name': 'event-2', + 'data': 'payload-2', + 'serial': 'serial-2', + 'anotherUnknownField': [1, 2, 3], + }, + ], + }) + + await settle() + + assert len(received_messages) == 2 + assert received_messages[0].name == 'event-1' + assert received_messages[0].data == 'payload-1' + assert received_messages[1].name == 'event-2' + assert received_messages[1].data == 'payload-2' + + assert client.connection.state == ConnectionState.CONNECTED + assert channel.state == ChannelState.ATTACHED diff --git a/test/uts/realtime/unit/connection/heartbeat_test.py b/test/uts/realtime/unit/connection/heartbeat_test.py new file mode 100644 index 00000000..97475258 --- /dev/null +++ b/test/uts/realtime/unit/connection/heartbeat_test.py @@ -0,0 +1,424 @@ +"""Derived from uts/realtime/unit/connection/heartbeat_test.md in ably/specification. + +Spec points: RTN23, RTN23a, RTN23b, RTN23c, RTN23c1 + +The idle timer is driven on real time here rather than with a `FakeClock`: +`WebSocketTransport.on_idle_timer_expire` schedules through the timer seam but +measures elapsed time against the real clock, so an advance fires the timer +while no time has passed and it reschedules itself. `maxIdleInterval` arrives in +the CONNECTED message and is honoured, so the intervals the specification quotes +in seconds are scaled down to the hundreds of milliseconds below. +""" + +import asyncio + +import pytest + +from ably.realtime.connection import ConnectionState +from test.uts.helpers.client import ( + await_connection_state, + next_connection_state, + realtime_client, +) +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + HEARTBEAT_MESSAGE, + PING_MESSAGE, + MockEventType, + MockWebSocket, + connected_message, + contains_in_order, + message_protocol_message, +) + +# The specification's `maxIdleInterval` of 2-5 seconds and `realtimeRequestTimeout` +# of 1-2 seconds, scaled so that the whole batch runs in real time +MAX_IDLE_INTERVAL = 200 +REQUEST_TIMEOUT = 200 + +# What the transport waits from the last activity before it declares the +# connection dropped: `maxIdleInterval + realtimeRequestTimeout`, plus the 100 ms +# of slack `on_activity` adds when it arms the timer +IDLE_TIMEOUT = (MAX_IDLE_INTERVAL + REQUEST_TIMEOUT + 100) / 1000 + +# A wait short enough to leave the idle timer running, standing in for the +# specification's `ADVANCE_TIME` between one server message and the next +WITHIN_IDLE_TIMEOUT = IDLE_TIMEOUT * 0.4 + +RECONNECT_CYCLE = ConnectionState.CONNECTING, ConnectionState.CONNECTED + +FULL_CYCLE = [ + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED, + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, +] + +PING_FRAMES_SKIP = ( + 'RTN23b applies to platforms whose websocket client surfaces ping frame events. ' + 'The `websockets` library answers pings inside the protocol and offers no hook, so ' + '`WebSocketTransport` cannot see one: a frame from `send_ping_frame()` reaches no ' + 'library code and cannot reset the idle timer. ably-python is an RTN23a platform, ' + 'and the specification says the RTN23b tests do not apply to one. ' + 'See deviations.md.') + +HEARTBEATS_BOUNCE_SKIP = ( + 'RTN23c applies to a client whose own code may be suspended while the transport ' + 'stays alive and keeps answering transport-level liveness checks, which the ' + 'specification scopes to browser builds. ably-python has no such build, so there is ' + 'no configuration of it under which `heartbeats=bounce` is the value to send. That ' + 'it sends no `heartbeats` parameter at all is recorded against RTN23a. ' + 'See deviations.md.') + + +def liveness_client(mock_websocket, **kwargs): + """A client whose connection drops `IDLE_TIMEOUT` after the last message.""" + kwargs.setdefault('realtime_request_timeout', REQUEST_TIMEOUT) + kwargs.setdefault('disconnected_retry_timeout', 500) + return realtime_client(mock_websocket, **kwargs) + + +def numbered_connections(attempts, max_idle_interval=MAX_IDLE_INTERVAL): + """An attempt handler answering the nth attempt with connection details n. + + Every test in the specification numbers its connections this way, so that a + reconnection can be told from the connection it replaced. + """ + def on_connection_attempt(conn): + attempts.append(conn) + conn.respond_with_success(connected_message( + f'connection-id-{len(attempts)}', + connectionKey=f'connection-key-{len(attempts)}', + maxIdleInterval=max_idle_interval, + connectionStateTtl=120000)) + return on_connection_attempt + + +def record_states(client): + """The state changes the connection reports from now on.""" + states = [] + + def record(change): + states.append(change.current) + + client.connection.on(record) + return states + + +# UTS: realtime/unit/RTN23a/heartbeats-true-query-param-0 +@deviation +async def test_rtn23a_heartbeats_true_query_param(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts, 15000)) + client = liveness_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # ably-python cannot observe ping frames, so RTN23b has it ask the server for + # HEARTBEAT protocol messages instead. It sends no `heartbeats` parameter at all + assert attempts[0].url.query_params.get('heartbeats') == 'true' + + +# UTS: realtime/unit/RTN23a/idle-timeout-reconnect-1 +async def test_rtn23a_idle_timeout_reconnect(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 1 + + # The server sends nothing more, so the idle timer runs out and the client + # reconnects at once, per RTN15a + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + # The specification reads `client.connection.id`, which ably-python carries + # on the connection manager + assert client.connection.connection_manager.connection_id == 'connection-id-2' + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23a/heartbeat-resets-timer-2 +async def test_rtn23a_heartbeat_resets_timer(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 1 + + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + mock_ws.send_to_client(HEARTBEAT_MESSAGE) + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + + # Longer than the idle timeout has passed since CONNECTED, but not since the + # HEARTBEAT, so the connection is still up + assert client.connection.state == ConnectionState.CONNECTED + assert len(attempts) == 1 + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 2 + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23a/any-message-resets-timer-3 +async def test_rtn23a_any_message_resets_timer(): + channel_name = 'test-RTN23a-message' + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + # The channel is created so that the MESSAGE below routes to a channel the + # collection holds; `Channels._on_channel_message` raises on one it does not + client.channels.get(channel_name) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + mock_ws.send_to_client({'action': 1, 'msgSerial': 0, 'count': 1}) + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + + assert client.connection.state == ConnectionState.CONNECTED + + mock_ws.send_to_client(message_protocol_message( + channel_name, [{'name': 'event', 'data': 'data'}])) + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + + assert len(attempts) == 1 + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23a/timeout-triggers-reconnect-4 +async def test_rtn23a_timeout_triggers_reconnect(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 1 + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + assert client.connection.state == ConnectionState.CONNECTED + assert client.connection.connection_manager.connection_id == 'connection-id-2' + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23a/reconnect-uses-resume-5 +async def test_rtn23a_reconnect_uses_resume(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + assert 'resume' not in attempts[0].url.query_params + assert attempts[1].url.query_params['resume'] == 'connection-key-1' + + +# UTS: realtime/unit/RTN23a/ping-resets-timer-6 +async def test_rtn23a_ping_resets_timer(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 1 + + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + # A PING is activity like any other message, which is what a client sending + # `heartbeats=bounce` relies on. ably-python resets the idle timer for every + # protocol message before it looks at the action, so it counts here too + mock_ws.send_to_client(PING_MESSAGE('ping-1')) + await asyncio.sleep(WITHIN_IDLE_TIMEOUT) + + assert client.connection.state == ConnectionState.CONNECTED + assert len(attempts) == 1 + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 2 + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23b/heartbeats-false-query-param-0 +async def test_rtn23b_heartbeats_false_query_param(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts, 15000)) + client = liveness_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # The parameter is absent, which is what RTN23b permits a client that can see + # ping frames to send. ably-python cannot see them, so the value RTN23a asks + # of it is `true`; that it sends neither is recorded against RTN23a above + heartbeats = attempts[0].url.query_params.get('heartbeats') + assert heartbeats == 'false' or heartbeats is None + + +# UTS: realtime/unit/RTN23b/idle-timeout-reconnect-1 +async def test_rtn23b_idle_timeout_reconnect(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 1 + + # No messages and no ping frames reach the client, so the idle timer runs out + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + assert client.connection.connection_manager.connection_id == 'connection-id-2' + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23b/ping-frame-resets-timer-2 +@pytest.mark.skip(reason=PING_FRAMES_SKIP) +async def test_rtn23b_ping_frame_resets_timer(): + pass + + +# UTS: realtime/unit/RTN23b/any-message-resets-timer-3 +@pytest.mark.skip(reason=PING_FRAMES_SKIP) +async def test_rtn23b_any_message_resets_timer(): + pass + + +# UTS: realtime/unit/RTN23b/timeout-triggers-reconnect-4 +async def test_rtn23b_timeout_triggers_reconnect(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert len(attempts) == 1 + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + assert client.connection.state == ConnectionState.CONNECTED + assert client.connection.connection_manager.connection_id == 'connection-id-2' + assert len(mock_ws.events_of_type(MockEventType.CLIENT_CLOSE)) == 1 + + +# UTS: realtime/unit/RTN23b/reconnect-uses-resume-5 +async def test_rtn23b_reconnect_uses_resume(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts)) + client = liveness_client(mock_ws) + states = record_states(client) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + await next_connection_state(client, ConnectionState.CONNECTED) + + assert contains_in_order(states, FULL_CYCLE) + assert len(attempts) == 2 + assert 'resume' not in attempts[0].url.query_params + assert attempts[1].url.query_params['resume'] == 'connection-key-1' + + +# UTS: realtime/unit/RTN23b/multiple-pings-keep-alive-6 +@pytest.mark.skip(reason=PING_FRAMES_SKIP) +async def test_rtn23b_multiple_pings_keep_alive(): + pass + + +# UTS: realtime/unit/RTN23c/heartbeats-bounce-query-param-0 +@pytest.mark.skip(reason=HEARTBEATS_BOUNCE_SKIP) +async def test_rtn23c_heartbeats_bounce_query_param(): + pass + + +# UTS: realtime/unit/RTN23c1/ping-pong-echo-id-0 +@deviation +async def test_rtn23c1_ping_pong_echo_id(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts, 15000)) + client = liveness_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + # RTN23c1 binds every client, whatever `heartbeats` value it sent. ably-python + # handles no PING: action 22 matches no branch of `on_protocol_message`, so + # nothing leaves the client and this wait runs out + mock_ws.send_to_client(PING_MESSAGE('ping-1')) + pong_with_id = await mock_ws.await_next_message_from_client(timeout=1.0) + + mock_ws.send_to_client({'action': 22}) + pong_without_id = await mock_ws.await_next_message_from_client(timeout=1.0) + + assert pong_with_id['action'] == 23 + assert pong_with_id['id'] == 'ping-1' + assert pong_with_id.get('msgSerial') is None + + assert pong_without_id['action'] == 23 + assert pong_without_id.get('id') is None + assert pong_without_id.get('msgSerial') is None + + for pong in (pong_with_id, pong_without_id): + assert pong.get('channel') is None + assert pong.get('messages') is None + assert pong.get('presence') is None + + assert len(mock_ws.messages_from_client) == 2 + + +# UTS: realtime/unit/RTN23c1/pong-regardless-of-heartbeats-param-1 +@deviation +async def test_rtn23c1_pong_regardless_of_heartbeats_param(): + attempts = [] + mock_ws = MockWebSocket(on_connection_attempt=numbered_connections(attempts, 15000)) + client = liveness_client(mock_ws, transport_params={'heartbeats': 'false'}) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + assert attempts[0].url.query_params['heartbeats'] == 'false' + + mock_ws.send_to_client(PING_MESSAGE('ping-1')) + pong = await mock_ws.await_next_message_from_client(timeout=1.0) + + assert pong['action'] == 23 + assert pong['id'] == 'ping-1' diff --git a/test/uts/realtime/unit/connection/network_change_test.py b/test/uts/realtime/unit/connection/network_change_test.py new file mode 100644 index 00000000..baf5a2a3 --- /dev/null +++ b/test/uts/realtime/unit/connection/network_change_test.py @@ -0,0 +1,38 @@ +"""Derived from uts/realtime/unit/connection/network_change_test.md in ably/specification. + +Spec points: RTN20, RTN20a, RTN20b, RTN20c +""" + +import pytest + +NETWORK_LISTENER_SKIP = ( + 'RTN20 applies only "when the client library can subscribe to OS events for ' + 'network/internet connectivity changes". ably-python subscribes to none, and has no ' + 'network connectivity listener interface for a mock to stand in for, so there is ' + 'nothing to install and no event to simulate. The specification itself lists Python ' + 'as a platform where RTN20 may not apply and says such SDKs should skip these tests. ' + 'See deviations.md.') + + +# UTS: realtime/unit/RTN20a/network-loss-connected-disconnects-0 +@pytest.mark.skip(reason=NETWORK_LISTENER_SKIP) +async def test_rtn20a_network_loss_connected_disconnects(): + pass + + +# UTS: realtime/unit/RTN20a/network-loss-connecting-disconnects-1 +@pytest.mark.skip(reason=NETWORK_LISTENER_SKIP) +async def test_rtn20a_network_loss_connecting_disconnects(): + pass + + +# UTS: realtime/unit/RTN20b/network-available-disconnected-connects-0 +@pytest.mark.skip(reason=NETWORK_LISTENER_SKIP) +async def test_rtn20b_network_available_disconnected_connects(): + pass + + +# UTS: realtime/unit/RTN20c/network-available-connecting-restarts-0 +@pytest.mark.skip(reason=NETWORK_LISTENER_SKIP) +async def test_rtn20c_network_available_connecting_restarts(): + pass diff --git a/test/uts/realtime/unit/connection/server_initiated_reauth_test.py b/test/uts/realtime/unit/connection/server_initiated_reauth_test.py new file mode 100644 index 00000000..3b0fa5c4 --- /dev/null +++ b/test/uts/realtime/unit/connection/server_initiated_reauth_test.py @@ -0,0 +1,159 @@ +"""Derived from uts/realtime/unit/connection/server_initiated_reauth_test.md in ably/specification. + +Spec points: RTN22, RTN22a +""" + +import time + +from ably.realtime.connection import ConnectionEvent, ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.tokendetails import TokenDetails +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +AUTH_ACTION = int(ProtocolMessageAction.AUTH) +DISCONNECTED_ACTION = int(ProtocolMessageAction.DISCONNECTED) + +# How many settling passes a reauth is given: the server's AUTH, the token the auth +# callback returns, the client's AUTH and the CONNECTED that answers it +REAUTH_PASSES = 20 + + +def token_details(token): + return TokenDetails(token=token, expires=int(time.time() * 1000) + 3600000) + + +async def settle_until(predicate, message, passes=REAUTH_PASSES): + """Settles until `predicate` holds, as the specifications' `AWAIT UNTIL` does.""" + for _ in range(passes): + if predicate(): + return + await settle() + raise AssertionError(f'Timed out waiting for {message}') + + +# UTS: realtime/unit/RTN22/server-auth-triggers-reauth-0 +async def test_rtn22_server_auth_triggers_reauth(): + auth_callback_calls = [] + captured_auth_messages = [] + + async def auth_callback(token_params): + auth_callback_calls.append(token_params) + return token_details(f'token-{len(auth_callback_calls)}') + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('connection-id', connectionKey='connection-key')), + ) + client = realtime_client(mock_ws, auth_callback=auth_callback) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + def on_message_from_client(message): + if message.get('action') == AUTH_ACTION: + captured_auth_messages.append(message) + mock_ws.send_to_client( + connected_message('connection-id', connectionKey='connection-key-2')) + + mock_ws.on_message_from_client = on_message_from_client + + mock_ws.send_to_client({'action': AUTH_ACTION}) + + await settle_until( + lambda: any(change.event == ConnectionEvent.UPDATE for change in state_changes), + 'the UPDATE event that signals reauth completion') + + assert len(auth_callback_calls) == 2 + + assert len(captured_auth_messages) == 1 + assert captured_auth_messages[0].get('auth') is not None + assert captured_auth_messages[0]['auth']['accessToken'] == 'token-2' + + assert [change for change in state_changes + if change.current != ConnectionState.CONNECTED] == [] + + update_events = [change for change in state_changes + if change.event == ConnectionEvent.UPDATE] + assert len(update_events) == 1 + + +# UTS: realtime/unit/RTN22/stays-connected-during-reauth-1 +async def test_rtn22_stays_connected_during_reauth(): + auth_callback_calls = [] + + async def auth_callback(token_params): + auth_callback_calls.append(token_params) + return token_details(f'reauth-token-{len(auth_callback_calls)}') + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('conn-1', connectionKey='key-1')), + ) + + def on_message_from_client(message): + if message.get('action') == AUTH_ACTION: + mock_ws.send_to_client(connected_message('conn-1', connectionKey='key-1-updated')) + + mock_ws.on_message_from_client = on_message_from_client + client = realtime_client(mock_ws, auth_callback=auth_callback) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + mock_ws.send_to_client({'action': AUTH_ACTION}) + + await settle_until(lambda: len(state_changes) >= 1, 'the UPDATE event') + + assert client.connection.state == ConnectionState.CONNECTED + + assert len(state_changes) == 1 + assert state_changes[0].event == ConnectionEvent.UPDATE + assert state_changes[0].current == ConnectionState.CONNECTED + assert state_changes[0].previous == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTN22a/forced-disconnect-reauth-failure-0 +async def test_rtn22a_forced_disconnect_reauth_failure(): + auth_callback_calls = [] + + async def auth_callback(token_params): + auth_callback_calls.append(token_params) + return token_details(f'recovery-token-{len(auth_callback_calls)}') + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('conn-1', connectionKey='key-1')), + ) + client = realtime_client(mock_ws, auth_callback=auth_callback) + + # RTN15h recovery leaves DISCONNECTED again immediately, so the state changes + # are recorded and asserted on rather than waited for + state_changes = [] + client.connection.on(lambda change: state_changes.append(change)) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + mock_ws.send_to_client({ + 'action': DISCONNECTED_ACTION, + 'error': {'message': 'Token expired', 'code': 40142, 'statusCode': 401}, + }) + + await await_connection_state(client, ConnectionState.CONNECTING) + + disconnected = [change for change in state_changes + if change.current == ConnectionState.DISCONNECTED] + assert len(disconnected) == 1 + assert disconnected[0].reason is not None + assert disconnected[0].reason.code == 40142 + + # The recovery obtains a new token before reconnecting + assert len(auth_callback_calls) == 2 diff --git a/test/uts/realtime/unit/connection/update_events_test.py b/test/uts/realtime/unit/connection/update_events_test.py new file mode 100644 index 00000000..34f51b09 --- /dev/null +++ b/test/uts/realtime/unit/connection/update_events_test.py @@ -0,0 +1,218 @@ +"""Derived from uts/realtime/unit/connection/update_events_test.md in ably/specification. + +Spec points: RTN24 +""" + +from ably.realtime.connection import ConnectionState +from ably.types.connectionstate import ConnectionEvent +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +# Every connection state, for the specification's "subscribe to everything and +# count what arrives" +CONNECTION_STATES = ( + ConnectionState.INITIALIZED, + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED, + ConnectionState.SUSPENDED, + ConnectionState.CLOSING, + ConnectionState.CLOSED, + ConnectionState.FAILED, +) + + +def connection_id(client): + """The specifications' `connection.id`; ably-python keeps it on the + connection manager. See [deviations.md](../../../deviations.md).""" + return client.connection.connection_manager.connection_id + + +def connection_key(client): + """The specifications' `connection.key`; ably-python reaches it through the + connection details. See [deviations.md](../../../deviations.md).""" + details = client.connection.connection_details + return details.connection_key if details is not None else None + + +def respond_with(message): + def on_connection_attempt(conn): + conn.respond_with_success(message) + + return on_connection_attempt + + +# UTS: realtime/unit/RTN24/connected-emits-update-0 +async def test_rtn24_connected_emits_update(): + first = connected_message( + 'connection-id-1', connectionKey='connection-key-1', clientId='client-123') + mock_ws = MockWebSocket(on_connection_attempt=respond_with(first)) + client = realtime_client(mock_ws) + + connected_events = [] + update_events = [] + + def on_connected(change): + connected_events.append(change) + + def on_update(change): + update_events.append(change) + + client.connection.on(ConnectionState.CONNECTED, on_connected) + client.connection.on(ConnectionEvent.UPDATE, on_update) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await settle() + + assert len(connected_events) == 1 + assert len(update_events) == 0 + + # connectionId is a top-level ProtocolMessage field rather than part of + # connectionDetails, so it does not change for an in-progress connection + mock_ws.send_to_client(connected_message( + 'connection-id-1', connectionKey='connection-key-1', clientId='client-123', + maxIdleInterval=20000)) + await settle() + + assert client.connection.state == ConnectionState.CONNECTED + assert len(connected_events) == 1 + assert len(update_events) == 1 + + update_change = update_events[0] + + assert update_change.previous == ConnectionState.CONNECTED + assert update_change.current == ConnectionState.CONNECTED + assert update_change.reason is None + + assert connection_id(client) == 'connection-id-1' + assert connection_key(client) == 'connection-key-1' + + +# UTS: realtime/unit/RTN24/update-event-with-error-1 +@deviation +async def test_rtn24_update_event_with_error(): + first = connected_message('connection-id-1', connectionKey='connection-key-1') + mock_ws = MockWebSocket(on_connection_attempt=respond_with(first)) + client = realtime_client(mock_ws) + + update_events = [] + + def on_update(change): + update_events.append(change) + + client.connection.on(ConnectionEvent.UPDATE, on_update) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await settle() + + renewed = connected_message('connection-id-1', connectionKey='connection-key-1') + renewed['error'] = { + 'code': 40142, 'statusCode': 401, 'message': 'Token expired; renewed automatically'} + mock_ws.send_to_client(renewed) + await settle() + + assert len(update_events) == 1 + + update_change = update_events[0] + + assert update_change.previous == ConnectionState.CONNECTED + assert update_change.current == ConnectionState.CONNECTED + assert update_change.reason is not None + assert update_change.reason.code == 40142 + assert update_change.reason.status_code == 401 + assert 'Token expired' in update_change.reason.message + + +# UTS: realtime/unit/RTN24/connection-details-override-2 +async def test_rtn24_connection_details_override(): + first = connected_message( + 'connection-id-1', connectionKey='connection-key-1', maxIdleInterval=10000, + connectionStateTtl=60000, maxMessageSize=16384, serverId='server-1', + clientId='client-original') + mock_ws = MockWebSocket(on_connection_attempt=respond_with(first)) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await settle() + + assert connection_id(client) == 'connection-id-1' + assert connection_key(client) == 'connection-key-1' + + # UTS SPEC ERROR: the specification's second message also changes clientId, + # which no CONNECTED for an in-progress connection does, and which RSA15 + # makes immutable once configured. The clientId is held at the value the + # first message established so that the override the test is about — the + # operational parameters — is what it exercises. + mock_ws.send_to_client(connected_message( + 'connection-id-1', connectionKey='connection-key-1', maxIdleInterval=20000, + connectionStateTtl=120000, maxMessageSize=32768, serverId='server-2', + clientId='client-original')) + await settle() + + assert connection_id(client) == 'connection-id-1' + assert connection_key(client) == 'connection-key-1' + + # The specification leaves the accessors for the overridden details open. + # ably-python keeps them on `Connection#connection_details`, which parses + # connectionStateTtl, maxIdleInterval, connectionKey and clientId only. + details = client.connection.connection_details + + assert details.max_idle_interval == 20000 + assert details.connection_state_ttl == 120000 + + assert client.connection.state == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTN24/no-duplicate-connected-event-3 +async def test_rtn24_no_duplicate_connected_event(): + first = connected_message('connection-id-1', connectionKey='connection-key-1') + mock_ws = MockWebSocket(on_connection_attempt=respond_with(first)) + client = realtime_client(mock_ws) + + all_events = [] + + def record_state(state): + def on_state(change): + all_events.append({'type': 'state', 'state': state, 'change': change}) + + return on_state + + for state in CONNECTION_STATES: + client.connection.on(state, record_state(state)) + + def on_update(change): + all_events.append({'type': 'update', 'change': change}) + + client.connection.on(ConnectionEvent.UPDATE, on_update) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await settle() + + initial_event_count = len(all_events) + + for _ in range(3): + mock_ws.send_to_client( + connected_message('connection-id-1', connectionKey='connection-key-1')) + await settle() + + new_events = all_events[initial_event_count:] + + assert len(new_events) == 3 + + for event in new_events: + assert event['type'] == 'update' + assert event['change'].previous == ConnectionState.CONNECTED + assert event['change'].current == ConnectionState.CONNECTED + + connected_state_events = [ + event for event in all_events + if event['type'] == 'state' and event['state'] == ConnectionState.CONNECTED + ] + + assert len(connected_state_events) == 1 diff --git a/test/uts/realtime/unit/connection/when_state_test.py b/test/uts/realtime/unit/connection/when_state_test.py new file mode 100644 index 00000000..63f2f9d0 --- /dev/null +++ b/test/uts/realtime/unit/connection/when_state_test.py @@ -0,0 +1,188 @@ +"""Derived from uts/realtime/unit/connection/when_state_test.md in ably/specification. + +Spec points: RTN26, RTN26a, RTN26b +""" + +import asyncio + +from ably.realtime.connection import ConnectionState +from test.uts.helpers.client import STATE_TIMEOUT, await_connection_state, realtime_client +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, connected_message + +CONNECTED_MESSAGE = connected_message('connection-id', connectionKey='connection-key') + + +def when_state(connection, state): + """The specifications' `connection.whenState(state, listener)`. + + ably-python spells it `Connection._when_state(state)`, which returns an + awaitable instead of taking a listener: already resolved with `None` when + the connection is in `state` (RTN26a), and otherwise resolving with the + `ConnectionStateChange` that enters it (RTN26b). Run as a task it carries + the same two observables a listener does — whether it has been called, and + with what. See [deviations.md](../../../deviations.md). + + The deferred branch registers its `once` when the coroutine starts rather + than when it is created, so a caller yields to the event loop after this + returns if the state may change in the meantime. + """ + return asyncio.ensure_future(connection._when_state(state)) + + +def next_connection_state(connection, state): + """An awaitable of the *next* entry into `state`, registered now. + + `await_connection_state` returns at once for the state the connection is + already in, which is not what a test asserting a second entry into a state + wants. + """ + reached = asyncio.get_running_loop().create_future() + + def on_state(change): + if not reached.done(): + reached.set_result(change) + + connection.once(state, on_state) + return asyncio.wait_for(reached, STATE_TIMEOUT) + + +# UTS: realtime/unit/RTN26a/immediate-callback-current-state-0 +async def test_rtn26a_immediate_callback_current_state(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + waiter = when_state(client.connection, ConnectionState.CONNECTED) + + # Resolved before the event loop is yielded to, which is the strongest + # reading of the specification's "invoked synchronously or very quickly" + assert waiter.done() + assert await waiter is None + + +# UTS: realtime/unit/RTN26b/deferred-callback-future-state-0 +async def test_rtn26b_deferred_callback_future_state(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + + assert client.connection.state == ConnectionState.INITIALIZED + + waiter = when_state(client.connection, ConnectionState.CONNECTED) + await settle() + + assert not waiter.done() + + client.connect() + change = await asyncio.wait_for(waiter, STATE_TIMEOUT) + + assert change is not None + assert change.previous in (ConnectionState.INITIALIZED, ConnectionState.CONNECTING) + assert change.current == ConnectionState.CONNECTED + + +# UTS: realtime/unit/RTN26b/fires-only-once-1 +async def test_rtn26b_fires_only_once(): + attempts = [] + + def on_connection_attempt(conn): + attempts.append(conn) + index = len(attempts) + conn.respond_with_success( + connected_message(f'connection-id-{index}', connectionKey=f'connection-key-{index}')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + client = realtime_client(mock_ws, disconnected_retry_timeout=100) + + invocations = [] + + async def record(): + invocations.append(await client.connection._when_state(ConnectionState.CONNECTED)) + + listener = asyncio.ensure_future(record()) + await settle() + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(listener, STATE_TIMEOUT) + + assert len(invocations) == 1 + + reconnected = next_connection_state(client.connection, ConnectionState.CONNECTED) + mock_ws.simulate_disconnect() + # RTN15a retries immediately after a drop from CONNECTED, so the second + # connection is established without any time passing + await reconnected + + assert client.connection.connection_manager.connection_id == 'connection-id-2' + assert len(invocations) == 1 + + +# UTS: realtime/unit/RTN26a/multiple-whenstate-calls-1 +async def test_rtn26a_multiple_whenstate_calls(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + + first = when_state(client.connection, ConnectionState.CONNECTED) + second = when_state(client.connection, ConnectionState.CONNECTED) + third = when_state(client.connection, ConnectionState.CONNECTING) + await settle() + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + await asyncio.wait_for(asyncio.gather(first, second, third), STATE_TIMEOUT) + + assert first.done() + assert second.done() + assert third.done() + + +# UTS: realtime/unit/RTN26a/no-fire-for-past-state-2 +async def test_rtn26a_no_fire_for_past_state(): + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + client = realtime_client(mock_ws) + + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + + waiter = when_state(client.connection, ConnectionState.CONNECTING) + await settle() + + # whenState reads the current state, not the states already passed through + assert not waiter.done() + waiter.cancel() + + +# UTS: realtime/unit/RTN26/whenstate-different-states-0 +async def test_rtn26_whenstate_different_states(): + mock_ws = MockWebSocket(on_connection_attempt=lambda conn: conn.respond_with_refused()) + # A refused connect reaches DISCONNECTED when the connecting transition + # timer expires, so a short request timeout keeps that wait small + client = realtime_client(mock_ws, realtime_request_timeout=300) + + initialized = when_state(client.connection, ConnectionState.INITIALIZED) + connecting = when_state(client.connection, ConnectionState.CONNECTING) + disconnected = when_state(client.connection, ConnectionState.DISCONNECTED) + await settle() + + assert initialized.done() + assert await initialized is None + assert not connecting.done() + assert not disconnected.done() + + client.connect() + await asyncio.wait_for(disconnected, STATE_TIMEOUT) + + assert initialized.done() + assert connecting.done() + assert disconnected.done() diff --git a/test/uts/realtime/unit/presence/__init__.py b/test/uts/realtime/unit/presence/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/uts/realtime/unit/presence/local_presence_map_test.py b/test/uts/realtime/unit/presence/local_presence_map_test.py new file mode 100644 index 00000000..9e2e5ce2 --- /dev/null +++ b/test/uts/realtime/unit/presence/local_presence_map_test.py @@ -0,0 +1,224 @@ +"""Derived from uts/realtime/unit/presence/local_presence_map.md in ably/specification. + +Spec points: RTP17, RTP17b, RTP17h, RTP2d2, RTP5a + +The specification's `LocalPresenceMap` is, in this SDK, the same `PresenceMap` class built +with `client_id` as its key function - `RealtimePresence._my_members` +(`ably/realtime/presence.py:79-81`). RTP17b's filtering of synthesized LEAVE events lives +one level up, in `RealtimePresence.set_presence()`, which the specification's +implementation note permits; the test for it therefore drives a `RealtimePresence`. See +test/uts/deviations.md. +""" + +from ably.realtime.presencemap import PresenceMap +from ably.types.presence import PresenceAction +from test.uts.helpers.deviations import deviation +from test.uts.helpers.presence import presence_message, subscribed_presence + + +def local_presence_map(): + """The specification's `LocalPresenceMap()`: the RTP17 map keyed by clientId.""" + return PresenceMap(member_key_fn=lambda msg: msg.client_id) + + +# UTS: realtime/unit/RTP17h/keyed-by-clientid-0 +@deviation +def test_rtp17h_keyed_by_clientid(): + members = local_presence_map() + + msg1 = presence_message( + PresenceAction.ENTER, 'user-1', 'conn-A', 'conn-A:0:0', 1000, data='first') + msg2 = presence_message( + PresenceAction.ENTER, 'user-1', 'conn-B', 'conn-B:0:0', 2000, data='second') + + members.put(msg1) + members.put(msg2) + + # Keyed by clientId, so the entry for the newer connection replaces the older one + assert len(members.values()) == 1 + assert members.get('user-1') is not None + assert members.get('user-1').data == 'second' + assert members.get('user-1').connection_id == 'conn-B' + + +# UTS: realtime/unit/RTP17b/enter-adds-to-map-0 +def test_rtp17b_enter_adds_to_map(): + members = local_presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='hello')) + + assert members.get('client-1') is not None + # RTP2d2: the stored action is always PRESENT + assert members.get('client-1').action == PresenceAction.PRESENT + assert members.get('client-1').data == 'hello' + assert len(members.values()) == 1 + + +# UTS: realtime/unit/RTP17b/update-adds-to-map-1 +def test_rtp17b_update_adds_to_map(): + members = local_presence_map() + + members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='from-update')) + + assert members.get('client-1') is not None + assert members.get('client-1').action == PresenceAction.PRESENT + assert members.get('client-1').data == 'from-update' + assert len(members.values()) == 1 + + +# UTS: realtime/unit/RTP17b/enter-overwrites-enter-2 +def test_rtp17b_enter_overwrites_enter(): + members = local_presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='first')) + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:1:0', 2000, data='second')) + + assert len(members.values()) == 1 + assert members.get('client-1').action == PresenceAction.PRESENT + assert members.get('client-1').data == 'second' + + +# UTS: realtime/unit/RTP17b/update-overwrites-enter-3 +def test_rtp17b_update_overwrites_enter(): + members = local_presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='initial')) + members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:1:0', 2000, data='updated')) + + assert len(members.values()) == 1 + assert members.get('client-1').action == PresenceAction.PRESENT + assert members.get('client-1').data == 'updated' + + +# UTS: realtime/unit/RTP17b/present-adds-to-map-4 +def test_rtp17b_present_adds_to_map(): + members = local_presence_map() + + members.put(presence_message( + PresenceAction.PRESENT, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='present')) + + assert members.get('client-1') is not None + assert members.get('client-1').action == PresenceAction.PRESENT + assert members.get('client-1').data == 'present' + + +# UTS: realtime/unit/RTP17b/non-synthesized-leave-removes-5 +def test_rtp17b_non_synthesized_leave_removes(): + members = local_presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000)) + + assert members.get('client-1') is not None + + # A non-synthesized LEAVE: connectionId "conn-1" is an initial substring of "conn-1:1:0" + result = members.remove( + presence_message(PresenceAction.LEAVE, 'client-1', 'conn-1', 'conn-1:1:0', 2000)) + + assert result is True + assert members.get('client-1') is None + assert len(members.values()) == 0 + + +# UTS: realtime/unit/RTP17b/synthesized-leave-ignored-6 +def test_rtp17b_synthesized_leave_ignored(): + # The specification's implementation note allows the synthesized-LEAVE filter to sit + # at the calling level, which is where this SDK keeps it, so the test drives + # `set_presence` rather than the map. + presence, _events = subscribed_presence('conn-1') + + presence.set_presence([ + presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='entered'), + ], is_sync=False) + + assert presence._my_members.get('client-1') is not None + + # A synthesized LEAVE: connectionId "conn-1" is not an initial substring of + # "synthesized-leave-id" + presence.set_presence([ + presence_message( + PresenceAction.LEAVE, 'client-1', 'conn-1', 'synthesized-leave-id', 2000), + ], is_sync=False) + + # The synthesized leave was not applied to the RTP17 map + assert presence._my_members.get('client-1') is not None + assert presence._my_members.get('client-1').data == 'entered' + assert len(presence._my_members.values()) == 1 + + +# UTS: realtime/unit/RTP17/multiple-clientids-coexist-0 +def test_rtp17_multiple_clientids_coexist(): + members = local_presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'alice', 'conn-1', 'conn-1:0:0', 100, data='alice-data')) + members.put(presence_message( + PresenceAction.ENTER, 'bob', 'conn-1', 'conn-1:0:1', 100, data='bob-data')) + members.put(presence_message( + PresenceAction.ENTER, 'carol', 'conn-1', 'conn-1:0:2', 100, data='carol-data')) + + assert len(members.values()) == 3 + assert members.get('alice') is not None + assert members.get('bob') is not None + assert members.get('carol') is not None + assert members.get('alice').data == 'alice-data' + assert members.get('bob').data == 'bob-data' + assert members.get('carol').data == 'carol-data' + + +# UTS: realtime/unit/RTP17/remove-one-of-multiple-1 +def test_rtp17_remove_one_of_multiple(): + members = local_presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'conn-1', 'conn-1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'conn-1', 'conn-1:0:1', 100)) + + members.remove(presence_message(PresenceAction.LEAVE, 'alice', 'conn-1', 'conn-1:1:0', 200)) + + assert members.get('alice') is None + assert members.get('bob') is not None + assert len(members.values()) == 1 + + +# UTS: realtime/unit/RTP17/clear-resets-state-2 +def test_rtp17_clear_resets_state(): + members = local_presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'conn-1', 'conn-1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'conn-1', 'conn-1:0:1', 100)) + + assert len(members.values()) == 2 + + members.clear() + + assert len(members.values()) == 0 + assert members.get('alice') is None + assert members.get('bob') is None + + +# UTS: realtime/unit/RTP17/get-null-unknown-clientid-3 +def test_rtp17_get_null_unknown_clientid(): + members = local_presence_map() + + result = members.get('nonexistent') + + assert result is None + + +# UTS: realtime/unit/RTP17/remove-unknown-noop-4 +def test_rtp17_remove_unknown_noop(): + members = local_presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'conn-1', 'conn-1:0:0', 100)) + + members.remove(presence_message( + PresenceAction.LEAVE, 'nonexistent', 'conn-1', 'conn-1:1:0', 200)) + + assert members.get('alice') is not None + assert len(members.values()) == 1 diff --git a/test/uts/realtime/unit/presence/presence_map_test.py b/test/uts/realtime/unit/presence/presence_map_test.py new file mode 100644 index 00000000..7277096e --- /dev/null +++ b/test/uts/realtime/unit/presence/presence_map_test.py @@ -0,0 +1,314 @@ +"""Derived from uts/realtime/unit/presence/presence_map.md in ably/specification. + +Spec points: RTP2, RTP2a, RTP2b, RTP2b1, RTP2b1a, RTP2b2, RTP2c, RTP2d, RTP2d1, RTP2d2, +RTP2h, RTP2h1, RTP2h1a, RTP2h1b, RTP2h2, RTP2h2a, RTP2h2b + +The specification drives a `PresenceMap` whose `put()` and `remove()` return the message to +emit, or null when the incoming message is stale. `ably/realtime/presencemap.py` returns a +bool instead and leaves the emission to `RealtimePresence.set_presence()`, so "IS NOT null" +is read as "returned True" and the emission assertions are made against a subscriber of a +`RealtimePresence` driven directly with the same messages. See +test/uts/deviations.md. +""" + +from ably.types.presence import PresenceAction +from test.uts.helpers.presence import presence_map, presence_message, subscribed_presence + + +# UTS: realtime/unit/RTP2/basic-put-and-get-0 +def test_rtp2_basic_put_and_get(): + members = presence_map() + + msg = presence_message(PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000) + result = members.put(msg) + + assert result is True + assert members.get('conn-1:client-1') is not None + assert members.get('conn-1:client-1').client_id == 'client-1' + assert members.get('conn-1:client-1').connection_id == 'conn-1' + + +# UTS: realtime/unit/RTP2d2/enter-stored-as-present-0 +def test_rtp2d2_enter_stored_as_present(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='entered')) + + stored = members.get('conn-1:client-1') + assert stored is not None + assert stored.action == PresenceAction.PRESENT + assert stored.data == 'entered' + + +# UTS: realtime/unit/RTP2d2/update-stored-as-present-1 +def test_rtp2d2_update_stored_as_present(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='initial')) + members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:1:0', 2000, data='updated')) + + stored = members.get('conn-1:client-1') + assert stored.action == PresenceAction.PRESENT + assert stored.data == 'updated' + + +# UTS: realtime/unit/RTP2d2/present-stored-as-present-2 +def test_rtp2d2_present_stored_as_present(): + members = presence_map() + + members.put(presence_message(PresenceAction.PRESENT, 'client-1', 'conn-1', 'conn-1:0:0', 1000)) + + stored = members.get('conn-1:client-1') + assert stored is not None + assert stored.action == PresenceAction.PRESENT + + +# UTS: realtime/unit/RTP2d1/put-returns-original-action-0 +def test_rtp2d1_put_returns_original_action(): + # The specification reads the message to emit off `put()`'s return value. Here the + # map answers with a bool and stores a copy, so the original action survives on the + # incoming message and reaches subscribers through `set_presence`. + members = presence_map() + + enter = presence_message(PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000) + update = presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:1:0', 2000, data='updated') + + assert members.put(enter) is True + assert enter.action == PresenceAction.ENTER + assert members.put(update) is True + assert update.action == PresenceAction.UPDATE + + presence, events = subscribed_presence() + presence.set_presence([enter, update], is_sync=False) + + assert [name for name, _ in events] == ['enter', 'update'] + assert events[0][1].action == PresenceAction.ENTER + assert events[1][1].action == PresenceAction.UPDATE + + +# UTS: realtime/unit/RTP2h1/leave-outside-sync-removes-0 +def test_rtp2h1_leave_outside_sync_removes(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000)) + leave = presence_message(PresenceAction.LEAVE, 'client-1', 'conn-1', 'conn-1:1:0', 2000) + emitted = members.remove(leave) + + # RTP2h1a: the LEAVE is emitted to subscribers + assert emitted is True + presence, events = subscribed_presence() + presence.set_presence( + [presence_message(PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000)], + is_sync=False) + events.clear() + presence.set_presence([leave], is_sync=False) + assert [name for name, _ in events] == ['leave'] + assert events[0][1].action == PresenceAction.LEAVE + + # RTP2h1b: the member is deleted from the presence map + assert members.get('conn-1:client-1') is None + assert len(members.values()) == 0 + + +# UTS: realtime/unit/RTP2h1/leave-nonexistent-returns-null-1 +def test_rtp2h1_leave_nonexistent_returns_null(): + members = presence_map() + + emitted = members.remove( + presence_message(PresenceAction.LEAVE, 'unknown', 'conn-x', 'conn-x:0:0', 1000)) + + assert emitted is False + + +# UTS: realtime/unit/RTP2h2a/leave-during-sync-stores-absent-0 +def test_rtp2h2a_leave_during_sync_stores_absent(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000)) + members.start_sync() + emitted = members.remove( + presence_message(PresenceAction.LEAVE, 'client-1', 'conn-1', 'conn-1:1:0', 2000)) + + # RTP2h2b allows no LEAVE event here, so the specification expects `remove()` to + # answer null. `remove()` reports the ABSENT store the same way it reports a + # deletion, and `set_presence` emits on the strength of it; see + # test/uts/deviations.md and + # test_rtp2h2a_leave_during_sync_absent_cleanup in presence_sync_test.py. + assert emitted is True + + # RTP2h2a: the member is stored as ABSENT rather than deleted + stored = members.get('conn-1:client-1') + assert stored is not None + assert stored.action == PresenceAction.ABSENT + + +# UTS: realtime/unit/RTP2h2b/absent-deleted-on-endsync-0 +def test_rtp2h2b_absent_deleted_on_endsync(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100)) + + members.start_sync() + members.put(presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200)) + members.remove(presence_message(PresenceAction.LEAVE, 'bob', 'c2', 'c2:1:0', 200)) + + members.end_sync() + + assert members.get('c2:bob') is None + assert members.get('c1:alice') is not None + assert members.get('c1:alice').action == PresenceAction.PRESENT + assert len(members.values()) == 1 + + +# UTS: realtime/unit/RTP2b2/newness-by-msgserial-index-0 +def test_rtp2b2_newness_by_msgserial_index(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:5:0', 1000, data='first')) + + stale_result = members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:3:0', 2000, data='stale')) + + newer_result = members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:7:0', 500, data='newer')) + + # RTP2a: the stale message is discarded + assert stale_result is False + # RTP2b2: the newer msgSerial wins even though its timestamp is older + assert newer_result is True + assert members.get('conn-1:client-1').data == 'newer' + + +# UTS: realtime/unit/RTP2b2/newness-by-index-same-serial-1 +def test_rtp2b2_newness_by_index_same_serial(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:5:2', 1000, data='index-2')) + + stale = members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:5:1', 2000, data='index-1')) + + newer = members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'conn-1:5:5', 500, data='index-5')) + + assert stale is False + assert newer is True + assert members.get('conn-1:client-1').data == 'index-5' + + +# UTS: realtime/unit/RTP2b1/newness-by-timestamp-0 +def test_rtp2b1_newness_by_timestamp(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 1000, data='entered')) + + # A synthesized leave carries an id which does not begin with its connectionId + synth_leave = members.remove(presence_message( + PresenceAction.LEAVE, 'client-1', 'conn-1', 'synthesized-leave-id', 2000)) + + # RTP2b1: timestamp 2000 is newer than 1000 + assert synth_leave is True + assert members.get('conn-1:client-1') is None + + +# UTS: realtime/unit/RTP2b1/older-synth-leave-rejected-1 +def test_rtp2b1_older_synth_leave_rejected(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'conn-1:0:0', 5000, data='entered')) + + result = members.remove(presence_message( + PresenceAction.LEAVE, 'client-1', 'conn-1', 'synthesized-leave-id', 3000)) + + assert result is False + assert members.get('conn-1:client-1') is not None + assert members.get('conn-1:client-1').data == 'entered' + + +# UTS: realtime/unit/RTP2b1a/equal-timestamps-incoming-wins-0 +def test_rtp2b1a_equal_timestamps_incoming_wins(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'client-1', 'conn-1', 'synthesized-id-1', 1000, data='first')) + + result = members.put(presence_message( + PresenceAction.UPDATE, 'client-1', 'conn-1', 'synthesized-id-2', 1000, data='second')) + + assert result is True + assert members.get('conn-1:client-1').data == 'second' + + +# UTS: realtime/unit/RTP2c/sync-uses-same-newness-0 +def test_rtp2c_sync_uses_same_newness(): + members = presence_map() + + members.start_sync() + + members.put(presence_message( + PresenceAction.PRESENT, 'client-1', 'conn-1', 'conn-1:5:0', 1000, data='sync-first')) + + stale = members.put(presence_message( + PresenceAction.PRESENT, 'client-1', 'conn-1', 'conn-1:3:0', 2000, data='sync-stale')) + + newer = members.put(presence_message( + PresenceAction.PRESENT, 'client-1', 'conn-1', 'conn-1:8:0', 500, data='sync-newer')) + + assert stale is False + assert newer is True + assert members.get('conn-1:client-1').data == 'sync-newer' + + +# UTS: realtime/unit/RTP2/multiple-members-coexist-1 +def test_rtp2_multiple_members_coexist(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c3', 'c3:0:0', 100)) + + assert len(members.values()) == 3 + assert members.get('c1:alice') is not None + assert members.get('c2:bob') is not None + assert members.get('c3:alice') is not None + + +# UTS: realtime/unit/RTP2/values-excludes-absent-2 +def test_rtp2_values_excludes_absent(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100)) + + members.start_sync() + members.remove(presence_message(PresenceAction.LEAVE, 'bob', 'c2', 'c2:1:0', 200)) + + assert members.get('c2:bob') is not None + assert members.get('c2:bob').action == PresenceAction.ABSENT + + present = members.values() + assert len(present) == 1 + assert present[0].client_id == 'alice' + + +# UTS: realtime/unit/RTP2/clear-resets-state-3 +def test_rtp2_clear_resets_state(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + members.start_sync() + + members.clear() + + assert len(members.values()) == 0 + assert members.get('c1:alice') is None + assert members.sync_in_progress is False diff --git a/test/uts/realtime/unit/presence/presence_sync_test.py b/test/uts/realtime/unit/presence/presence_sync_test.py new file mode 100644 index 00000000..2e291c7d --- /dev/null +++ b/test/uts/realtime/unit/presence/presence_sync_test.py @@ -0,0 +1,367 @@ +"""Derived from uts/realtime/unit/presence/presence_sync.md in ably/specification. + +Spec points: RTP18, RTP18a, RTP18b, RTP18c, RTP19, RTP19a, RTP2h2a, RTP2h2b + +The specification's `endSync()` answers with the synthesized LEAVE events. +`ably/realtime/presencemap.py` answers with a `(residual, absent)` pair of the stored +members and `RealtimePresence.set_presence()` builds the LEAVE events from them, so a +test reading only the count and the clientId works through `end_sync_leaves()` below, +while a test reading the LEAVE itself drives a `RealtimePresence` with the same +messages. See test/uts/deviations.md. +""" + +from datetime import datetime, timezone + +from ably.types.presence import PresenceAction +from test.uts.helpers.deviations import deviation +from test.uts.helpers.presence import presence_map, presence_message, subscribed_presence + + +def end_sync_leaves(members): + """The members `end_sync` hands back for RTP19's synthesized LEAVE events. + + `RealtimePresence.set_presence` synthesizes one LEAVE per member across both lists, + so their concatenation is the specification's `endSync() -> List`. + """ + residual, absent = members.end_sync() + return residual + absent + + +def leaves(events): + """The LEAVE messages out of a recorded `(event_name, message)` list.""" + return [message for name, message in events if name == 'leave'] + + +# UTS: realtime/unit/RTP18a/startsync-sets-flag-0 +def test_rtp18a_startsync_sets_flag(): + members = presence_map() + + assert members.sync_in_progress is False + + members.start_sync() + + assert members.sync_in_progress is True + + +# UTS: realtime/unit/RTP18b/endsync-clears-flag-0 +def test_rtp18b_endsync_clears_flag(): + members = presence_map() + + members.start_sync() + assert members.sync_in_progress is True + + members.end_sync() + + assert members.sync_in_progress is False + + +# UTS: realtime/unit/RTP19/stale-members-leave-after-sync-0 +def test_rtp19_stale_members_leave_after_sync(): + presence, events = subscribed_presence() + + presence.set_presence([ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100), + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100), + ], is_sync=False) + + assert len(presence.members.values()) == 2 + + events.clear() + # A sync in which only alice appears + presence.set_presence([ + presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200), + ], is_sync=True) + + leave_events = leaves(events) + assert len(leave_events) == 1 + assert leave_events[0].client_id == 'bob' + assert leave_events[0].action == PresenceAction.LEAVE + + assert len(presence.members.values()) == 1 + assert presence.members.get('c1:alice') is not None + assert presence.members.get('c2:bob') is None + + +# UTS: realtime/unit/RTP19/synth-leave-null-id-timestamp-1 +def test_rtp19_synth_leave_null_id_timestamp(): + presence, events = subscribed_presence() + + presence.set_presence([ + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100, data='bob-data'), + ], is_sync=False) + + before_time = datetime.now(timezone.utc) + + events.clear() + # A sync carrying no members for bob + presence.set_presence([], is_sync=True) + + after_time = datetime.now(timezone.utc) + + leave_events = leaves(events) + assert len(leave_events) == 1 + + leave = leave_events[0] + assert leave.action == PresenceAction.LEAVE + assert leave.client_id == 'bob' + assert leave.connection_id == 'c2' + assert leave.data == 'bob-data' + assert leave.id is None + assert before_time <= leave.timestamp <= after_time + + +# UTS: realtime/unit/RTP19/updated-members-survive-sync-2 +def test_rtp19_updated_members_survive_sync(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'carol', 'c3', 'c3:0:0', 100)) + + members.start_sync() + + # Alice arrives in the SYNC data + members.put(presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200)) + # Bob arrives as a PRESENCE message during the sync + members.put(presence_message( + PresenceAction.UPDATE, 'bob', 'c2', 'c2:1:0', 200, data='new-data')) + # Carol does not appear during the sync + + leave_events = end_sync_leaves(members) + + assert len(leave_events) == 1 + assert leave_events[0].client_id == 'carol' + + assert len(members.values()) == 2 + assert members.get('c1:alice') is not None + assert members.get('c2:bob') is not None + assert members.get('c2:bob').data == 'new-data' + + +# UTS: realtime/unit/RTP18a/new-sync-discards-previous-1 +def test_rtp18a_new_sync_discards_previous(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + members.put(presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100)) + + # A first sync in which only alice appears + members.start_sync() + members.put(presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200)) + + # A new sequence identifier starts a fresh sync before the first one ended. + # `start_sync` while a sync is running keeps the first sync's residual set rather + # than re-snapshotting the map, which this test cannot tell apart because the + # second sync delivers every member; see test/uts/deviations.md. + members.start_sync() + + members.put(presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:2:0', 300)) + members.put(presence_message(PresenceAction.PRESENT, 'bob', 'c2', 'c2:1:0', 300)) + + leave_events = end_sync_leaves(members) + + assert len(leave_events) == 0 + assert len(members.values()) == 2 + assert members.get('c1:alice') is not None + assert members.get('c2:bob') is not None + + +# UTS: realtime/unit/RTP18c/single-message-sync-0 +def test_rtp18c_single_message_sync(): + presence, events = subscribed_presence() + + presence.set_presence([ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100), + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100), + ], is_sync=False) + + events.clear() + # A SYNC with no channelSerial carries the whole sync in one ProtocolMessage + presence.set_presence([ + presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200), + ], is_sync=True, sync_channel_serial=None) + + leave_events = leaves(events) + assert len(leave_events) == 1 + assert leave_events[0].client_id == 'bob' + assert leave_events[0].action == PresenceAction.LEAVE + + assert len(presence.members.values()) == 1 + assert presence.members.get('c1:alice') is not None + assert presence.members.sync_in_progress is False + + +# UTS: realtime/unit/RTP19a/no-has-presence-clears-members-0 +async def test_rtp19a_no_has_presence_clears_members(): + # The members are given connectionIds other than the connection's own, so that + # RTP17i's re-entry of the internal presence map has nothing to do here. + presence, events = subscribed_presence() + + presence.set_presence([ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100, data='a'), + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100, data='b'), + presence_message(PresenceAction.ENTER, 'carol', 'c3', 'c3:0:0', 100, data='c'), + ], is_sync=False) + + events.clear() + presence.on_attached(has_presence=False) + + leave_events = leaves(events) + assert len(leave_events) == 3 + + by_client = {leave.client_id: leave for leave in leave_events} + + alice_leave = by_client.get('alice') + bob_leave = by_client.get('bob') + carol_leave = by_client.get('carol') + + assert alice_leave is not None + assert alice_leave.action == PresenceAction.LEAVE + assert alice_leave.data == 'a' + assert alice_leave.id is None + + assert bob_leave is not None + assert bob_leave.action == PresenceAction.LEAVE + assert bob_leave.data == 'b' + assert bob_leave.id is None + + assert carol_leave is not None + assert carol_leave.action == PresenceAction.LEAVE + assert carol_leave.data == 'c' + assert carol_leave.id is None + + assert len(presence.members.values()) == 0 + + +# UTS: realtime/unit/RTP2h2a/leave-during-sync-absent-cleanup-0 +@deviation +def test_rtp2h2a_leave_during_sync_absent_cleanup(): + presence, events = subscribed_presence() + + presence.set_presence([ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100), + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 100), + ], is_sync=False) + + events.clear() + # A sync whose cursor is not yet empty: alice appears, bob leaves + presence.set_presence([ + presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200), + presence_message(PresenceAction.LEAVE, 'bob', 'c2', 'c2:1:0', 200), + ], is_sync=True, sync_channel_serial='seq-1:cursor-1') + + # RTP2h2a: bob is stored as ABSENT rather than emitted or deleted + assert leaves(events) == [] + assert presence.members.get('c2:bob') is not None + assert presence.members.get('c2:bob').action == PresenceAction.ABSENT + + # The empty cursor completes the sync + presence.set_presence([], is_sync=True, sync_channel_serial='seq-1:') + + # RTP2h2b: the ABSENT entry is deleted with no LEAVE event, because RTP19's + # synthesized LEAVEs are only for members that were never seen during the sync + assert leaves(events) == [] + assert presence.members.get('c2:bob') is None + + assert len(presence.members.values()) == 1 + assert presence.members.get('c1:alice') is not None + + +# UTS: realtime/unit/RTP19/empty-map-sync-no-leaves-3 +def test_rtp19_empty_map_sync_no_leaves(): + members = presence_map() + + members.start_sync() + members.put(presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:0:0', 100)) + leave_events = end_sync_leaves(members) + + assert len(leave_events) == 0 + assert len(members.values()) == 1 + assert members.get('c1:alice') is not None + + +# UTS: realtime/unit/RTP18/endsync-without-startsync-noop-0 +def test_rtp18_endsync_without_startsync_noop(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + + leave_events = end_sync_leaves(members) + + assert len(leave_events) == 0 + assert len(members.values()) == 1 + assert members.get('c1:alice') is not None + assert members.sync_in_progress is False + + +# UTS: realtime/unit/RTP19/stale-sync-removes-from-residuals-4 +def test_rtp19_stale_sync_removes_from_residuals(): + members = presence_map() + + members.put(presence_message( + PresenceAction.ENTER, 'alice', 'c1', 'c1:5:0', 500, data='original')) + + members.start_sync() + + # A SYNC message with an older id for a member already in the map + result = members.put(presence_message( + PresenceAction.PRESENT, 'alice', 'c1', 'c1:3:0', 300, data='stale')) + + leave_events = end_sync_leaves(members) + + assert result is False + + # Alice was seen during the sync, so she is not a residual + assert len(leave_events) == 0 + assert len(members.values()) == 1 + assert members.get('c1:alice') is not None + assert members.get('c1:alice').data == 'original' + + +# UTS: realtime/unit/RTP19/presence-echoes-then-sync-preserves-5 +def test_rtp19_presence_echoes_then_sync_preserves(): + members = presence_map() + + # The server echoes a PRESENCE event for each member entered + members.put(presence_message(PresenceAction.ENTER, 'user-0', 'c1', 'c1:0:0', 100, data='data-0')) + members.put(presence_message(PresenceAction.ENTER, 'user-1', 'c1', 'c1:1:0', 100, data='data-1')) + members.put(presence_message(PresenceAction.ENTER, 'user-2', 'c1', 'c1:2:0', 100, data='data-2')) + + assert len(members.values()) == 3 + + members.start_sync() + + # The SYNC repeats the ids the echoes already carried + members.put(presence_message(PresenceAction.PRESENT, 'user-0', 'c1', 'c1:0:0', 100, data='data-0')) + members.put(presence_message(PresenceAction.PRESENT, 'user-1', 'c1', 'c1:1:0', 100, data='data-1')) + members.put(presence_message(PresenceAction.PRESENT, 'user-2', 'c1', 'c1:2:0', 100, data='data-2')) + + leave_events = end_sync_leaves(members) + + assert len(leave_events) == 0 + assert len(members.values()) == 3 + + for i in range(3): + member = members.get(f'c1:user-{i}') + assert member is not None + assert member.data == f'data-{i}' + + +# UTS: realtime/unit/RTP19/new-member-during-sync-survives-6 +def test_rtp19_new_member_during_sync_survives(): + members = presence_map() + + members.put(presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 100)) + + members.start_sync() + + members.put(presence_message(PresenceAction.PRESENT, 'alice', 'c1', 'c1:1:0', 200)) + # Bob enters through a PRESENCE message during the sync + members.put(presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 200)) + + leave_events = end_sync_leaves(members) + + assert len(leave_events) == 0 + assert len(members.values()) == 2 + assert members.get('c1:alice') is not None + assert members.get('c2:bob') is not None diff --git a/test/uts/realtime/unit/presence/realtime_presence_channel_state_test.py b/test/uts/realtime/unit/presence/realtime_presence_channel_state_test.py new file mode 100644 index 00000000..39341669 --- /dev/null +++ b/test/uts/realtime/unit/presence/realtime_presence_channel_state_test.py @@ -0,0 +1,565 @@ +"""Derived from uts/realtime/unit/presence/realtime_presence_channel_state.md in ably/specification. + +Spec points: RTL9, RTL9a, RTL11, RTL11a, RTP1, RTP5, RTP5a, RTP5b, RTP5f, RTP13 + +`syncComplete` is `presence.sync_complete`, and `get(waitForSync: false)` is +`presence.get(False)`. + +A channel reaches SUSPENDED from a SUSPENDED connection, not from a DISCONNECTED one +(RTL3c), so the two tests whose premise is a SUSPENDED channel drop the transport, leave +the reconnection attempt unanswered and run a `FakeClock` past the connection state TTL. + +PRESENCE is an `ack_required` action, so a presence operation is only resolved once the +server answers it; every handler here which captures a PRESENCE also acknowledges it, +except in RTL11a, where the specification holds the ACK back on purpose. +""" + +import asyncio +import uuid + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.realtime.presence import RealtimePresence +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.types.presence import PresenceAction +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + connected_client, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, advance_to_connection_state, settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import ( + CONNECTED_MESSAGE_NO_IDLE, + MockWebSocket, + attached_message, + channel_error_message, + connected_message, + detached_message, +) +from test.uts.helpers.presence import present_member, sync_message + +CONNECTED_MESSAGE = connected_message('conn-1', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 2.0 + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def presence_actions(protocol_message): + """The wire actions of the presence messages one PRESENCE protocol message carries.""" + return [item.get('action') for item in protocol_message.get('presence', [])] + + +# UTS: realtime/unit/RTP1/has-presence-triggers-sync-0 +async def test_rtp1_has_presence_triggers_sync(): + channel_name = f'test-RTP1-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + members = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + + assert len(members) == 1 + assert members[0].client_id == 'alice' + assert channel.presence.sync_complete is True + + +# UTS: realtime/unit/RTP1/no-has-presence-empty-1 +async def test_rtp1_no_has_presence_empty(): + channel_name = f'test-RTP1-empty-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + members = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + + assert len(members) == 0 + # An ATTACHED without HAS_PRESENCE puts the map in sync at once + assert channel.presence.sync_complete is True + + +# UTS: realtime/unit/RTP1/no-has-presence-clears-existing-2 +async def test_rtp1_no_has_presence_clears_existing(): + channel_name = f'test-RTP19a-{random_id()}' + connections = [] + attach_messages = [] + + def on_connection_attempt(conn): + connections.append(conn) + conn.respond_with_success(connected_message(f'conn-{len(connections)}')) + + mock_ws = MockWebSocket(on_connection_attempt=on_connection_attempt) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + attach_messages.append(msg) + if len(connections) == 1: + mock_ws.send_to_client( + attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + present_member('bob', 'c2', 'c2:0:0'), + ])) + else: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, disconnected_retry_timeout=100) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + members = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + assert len(members) == 2 + + leave_events = [] + + def on_leave(message): + leave_events.append(message) + + await channel.presence.subscribe('leave', on_leave) + + mock_ws.simulate_disconnect() + + # The channel holds ATTACHED across the drop, so the second ATTACH is what + # marks the re-attach having happened + await poll_until(lambda: len(attach_messages) == 2, OPERATION_TIMEOUT, 'a second ATTACH') + await settle() + + members_after = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + + assert len(members_after) == 0 + + assert len(leave_events) == 2 + assert any(event.client_id == 'alice' for event in leave_events) + assert any(event.client_id == 'bob' for event in leave_events) + + # RTP19a: a synthesized LEAVE carries no id + assert all(event.id is None for event in leave_events) + + +# UTS: realtime/unit/RTP5a/detached-clears-presence-maps-0 +async def test_rtp5a_detached_clears_presence_maps(): + channel_name = f'test-RTP5a-detached-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + elif msg['action'] == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + members = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + assert len(members) == 1 + + leave_events = [] + + def on_leave(message): + leave_events.append(message) + + await channel.presence.subscribe('leave', on_leave) + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.DETACHED + await settle() + + # RTP5a: clearing the maps on DETACHED emits nothing + assert len(leave_events) == 0 + + # The specification reads the cleared map back with `get(waitForSync: false)`, + # which RTP11b has implicitly re-attach a DETACHED channel; the maps + # themselves are what RTP5a is about + assert len(channel.presence.members.values()) == 0 + assert len(channel.presence._my_members.values()) == 0 + + +# UTS: realtime/unit/RTP5a/failed-clears-presence-maps-1 +async def test_rtp5a_failed_clears_presence_maps(): + channel_name = f'test-RTP5a-failed-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + members = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + assert len(members) == 1 + + leave_events = [] + + def on_leave(message): + leave_events.append(message) + + await channel.presence.subscribe('leave', on_leave) + + mock_ws.send_to_client(channel_error_message(channel_name, 90001, 'Channel failed')) + + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + await settle() + + # RTP5a: clearing the maps on FAILED emits nothing + assert len(leave_events) == 0 + assert len(channel.presence.members.values()) == 0 + + +# UTS: realtime/unit/RTP5b/attached-sends-queued-presence-0 +async def test_rtp5b_attached_sends_queued_presence(): + channel_name = f'test-RTP5b-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client( + {'action': int(ProtocolMessageAction.ACK), 'msgSerial': msg['msgSerial'], 'count': 1}) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + # The ATTACH is left unanswered, holding the channel in ATTACHING + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + enter_future = asyncio.ensure_future(channel.presence.enter(data='queued')) + await settle() + + assert len(captured_presence) == 0 + + mock_ws.send_to_client(attached_message(channel_name)) + + await asyncio.wait_for(enter_future, OPERATION_TIMEOUT) + await asyncio.wait_for(attach_future, OPERATION_TIMEOUT) + + assert len(captured_presence) == 1 + assert presence_actions(captured_presence[0]) == [int(PresenceAction.ENTER)] + assert captured_presence[0]['presence'][0]['data'] == 'queued' + + +# UTS: realtime/unit/RTP5f/suspended-maintains-presence-map-0 +async def test_rtp5f_suspended_maintains_presence_map(): + channel_name = f'test-RTP5f-{random_id()}' + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + present_member('bob', 'c2', 'c2:0:0'), + ])) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client( + mock_ws, clock=clock, realtime_request_timeout=300000, + disconnected_retry_timeout=1000, suspended_retry_timeout=600000) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + members = await asyncio.wait_for(channel.presence.get(), OPERATION_TIMEOUT) + assert len(members) == 2 + + # The channel follows the connection to SUSPENDED, so every reconnection + # attempt is left unanswered and the clock runs past the connection state TTL + mock_ws.on_connection_attempt = lambda conn: None + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED, step=5000) + await await_channel_state(channel, ChannelState.SUSPENDED, OPERATION_TIMEOUT) + + members_during_suspended = await asyncio.wait_for( + channel.presence.get(False), OPERATION_TIMEOUT) + + assert len(members_during_suspended) == 2 + + +# UTS: realtime/unit/RTP13/sync-complete-attribute-0 +async def test_rtp13_sync_complete_attribute(): + channel_name = f'test-RTP13-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + # A non-empty cursor leaves the sync running + mock_ws.send_to_client(sync_message(channel_name, 'seq1:cursor1', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await poll_until( + lambda: len(channel.presence.members.values()) == 1, + OPERATION_TIMEOUT, + 'the first sync message to land', + ) + + assert channel.presence.sync_complete is False + + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('bob', 'c2', 'c2:0:0'), + ])) + await poll_until( + lambda: channel.presence.sync_complete, OPERATION_TIMEOUT, 'the sync to complete') + + assert channel.presence.sync_complete is True + + +# UTS: realtime/unit/RTL9/presence-attribute-0 +async def test_rtl9_presence_attribute(): + channel_name = f'test-RTL9a-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + on_message_from_client=lambda msg: None, + ) + client = realtime_client(mock_ws) + channel = client.channels.get(channel_name) + + presence = channel.presence + + assert presence is not None + assert isinstance(presence, RealtimePresence) + + # RTL9a: the same object every time it is read + assert channel.presence is channel.presence + assert client.channels.get(channel_name).presence is presence + + +# UTS: realtime/unit/RTL11/queued-presence-fail-detached-0 +@deviation +async def test_rtl11_queued_presence_fail_detached(): + channel_name = f'test-RTL11-detached-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.DETACHED + + with pytest.raises(AblyException) as raised: + await asyncio.wait_for( + channel.presence.enter(data='queued-enter'), OPERATION_TIMEOUT) + + assert len(captured_presence) == 0 + assert raised.value.code is not None + + +# UTS: realtime/unit/RTL11/queued-presence-fail-suspended-1 +async def test_rtl11_queued_presence_fail_suspended(): + channel_name = f'test-RTL11-suspended-{random_id()}' + captured_presence = [] + clock = FakeClock() + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE_NO_IDLE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + # The channel's RTL4f attach timeout and the connection's transition timeout + # are the same option, so it is set beyond the connection state TTL: the + # channel is to follow the connection to SUSPENDED rather than time its own + # attach out first + client = await connected_client( + mock_ws, clock=clock, client_id='my-client', realtime_request_timeout=300000, + disconnected_retry_timeout=1000, suspended_retry_timeout=600000) + channel = client.channels.get(channel_name) + + # The ATTACH is left unanswered, holding the channel in ATTACHING + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + enter_future = asyncio.ensure_future(channel.presence.enter(data='queued-enter')) + update_future = asyncio.ensure_future(channel.presence.update(data='queued-update')) + await settle() + + assert len(captured_presence) == 0 + + mock_ws.on_connection_attempt = lambda conn: None + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED, step=5000) + await await_channel_state(channel, ChannelState.SUSPENDED, OPERATION_TIMEOUT) + + assert len(captured_presence) == 0 + + with pytest.raises(AblyException): + await asyncio.wait_for(enter_future, OPERATION_TIMEOUT) + + with pytest.raises(AblyException): + await asyncio.wait_for(update_future, OPERATION_TIMEOUT) + + attach_future.cancel() + + +# UTS: realtime/unit/RTL11/queued-presence-fail-failed-2 +async def test_rtl11_queued_presence_fail_failed(): + channel_name = f'test-RTL11-failed-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + # The ATTACH is left unanswered, holding the channel in ATTACHING + attach_future = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + enter_future = asyncio.ensure_future(channel.presence.enter(data='queued-enter')) + await settle() + + assert len(captured_presence) == 0 + + mock_ws.send_to_client(channel_error_message(channel_name, 90001, 'Channel failed')) + await await_channel_state(channel, ChannelState.FAILED, OPERATION_TIMEOUT) + + assert len(captured_presence) == 0 + + with pytest.raises(AblyException): + await asyncio.wait_for(enter_future, OPERATION_TIMEOUT) + + attach_future.cancel() + + +# UTS: realtime/unit/RTL11a/ack-nack-unaffected-by-state-0 +async def test_rtl11a_ack_nack_unaffected_by_state(): + channel_name = f'test-RTL11a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.DETACH: + mock_ws.send_to_client(detached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + # The ACK is withheld until after the channel has detached + captured_presence.append(msg) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + enter_future = asyncio.ensure_future(channel.presence.enter(data='awaiting-ack')) + await poll_until(lambda: captured_presence, OPERATION_TIMEOUT, 'the PRESENCE to be sent') + + assert len(captured_presence) == 1 + + await asyncio.wait_for(channel.detach(), OPERATION_TIMEOUT) + assert channel.state == ChannelState.DETACHED + + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ACK), + 'msgSerial': captured_presence[0]['msgSerial'], + 'count': 1, + }) + + assert await asyncio.wait_for(enter_future, OPERATION_TIMEOUT) is None diff --git a/test/uts/realtime/unit/presence/realtime_presence_enter_test.py b/test/uts/realtime/unit/presence/realtime_presence_enter_test.py new file mode 100644 index 00000000..2ba06cc7 --- /dev/null +++ b/test/uts/realtime/unit/presence/realtime_presence_enter_test.py @@ -0,0 +1,705 @@ +"""Derived from uts/realtime/unit/presence/realtime_presence_enter.md in ably/specification. + +Spec points: RTP4, RTP8, RTP8a, RTP8c, RTP8d, RTP8e, RTP8g, RTP8h, RTP8j, RTP9, RTP9a, +RTP9d, RTP10, RTP10a, RTP10c, RTP14, RTP14a, RTP15, RTP15a, RTP15c, RTP15e, RTP15f, +RTP16, RTP16a, RTP16b, RTP16c + +A PRESENCE ProtocolMessage is `ack_required` (`ably/realtime/connectionmanager.py:38-42`) +exactly as a MESSAGE is, so `presence.enter()` and its siblings resolve only once the +server answers. Every specification here records the PRESENCE without answering it; an +ACK is added so that the awaited call returns, as `channel_publish_test.py` does for +publishes. + +The specifications reach for a wildcard `clientId` wherever one connection acts on behalf +of several. `ClientOptions` accepts `'*'` here, so the tests use it as written. +""" + +import asyncio +import uuid + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.types.presence import PresenceAction +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import settle +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('conn-1', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 2.0 + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def ack_message(protocol_message): + """An ACK for one PRESENCE ProtocolMessage.""" + return { + 'action': int(ProtocolMessageAction.ACK), + 'msgSerial': protocol_message['msgSerial'], + 'count': 1, + } + + +def nack_message(protocol_message, code, status_code, message): + return { + 'action': int(ProtocolMessageAction.NACK), + 'msgSerial': protocol_message['msgSerial'], + 'count': 1, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def presence_server(mock_ws, channel_name, captured=None, ack=True, attach_flags=0): + """The handler most of these specifications set on the mock. + + An ATTACH is answered with ATTACHED, and each PRESENCE is recorded in + `captured` and acknowledged. + """ + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + if attach_flags: + mock_ws.send_to_client(attached_message(channel_name, flags=attach_flags)) + else: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + if captured is not None: + captured.append(msg) + if ack: + mock_ws.send_to_client(ack_message(msg)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def connected_client(mock_ws, **kwargs): + """A client connected through `mock_ws`, which the presence tests open with.""" + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTP8a/enter-sends-presence-enter-0 +async def test_rtp8a_enter_sends_presence_enter(): + channel_name = f'test-RTP8a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.presence.enter() + + assert len(captured_presence) == 1 + assert captured_presence[0]['action'] == ProtocolMessageAction.PRESENCE + assert captured_presence[0]['channel'] == channel_name + assert len(captured_presence[0]['presence']) == 1 + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.ENTER + + # RTP8c asks for the clientId to be left out of the PresenceMessage, the + # connection's own being implied. This SDK resolves the connection's clientId + # and sends it; see deviations.md. + assert captured_presence[0]['presence'][0]['clientId'] == 'my-client' + + +# UTS: realtime/unit/RTP8e/enter-with-data-0 +async def test_rtp8e_enter_with_data(): + channel_name = f'test-RTP8e-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.presence.enter('hello world') + + assert len(captured_presence) == 1 + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.ENTER + assert captured_presence[0]['presence'][0]['data'] == 'hello world' + + +# UTS: realtime/unit/RTP8d/enter-implicitly-attaches-0 +async def test_rtp8d_enter_implicitly_attaches(): + channel_name = f'test-RTP8d-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.presence.enter() + + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTP8g/enter-detached-failed-errors-0 +async def test_rtp8g_enter_detached_failed_errors(): + channel_name = f'test-RTP8g-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.ERROR), + 'channel': channel_name, + 'error': {'code': 90001, 'statusCode': 400, 'message': 'Channel failed'}, + }) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + with pytest.raises(AblyException): + await channel.attach() + assert channel.state == ChannelState.FAILED + + with pytest.raises(AblyException) as error: + await channel.presence.enter() + + assert error.value is not None + + +# UTS: realtime/unit/RTP8j/enter-null-clientid-errors-0 +async def test_rtp8j_enter_null_clientid_errors(): + channel_name = f'test-RTP8j-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name) + # No clientId — anonymous client + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + with pytest.raises(AblyException) as error: + await channel.presence.enter() + + assert error.value is not None + assert error.value.code == 40012 + + +# UTS: realtime/unit/RTP8j/enter-wildcard-clientid-errors-1 +async def test_rtp8j_enter_wildcard_clientid_errors(): + channel_name = f'test-RTP8j-wild-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name) + client = await connected_client(mock_ws, client_id='*') + channel = client.channels.get(channel_name) + + await channel.attach() + + with pytest.raises(AblyException) as error: + await channel.presence.enter() + + assert error.value is not None + assert error.value.code == 40012 + + +# UTS: realtime/unit/RTP8h/nack-presence-permission-denied-0 +async def test_rtp8h_nack_presence_permission_denied(): + channel_name = f'test-RTP8h-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + mock_ws.send_to_client( + nack_message(msg, 40160, 401, 'Presence permission denied')) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + + with pytest.raises(AblyException) as error: + await channel.presence.enter() + + assert error.value is not None + assert error.value.code == 40160 + + +# UTS: realtime/unit/RTP9a/update-sends-presence-update-0 +async def test_rtp9a_update_sends_presence_update(): + channel_name = f'test-RTP9a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.presence.update('new-status') + + assert len(captured_presence) == 1 + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.UPDATE + assert captured_presence[0]['presence'][0]['data'] == 'new-status' + + # RTP9d asks for the clientId to be left out; this SDK sends the connection's + # own clientId. See deviations.md. + assert captured_presence[0]['presence'][0]['clientId'] == 'my-client' + + +# UTS: realtime/unit/RTP10a/leave-sends-presence-leave-0 +async def test_rtp10a_leave_sends_presence_leave(): + channel_name = f'test-RTP10a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.presence.leave() + + assert len(captured_presence) == 1 + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.LEAVE + + # RTP10c asks for the clientId to be left out; this SDK sends the connection's + # own clientId. See deviations.md. + assert captured_presence[0]['presence'][0]['clientId'] == 'my-client' + + +# UTS: realtime/unit/RTP10a/leave-with-data-1 +async def test_rtp10a_leave_with_data(): + channel_name = f'test-RTP10a-data-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.presence.leave('goodbye') + + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.LEAVE + assert captured_presence[0]['presence'][0]['data'] == 'goodbye' + + +# UTS: realtime/unit/RTP14a/enterclient-on-behalf-0 +async def test_rtp14a_enterclient_on_behalf(): + channel_name = f'test-RTP14a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='*') + channel = client.channels.get(channel_name) + + await channel.attach() + + await channel.presence.enter_client('user-alice', 'alice-data') + await channel.presence.enter_client('user-bob', 'bob-data') + + assert len(captured_presence) == 2 + + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.ENTER + assert captured_presence[0]['presence'][0]['clientId'] == 'user-alice' + assert captured_presence[0]['presence'][0]['data'] == 'alice-data' + + assert captured_presence[1]['presence'][0]['action'] == PresenceAction.ENTER + assert captured_presence[1]['presence'][0]['clientId'] == 'user-bob' + assert captured_presence[1]['presence'][0]['data'] == 'bob-data' + + +# UTS: realtime/unit/RTP15a/updateclient-leaveclient-0 +async def test_rtp15a_updateclient_leaveclient(): + channel_name = f'test-RTP15a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='*') + channel = client.channels.get(channel_name) + + await channel.attach() + + await channel.presence.enter_client('user-1', 'entered') + await channel.presence.update_client('user-1', 'updated') + await channel.presence.leave_client('user-1', 'leaving') + + assert len(captured_presence) == 3 + + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.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'] == PresenceAction.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'] == PresenceAction.LEAVE + assert captured_presence[2]['presence'][0]['clientId'] == 'user-1' + assert captured_presence[2]['presence'][0]['data'] == 'leaving' + + +# UTS: realtime/unit/RTP15e/enterclient-implicitly-attaches-0 +async def test_rtp15e_enterclient_implicitly_attaches(): + channel_name = f'test-RTP15e-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name) + client = await connected_client(mock_ws, client_id='*') + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.presence.enter_client('user-1') + + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTP15f/enterclient-mismatched-clientid-0 +async def test_rtp15f_enterclient_mismatched_clientid(): + channel_name = f'test-RTP15f-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + + with pytest.raises(AblyException) as error: + await channel.presence.enter_client('other-client') + + assert error.value is not None + assert error.value.code == 40012 + assert client.connection.state == ConnectionState.CONNECTED + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTP16a/presence-sent-when-attached-0 +async def test_rtp16a_presence_sent_when_attached(): + channel_name = f'test-RTP16a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await channel.attach() + await channel.presence.enter() + + assert len(captured_presence) == 1 + + +# UTS: realtime/unit/RTP16b/presence-queued-when-attaching-0 +async def test_rtp16b_presence_queued_when_attaching(): + channel_name = f'test-RTP16b-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + # The ATTACHED is withheld so that the channel stays ATTACHING + presence_server(mock_ws, channel_name, captured_presence) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ack_message(msg)) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + attaching = asyncio.ensure_future(channel.attach()) + await await_channel_state(channel, ChannelState.ATTACHING, OPERATION_TIMEOUT) + + entering = asyncio.ensure_future(channel.presence.enter()) + await settle() + + assert len(captured_presence) == 0 + + mock_ws.send_to_client(attached_message(channel_name)) + + await asyncio.wait_for(entering, OPERATION_TIMEOUT) + await asyncio.wait_for(attaching, OPERATION_TIMEOUT) + + assert len(captured_presence) == 1 + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.ENTER + + +# UTS: realtime/unit/RTP16c/presence-errors-other-states-0 +async def test_rtp16c_presence_errors_other_states(): + channel_name = f'test-RTP16c-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.DETACHED), + 'channel': channel_name, + 'error': {'code': 90001, 'statusCode': 400, 'message': 'Detached'}, + }) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + # A DETACHED received while ATTACHING moves the channel to SUSPENDED rather than + # the DETACHED the specification expects, with no reason attached, so `attach()` + # raises `None`. Both are recorded against the channel specifications; see + # test/uts/deviations.md. + with pytest.raises(TypeError): + await channel.attach() + assert channel.state == ChannelState.SUSPENDED + + with pytest.raises(AblyException) as error: + await channel.presence.enter() + + assert error.value is not None + + +# UTS: realtime/unit/RTP15c/enterclient-no-side-effects-0 +async def test_rtp15c_enterclient_no_side_effects(): + channel_name = f'test-RTP15c-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + presence_server(mock_ws, channel_name, captured_presence) + client = await connected_client(mock_ws, client_id='*') + channel = client.channels.get(channel_name) + + await channel.attach() + + # A wildcard client cannot enter on its own behalf here (RTP8j), so the + # specification's normal enter is made for a named client of its own + await channel.presence.enter_client('main-client', 'main-client') + await channel.presence.enter_client('other-user', 'other-data') + await channel.presence.leave_client('other-user') + + assert len(captured_presence) == 3 + + assert captured_presence[0]['presence'][0]['action'] == PresenceAction.ENTER + assert captured_presence[0]['presence'][0]['data'] == 'main-client' + assert captured_presence[0]['presence'][0]['clientId'] == 'main-client' + + assert captured_presence[1]['presence'][0]['action'] == PresenceAction.ENTER + assert captured_presence[1]['presence'][0]['clientId'] == 'other-user' + + assert captured_presence[2]['presence'][0]['action'] == PresenceAction.LEAVE + assert captured_presence[2]['presence'][0]['clientId'] == 'other-user' + + +# UTS: realtime/unit/RTP4/bulk-enterclient-same-connection-0 +async def test_rtp4_bulk_enterclient_same_connection(): + channel_name = f'test-RTP4-same-{random_id()}' + member_count = 50 + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ack_message(msg)) + # The server echoes each ENTER back as a presence event + for index, entry in enumerate(msg['presence']): + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.PRESENCE), + 'channel': channel_name, + 'presence': [{ + 'action': PresenceAction.ENTER, + 'clientId': entry['clientId'], + 'connectionId': 'conn-1', + 'id': f"conn-1:{msg['msgSerial']}:{index}", + 'timestamp': 100, + 'data': entry.get('data'), + }], + }) + + mock_ws.on_message_from_client = on_message_from_client + client = await connected_client(mock_ws, client_id='*') + channel = client.channels.get(channel_name) + + await channel.attach() + + received_enters = [] + + def on_enter(message): + received_enters.append(message) + + await channel.presence.subscribe('enter', on_enter) + + for i in range(member_count): + await channel.presence.enter_client(f'user-{i}', f'data-{i}') + + await poll_until( + lambda: len(received_enters) == member_count, + description=f'{member_count} enter events') + + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.SYNC), + 'channel': channel_name, + 'channelSerial': 'seq1:', + 'presence': [{ + 'action': PresenceAction.PRESENT, + 'clientId': f'user-{i}', + 'connectionId': 'conn-1', + 'id': f'conn-1:{i}:0', + 'timestamp': 100, + 'data': f'data-{i}', + } for i in range(member_count)], + }) + + members = await channel.presence.get() + + assert len(captured_presence) == member_count + assert len(received_enters) == member_count + assert len(members) == member_count + + by_client_id = {member.client_id: member for member in members} + for i in range(member_count): + member = by_client_id.get(f'user-{i}') + assert member is not None + assert member.data == f'data-{i}' + + +# UTS: realtime/unit/RTP4/bulk-enterclient-diff-connections-1 +async def test_rtp4_bulk_enterclient_diff_connections(): + channel_name = f'test-RTP4-diff-{random_id()}' + member_count = 50 + captured_presence_a = [] + + mock_ws_a = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected_message('conn-A')), + ) + presence_server( + mock_ws_a, channel_name, captured_presence_a, attach_flags=int(Flag.HAS_PRESENCE)) + + mock_ws_b = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected_message('conn-B')), + ) + presence_server(mock_ws_b, channel_name, attach_flags=int(Flag.HAS_PRESENCE)) + + client_a = await connected_client(mock_ws_a, client_id='*') + client_b = await connected_client(mock_ws_b) + channel_a = client_a.channels.get(channel_name) + channel_b = client_b.channels.get(channel_name) + + await channel_a.attach() + await channel_b.attach() + + received_enters_b = [] + + def on_enter(message): + received_enters_b.append(message) + + await channel_b.presence.subscribe('enter', on_enter) + + for i in range(member_count): + await channel_a.presence.enter_client(f'user-{i}', f'data-{i}') + + for i in range(member_count): + mock_ws_b.send_to_client({ + 'action': int(ProtocolMessageAction.PRESENCE), + 'channel': channel_name, + 'presence': [{ + 'action': PresenceAction.ENTER, + 'clientId': f'user-{i}', + 'connectionId': 'conn-A', + 'id': f'conn-A:{i}:0', + 'timestamp': 100, + 'data': f'data-{i}', + }], + }) + + await poll_until( + lambda: len(received_enters_b) == member_count, + description=f'{member_count} enter events on the observing client') + + mock_ws_b.send_to_client({ + 'action': int(ProtocolMessageAction.SYNC), + 'channel': channel_name, + 'channelSerial': 'seq1:', + 'presence': [{ + 'action': PresenceAction.PRESENT, + 'clientId': f'user-{i}', + 'connectionId': 'conn-A', + 'id': f'conn-A:{i}:0', + 'timestamp': 100, + 'data': f'data-{i}', + } for i in range(member_count)], + }) + + members = await channel_b.presence.get() + + assert len(captured_presence_a) == member_count + assert len(received_enters_b) == member_count + assert len(members) == member_count + + by_client_id = {member.client_id: member for member in members} + for i in range(member_count): + member = by_client_id.get(f'user-{i}') + assert member is not None + assert member.data == f'data-{i}' + assert member.connection_id == 'conn-A' diff --git a/test/uts/realtime/unit/presence/realtime_presence_get_test.py b/test/uts/realtime/unit/presence/realtime_presence_get_test.py new file mode 100644 index 00000000..78fc6712 --- /dev/null +++ b/test/uts/realtime/unit/presence/realtime_presence_get_test.py @@ -0,0 +1,284 @@ +"""Derived from uts/realtime/unit/presence/realtime_presence_get.md in ably/specification. + +Spec points: RTP11, RTP11a, RTP11b, RTP11c, RTP11c1, RTP11c2, RTP11c3, RTP11d + +`presence.get()` takes `wait_for_sync` as its first argument, then `client_id` and +`connection_id`, which is the specifications' `waitForSync`, `clientId` and +`connectionId`. + +The two RTP11d tests reach a SUSPENDED channel by driving the connection to SUSPENDED +with a `FakeClock`: a bare transport drop leaves the connection DISCONNECTED and the +channel ATTACHED, and only a SUSPENDED connection propagates SUSPENDED to its channels +(`ably/realtime/channel.py:1047`). The `connectionStateTtl` the specification puts in +the CONNECTED is parsed and then ignored, so the wait is the 120 s default; see +test/uts/deviations.md. +""" + +import asyncio +import uuid + +import pytest + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.util.exceptions import AblyException +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import FakeClock, advance_to_connection_state, settle +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message +from test.uts.helpers.presence import present_member, sync_message + +CONNECTED_MESSAGE = connected_message('conn-1', connectionKey='connection-key') + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 2.0 + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def attaching_server(mock_ws, channel_name, has_presence=True, then=None): + """Answers each ATTACH with an ATTACHED, optionally followed by `then`.""" + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + if has_presence: + mock_ws.send_to_client( + attached_message(channel_name, flags=int(Flag.HAS_PRESENCE))) + else: + mock_ws.send_to_client(attached_message(channel_name)) + if then is not None: + mock_ws.send_to_client(then) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def connected_client(mock_ws, **kwargs): + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +async def suspended_channel(channel_name): + """A channel which reached SUSPENDED, holding one member from a completed sync. + + Returns the channel. Getting there means turning the whole connection retry + cycle: the transport drops, reconnection is refused, and the clock runs past + the connection state TTL. + """ + clock = FakeClock() + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success( + connected_message('conn-1', maxIdleInterval=0, connectionStateTtl=5000)), + ) + attaching_server(mock_ws, channel_name, then=sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + client = await connected_client( + mock_ws, clock=clock, realtime_request_timeout=300, + disconnected_retry_timeout=1000, suspended_retry_timeout=600000) + channel = client.channels.get(channel_name) + + await channel.attach() + await poll_until( + lambda: len(channel.presence.members.values()) == 1, description='the sync to land') + + # Every reconnection attempt is left unanswered, so the retry cycle runs out + mock_ws.on_connection_attempt = lambda conn: None + mock_ws.simulate_disconnect() + await settle() + + await advance_to_connection_state(client, clock, ConnectionState.SUSPENDED, step=5000) + await await_channel_state(channel, ChannelState.SUSPENDED, OPERATION_TIMEOUT) + return channel + + +# UTS: realtime/unit/RTP11a/get-returns-members-single-sync-0 +async def test_rtp11a_get_returns_members_single_sync(): + channel_name = f'test-RTP11a-single-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + getting = asyncio.ensure_future(channel.presence.get()) + await settle() + + assert not getting.done() + + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0', data='a'), + present_member('bob', 'c2', 'c2:0:0', data='b'), + ])) + + members = await asyncio.wait_for(getting, OPERATION_TIMEOUT) + + assert len(members) == 2 + assert sorted(member.client_id for member in members) == ['alice', 'bob'] + + +# UTS: realtime/unit/RTP11a/get-waits-for-multi-sync-1 +async def test_rtp11a_get_waits_for_multi_sync(): + channel_name = f'test-RTP11c1-multi-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + getting = asyncio.ensure_future(channel.presence.get()) + await settle() + + assert not getting.done() + + mock_ws.send_to_client(sync_message(channel_name, 'seq1:cursor1', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + await poll_until( + lambda: len(channel.presence.members.values()) == 1, + description='the first sync message to land') + await settle() + + # A non-empty cursor leaves the sync incomplete + assert not getting.done() + + mock_ws.send_to_client(sync_message(channel_name, 'seq1:', [ + present_member('bob', 'c2', 'c2:0:0'), + ])) + + members = await asyncio.wait_for(getting, OPERATION_TIMEOUT) + + assert len(members) == 2 + assert sorted(member.client_id for member in members) == ['alice', 'bob'] + + +# UTS: realtime/unit/RTP11c1/get-no-wait-returns-immediately-0 +async def test_rtp11c1_get_no_wait_returns_immediately(): + channel_name = f'test-RTP11c1-nowait-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, then=sync_message(channel_name, 'seq1:cursor1', [ + present_member('alice', 'c1', 'c1:0:0'), + ])) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + await poll_until( + lambda: len(channel.presence.members.values()) == 1, + description='the partial sync to land') + + # The sync is still running, so a waiting get would not return here + assert channel.presence.members.sync_in_progress + assert not channel.presence.sync_complete + + members = await channel.presence.get(wait_for_sync=False) + + assert len(members) == 1 + assert members[0].client_id == 'alice' + + +# UTS: realtime/unit/RTP11c2/get-filtered-by-clientid-0 +async def test_rtp11c2_get_filtered_by_clientid(): + channel_name = f'test-RTP11c2-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, then=sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + present_member('bob', 'c2', 'c2:0:0'), + present_member('alice', 'c3', 'c3:0:0'), + ])) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + members = await channel.presence.get(client_id='alice') + + assert len(members) == 2 + assert all(member.client_id == 'alice' for member in members) + + +# UTS: realtime/unit/RTP11c3/get-filtered-by-connectionid-0 +async def test_rtp11c3_get_filtered_by_connectionid(): + channel_name = f'test-RTP11c3-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, then=sync_message(channel_name, 'seq1:', [ + present_member('alice', 'c1', 'c1:0:0'), + present_member('bob', 'c2', 'c2:0:0'), + present_member('carol', 'c1', 'c1:0:1'), + ])) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + members = await channel.presence.get(connection_id='c1') + + assert len(members) == 2 + assert all(member.connection_id == 'c1' for member in members) + + +# UTS: realtime/unit/RTP11b/get-implicitly-attaches-0 +async def test_rtp11b_get_implicitly_attaches(): + channel_name = f'test-RTP11b-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, has_presence=False) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + members = await channel.presence.get(wait_for_sync=False) + + assert channel.state == ChannelState.ATTACHED + assert members is not None + + +# UTS: realtime/unit/RTP11d/get-suspended-errors-default-0 +async def test_rtp11d_get_suspended_errors_default(): + channel = await suspended_channel(f'test-RTP11d-{random_id()}') + + with pytest.raises(AblyException) as error: + await channel.presence.get() + + assert error.value is not None + assert error.value.code == 91005 + + +# UTS: realtime/unit/RTP11d/get-suspended-no-wait-returns-1 +async def test_rtp11d_get_suspended_no_wait_returns(): + channel = await suspended_channel(f'test-RTP11d-nowait-{random_id()}') + + members = await channel.presence.get(wait_for_sync=False) + + assert len(members) == 1 + assert members[0].client_id == 'alice' diff --git a/test/uts/realtime/unit/presence/realtime_presence_history_test.py b/test/uts/realtime/unit/presence/realtime_presence_history_test.py new file mode 100644 index 00000000..c9943c74 --- /dev/null +++ b/test/uts/realtime/unit/presence/realtime_presence_history_test.py @@ -0,0 +1,116 @@ +"""Derived from uts/realtime/unit/presence/realtime_presence_history.md in ably/specification. + +Spec points: RTP12, RTP12a, RTP12c, RTP12d + +The specification points `RealtimePresence#history` at `RestPresence#history`, so each +test here drives a realtime client whose HTTP layer is a `MockHttpClient` and asserts the +same observables as the derived REST tests in +`test/uts/rest/unit/presence/rest_presence_test.py`. The websocket mock is still needed +for the channel setup the specification asks for. + +`RealtimePresence` has no `history` at all, so both tests are gated; see +test/uts/deviations.md. +""" + +import uuid + +from ably.http.paginatedresult import PaginatedResult +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.presence import PresenceAction, PresenceMessage +from test.uts.helpers.client import await_connection_state, realtime_client +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_http import MockHttpClient +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('conn-1', connectionKey='connection-key') + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def attaching_server(mock_ws, channel_name): + """Answers each ATTACH with an ATTACHED.""" + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +def capturing_mock(captured_requests, body=None): + """A mock serving every HTTP request with the same response.""" + def on_request(request): + captured_requests.append(request) + request.respond_with(200, [] if body is None else body) + + return MockHttpClient( + on_connection_attempt=lambda conn: conn.respond_with_success(), + on_request=on_request, + ) + + +async def attached_channel(mock_ws, mock_http, channel_name): + """A channel attached over `mock_ws`, on a client whose REST calls `mock_http` serves.""" + client = realtime_client(mock_ws, mock_http=mock_http) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + channel = client.channels.get(channel_name) + await channel.attach() + return channel + + +# UTS: realtime/unit/RTP12a/history-supports-rest-params-0 +@deviation +async def test_rtp12a_history_supports_rest_params(): + channel_name = f'test-RTP12a-{random_id()}' + captured_requests = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + mock_http = capturing_mock(captured_requests) + + channel = await attached_channel(mock_ws, mock_http, channel_name) + + await channel.presence.history(start=1000, end=2000, direction='backwards', limit=50) + + assert len(captured_requests) == 1 + assert captured_requests[0].url.path == f'/channels/{channel_name}/presence/history' + query_params = captured_requests[0].url.query_params + assert query_params['start'] == '1000' + assert query_params['end'] == '2000' + assert query_params['direction'] == 'backwards' + assert query_params['limit'] == '50' + + +# UTS: realtime/unit/RTP12c/history-returns-paginated-result-0 +@deviation +async def test_rtp12c_history_returns_paginated_result(): + channel_name = f'test-RTP12c-{random_id()}' + captured_requests = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + # The REST layer reads the numeric wire actions: ENTER is 2, UPDATE 4, LEAVE 3 + mock_http = capturing_mock(captured_requests, [ + {'action': 2, 'clientId': 'alice', 'timestamp': 1000}, + {'action': 4, 'clientId': 'alice', 'timestamp': 2000}, + {'action': 3, 'clientId': 'alice', 'timestamp': 3000}, + ]) + + channel = await attached_channel(mock_ws, mock_http, channel_name) + + result = await channel.presence.history() + + assert isinstance(result, PaginatedResult) + assert len(result.items) == 3 + assert all(isinstance(item, PresenceMessage) for item in result.items) + assert result.items[0].client_id == 'alice' + assert result.items[0].action == PresenceAction.ENTER + assert result.items[2].action == PresenceAction.LEAVE diff --git a/test/uts/realtime/unit/presence/realtime_presence_reentry_test.py b/test/uts/realtime/unit/presence/realtime_presence_reentry_test.py new file mode 100644 index 00000000..702dd77d --- /dev/null +++ b/test/uts/realtime/unit/presence/realtime_presence_reentry_test.py @@ -0,0 +1,376 @@ +"""Derived from uts/realtime/unit/presence/realtime_presence_reentry.md in ably/specification. + +Spec points: RTP17a, RTP17e, RTP17g, RTP17g1, RTP17i + +The RTP17 internal presence map is filled from the server's echo of a presence message, +so each server here answers a PRESENCE with an ACK and then plays the member back on the +current connection, as the specification's setups do. PRESENCE is an `ack_required` +action, so the ACK is what resolves the client's `enter()`. + +A reconnection is driven by dropping the transport: the connection retries at once, the +mock answers with a fresh connectionId, and the channels re-attach, which is what brings +about the re-entry under test. +""" + +import asyncio +import uuid + +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.types.presence import PresenceAction +from test.uts.helpers.client import await_channel_state, connected_client, poll_until +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +# How long an operation which the specification expects to settle is given +# before a test calls it hung +OPERATION_TIMEOUT = 2.0 + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def ack_message(msg_serial): + return {'action': int(ProtocolMessageAction.ACK), 'msgSerial': msg_serial, 'count': 1} + + +def nack_message(msg_serial, code, status_code, message): + return { + 'action': int(ProtocolMessageAction.NACK), + 'msgSerial': msg_serial, + 'count': 1, + 'error': {'code': code, 'statusCode': status_code, 'message': message}, + } + + +def echo_of(msg, channel_name, connection_id, default_client_id): + """The PRESENCE messages a server plays back for the client's own `msg`.""" + echoes = [] + for index, item in enumerate(msg.get('presence', [])): + echoes.append({ + 'action': int(ProtocolMessageAction.PRESENCE), + 'channel': channel_name, + 'connectionId': connection_id, + 'presence': [{ + 'action': item.get('action'), + 'clientId': item.get('clientId') or default_client_id, + 'connectionId': connection_id, + 'id': f'{connection_id}:{msg["msgSerial"]}:{index}', + 'timestamp': 1000 + index, + 'data': item.get('data'), + }], + }) + return echoes + + +def counting_connections(mock_ws): + """Answers each attempt with a CONNECTED carrying a fresh connectionId. + + Returns the list the attempts are counted in, so a handler can tell which + connection it is answering on. + """ + connections = [] + + def on_connection_attempt(conn): + connections.append(conn) + conn.respond_with_success(connected_message(f'conn-{len(connections)}')) + + mock_ws.on_connection_attempt = on_connection_attempt + return connections + + +def echoing_server(mock_ws, channel_name, connections, captured_presence, + default_client_id='my-client', attached_flags=0): + """Attaches the channel and echoes back every presence message the client sends.""" + def on_message_from_client(msg): + connection_id = f'conn-{len(connections)}' + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name, flags=attached_flags)) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ack_message(msg['msgSerial'])) + for echo in echo_of(msg, channel_name, connection_id, default_client_id): + mock_ws.send_to_client(echo) + + mock_ws.on_message_from_client = on_message_from_client + + +async def member_present(channel, client_id): + """Waits for the server's echo of `client_id` to reach the presence map.""" + await poll_until( + lambda: any(m.client_id == client_id for m in channel.presence.members.values()), + OPERATION_TIMEOUT, + f'the presence echo for {client_id}', + ) + + +def presence_items(captured_presence): + items = [] + for msg in captured_presence: + items.extend(msg.get('presence', [])) + return items + + +# UTS: realtime/unit/RTP17i/auto-reentry-on-attached-0 +async def test_rtp17i_auto_reentry_on_attached(): + channel_name = f'test-RTP17i-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket() + connections = counting_connections(mock_ws) + echoing_server(mock_ws, channel_name, connections, captured_presence) + + client = await connected_client( + mock_ws, client_id='my-client', disconnected_retry_timeout=100) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.presence.enter(data='hello'), OPERATION_TIMEOUT) + await member_present(channel, 'my-client') + + assert len(captured_presence) == 1 + + captured_presence.clear() + + mock_ws.simulate_disconnect() + await poll_until( + lambda: len(connections) == 2, OPERATION_TIMEOUT, 'the connection to be re-established') + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + # RTP17i: the member is re-entered on the fresh ATTACHED + await poll_until( + lambda: captured_presence, OPERATION_TIMEOUT, 'the re-entry to be published') + + assert len(captured_presence) >= 1 + + reenter = next( + (m for m in captured_presence + if m['presence'][0]['action'] == int(PresenceAction.ENTER)), None) + assert reenter is not None + + +# UTS: realtime/unit/RTP17g/reentry-publishes-enter-with-data-0 +async def test_rtp17g_reentry_publishes_enter_with_data(): + channel_name = f'test-RTP17g-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket() + connections = counting_connections(mock_ws) + echoing_server(mock_ws, channel_name, connections, captured_presence) + + # RTP15f has a client with a concrete clientId refused `enterClient` for any + # other one, so the client entering alice and bob on their behalf holds the + # wildcard clientId the specification's own note allows for + client = await connected_client(mock_ws, client_id='*', disconnected_retry_timeout=100) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + await asyncio.wait_for( + channel.presence.enter_client('alice', data='alice-data'), OPERATION_TIMEOUT) + await asyncio.wait_for( + channel.presence.enter_client('bob', data='bob-data'), OPERATION_TIMEOUT) + await member_present(channel, 'alice') + await member_present(channel, 'bob') + + assert len(captured_presence) == 2 + + captured_presence.clear() + + mock_ws.simulate_disconnect() + await poll_until( + lambda: len(connections) == 2, OPERATION_TIMEOUT, 'the connection to be re-established') + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + await poll_until( + lambda: len(presence_items(captured_presence)) >= 2, + OPERATION_TIMEOUT, + 'both members to be re-entered', + ) + + items = presence_items(captured_presence) + assert len(items) >= 2 + + alice_reentry = next((p for p in items if p.get('clientId') == 'alice'), None) + bob_reentry = next((p for p in items if p.get('clientId') == 'bob'), None) + + assert alice_reentry is not None + assert alice_reentry['action'] == int(PresenceAction.ENTER) + assert alice_reentry['data'] == 'alice-data' + + assert bob_reentry is not None + assert bob_reentry['action'] == int(PresenceAction.ENTER) + assert bob_reentry['data'] == 'bob-data' + + +# UTS: realtime/unit/RTP17g1/reentry-omits-id-new-connid-0 +async def test_rtp17g1_reentry_omits_id_new_connid(): + channel_name = f'test-RTP17g1-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket() + connections = counting_connections(mock_ws) + echoing_server(mock_ws, channel_name, connections, captured_presence) + + client = await connected_client( + mock_ws, client_id='my-client', disconnected_retry_timeout=100) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.presence.enter(data='hello'), OPERATION_TIMEOUT) + await member_present(channel, 'my-client') + + assert len(connections) == 1 + + captured_presence.clear() + + mock_ws.simulate_disconnect() + await poll_until( + lambda: len(connections) == 2, OPERATION_TIMEOUT, 'the connection to be re-established') + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + assert len(connections) == 2 + + await poll_until( + lambda: captured_presence, OPERATION_TIMEOUT, 'the re-entry to be published') + + reentry_presence = captured_presence[0]['presence'][0] + + assert reentry_presence['action'] == int(PresenceAction.ENTER) + # RTP17g1: the stored id belongs to the old connection, so it is left off + assert 'id' not in reentry_presence + assert reentry_presence['data'] == 'hello' + + +# UTS: realtime/unit/RTP17i/no-reentry-with-resumed-flag-1 +async def test_rtp17i_no_reentry_with_resumed_flag(): + channel_name = f'test-RTP17i-resumed-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket() + connections = counting_connections(mock_ws) + echoing_server(mock_ws, channel_name, connections, captured_presence) + + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.presence.enter(data='hello'), OPERATION_TIMEOUT) + await member_present(channel, 'my-client') + + captured_presence.clear() + + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.RESUMED))) + await settle() + + # RTP17i: an ATTACHED with RESUMED on an attached channel leaves the server's + # copy of the presence state in place, so nothing is re-entered + assert len(captured_presence) == 0 + + +# UTS: realtime/unit/RTP17e/failed-reentry-emits-update-error-0 +@deviation +async def test_rtp17e_failed_reentry_emits_update_error(): + channel_name = f'test-RTP17e-{random_id()}' + + mock_ws = MockWebSocket() + connections = counting_connections(mock_ws) + + def on_message_from_client(msg): + connection_id = f'conn-{len(connections)}' + if msg['action'] == ProtocolMessageAction.ATTACH: + mock_ws.send_to_client(attached_message(channel_name)) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + if len(connections) == 1: + mock_ws.send_to_client(ack_message(msg['msgSerial'])) + for echo in echo_of(msg, channel_name, connection_id, 'my-client'): + mock_ws.send_to_client(echo) + else: + mock_ws.send_to_client( + nack_message(msg['msgSerial'], 40160, 401, 'Presence denied')) + + mock_ws.on_message_from_client = on_message_from_client + + client = await connected_client( + mock_ws, client_id='my-client', disconnected_retry_timeout=100) + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + + await asyncio.wait_for(channel.presence.enter(data='hello'), OPERATION_TIMEOUT) + await member_present(channel, 'my-client') + + channel_events = [] + + def on_update(change): + if change.reason is not None: + channel_events.append(change) + + channel.on('update', on_update) + + mock_ws.simulate_disconnect() + await poll_until( + lambda: len(connections) == 2, OPERATION_TIMEOUT, 'the connection to be re-established') + await await_channel_state(channel, ChannelState.ATTACHED, OPERATION_TIMEOUT) + + await poll_until( + lambda: channel_events, OPERATION_TIMEOUT, 'the re-entry failure to be reported') + + assert len(channel_events) >= 1 + + update_event = channel_events[0] + assert update_event.resumed is True + assert update_event.reason is not None + assert update_event.reason.code == 91004 + assert 'my-client' in update_event.reason.message + assert update_event.reason.cause is not None + assert update_event.reason.cause.code == 40160 + + +# UTS: realtime/unit/RTP17a/server-publishes-without-subscribe-0 +async def test_rtp17a_server_publishes_without_subscribe(): + channel_name = f'test-RTP17a-{random_id()}' + captured_presence = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(connected_message('conn-1')), + ) + + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + # A channel carrying the presence capability but not subscribe + mock_ws.send_to_client(attached_message(channel_name, flags=int(Flag.PRESENCE))) + elif msg['action'] == ProtocolMessageAction.PRESENCE: + captured_presence.append(msg) + mock_ws.send_to_client(ack_message(msg['msgSerial'])) + mock_ws.send_to_client({ + 'action': int(ProtocolMessageAction.PRESENCE), + 'channel': channel_name, + 'presence': [{ + 'action': int(PresenceAction.ENTER), + 'clientId': 'my-client', + 'connectionId': 'conn-1', + 'id': 'conn-1:0:0', + 'timestamp': 1000, + }], + }) + + mock_ws.on_message_from_client = on_message_from_client + + client = await connected_client(mock_ws, client_id='my-client') + channel = client.channels.get(channel_name) + + await asyncio.wait_for(channel.attach(), OPERATION_TIMEOUT) + await asyncio.wait_for(channel.presence.enter(), OPERATION_TIMEOUT) + await member_present(channel, 'my-client') + + members = await asyncio.wait_for(channel.presence.get(False), OPERATION_TIMEOUT) + + assert len(members) == 1 + assert members[0].client_id == 'my-client' diff --git a/test/uts/realtime/unit/presence/realtime_presence_subscribe_test.py b/test/uts/realtime/unit/presence/realtime_presence_subscribe_test.py new file mode 100644 index 00000000..cec42e4f --- /dev/null +++ b/test/uts/realtime/unit/presence/realtime_presence_subscribe_test.py @@ -0,0 +1,424 @@ +"""Derived from uts/realtime/unit/presence/realtime_presence_subscribe.md in ably/specification. + +Spec points: RTP6, RTP6a, RTP6b, RTP6d, RTP6e, RTP7, RTP7a, RTP7b, RTP7c + +`RealtimePresence.subscribe()` is a coroutine here, because it carries out the RTP6d +implicit attach, so the specifications' bare `channel.presence.subscribe(...)` is +awaited. A presence action is named by its lowercase wire name — `'enter'`, `'leave'`, +`'update'`, `'present'` — which is what `set_presence` emits. +""" + +import uuid + +from ably.realtime.connection import ConnectionState +from ably.transport.websockettransport import ProtocolMessageAction +from ably.types.channeloptions import ChannelOptions +from ably.types.channelstate import ChannelState +from ably.types.flags import Flag +from ably.types.presence import PresenceAction +from test.uts.helpers.client import ( + await_channel_state, + await_connection_state, + poll_until, + realtime_client, +) +from test.uts.helpers.clock import settle +from test.uts.helpers.deviations import deviation +from test.uts.helpers.mock_websocket import MockWebSocket, attached_message, connected_message + +CONNECTED_MESSAGE = connected_message('conn-1', connectionKey='connection-key') + + +def random_id(): + return uuid.uuid4().hex[:8] + + +def presence_message(action, client_id, connection_id, id, timestamp, **fields): + return { + 'action': action, + 'clientId': client_id, + 'connectionId': connection_id, + 'id': id, + 'timestamp': timestamp, + **fields, + } + + +def presence_protocol_message(channel_name, presence): + return { + 'action': int(ProtocolMessageAction.PRESENCE), + 'channel': channel_name, + 'presence': presence, + } + + +def attaching_server(mock_ws, channel_name, attach_count=None, attach_flags=0, attached=True): + """Answers each ATTACH with an ATTACHED, counting the attaches seen.""" + def on_message_from_client(msg): + if msg['action'] == ProtocolMessageAction.ATTACH: + if attach_count is not None: + attach_count.append(msg) + if not attached: + return + if attach_flags: + mock_ws.send_to_client(attached_message(channel_name, flags=attach_flags)) + else: + mock_ws.send_to_client(attached_message(channel_name)) + + mock_ws.on_message_from_client = on_message_from_client + return mock_ws + + +async def connected_client(mock_ws, **kwargs): + client = realtime_client(mock_ws, **kwargs) + client.connect() + await await_connection_state(client, ConnectionState.CONNECTED) + return client + + +# UTS: realtime/unit/RTP6a/subscribe-all-presence-events-0 +async def test_rtp6a_subscribe_all_presence_events(): + channel_name = f'test-RTP6a-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, attach_flags=int(Flag.HAS_PRESENCE)) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + received_events = [] + + def on_event(message): + received_events.append(message) + + await channel.presence.subscribe(on_event) + await await_channel_state(channel, ChannelState.ATTACHED) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + ])) + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.UPDATE, 'alice', 'c1', 'c1:1:0', 2000, data='updated'), + ])) + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.LEAVE, 'alice', 'c1', 'c1:2:0', 3000), + ])) + + await poll_until(lambda: len(received_events) == 3, description='three presence events') + + assert len(received_events) == 3 + assert received_events[0].action == PresenceAction.ENTER + assert received_events[0].client_id == 'alice' + assert received_events[1].action == PresenceAction.UPDATE + assert received_events[1].data == 'updated' + assert received_events[2].action == PresenceAction.LEAVE + + +# UTS: realtime/unit/RTP6b/subscribe-filtered-by-action-0 +async def test_rtp6b_subscribe_filtered_by_action(): + channel_name = f'test-RTP6b-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + enter_events = [] + leave_events = [] + + def on_enter(message): + enter_events.append(message) + + def on_leave(message): + leave_events.append(message) + + await channel.presence.subscribe('enter', on_enter) + await channel.presence.subscribe('leave', on_leave) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + presence_message(PresenceAction.UPDATE, 'alice', 'c1', 'c1:1:0', 2000), + presence_message(PresenceAction.LEAVE, 'alice', 'c1', 'c1:2:0', 3000), + ])) + + await poll_until( + lambda: enter_events and leave_events, description='an enter and a leave event') + + assert len(enter_events) == 1 + assert enter_events[0].action == PresenceAction.ENTER + + assert len(leave_events) == 1 + assert leave_events[0].action == PresenceAction.LEAVE + + +# UTS: realtime/unit/RTP6b/subscribe-filtered-multiple-actions-1 +@deviation +async def test_rtp6b_subscribe_filtered_multiple_actions(): + channel_name = f'test-RTP6b-multi-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + enter_leave_events = [] + + def on_event(message): + enter_leave_events.append(message) + + await channel.presence.subscribe(['enter', 'leave'], on_event) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + presence_message(PresenceAction.UPDATE, 'alice', 'c1', 'c1:1:0', 2000), + presence_message(PresenceAction.LEAVE, 'alice', 'c1', 'c1:2:0', 3000), + ])) + + await poll_until( + lambda: len(enter_leave_events) == 2, description='an enter and a leave event') + + assert len(enter_leave_events) == 2 + assert enter_leave_events[0].action == PresenceAction.ENTER + assert enter_leave_events[1].action == PresenceAction.LEAVE + + +# UTS: realtime/unit/RTP6d/subscribe-implicitly-attaches-0 +async def test_rtp6d_subscribe_implicitly_attaches(): + channel_name = f'test-RTP6d-{random_id()}' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, attach_count=attach_messages) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + assert channel.state == ChannelState.INITIALIZED + + await channel.presence.subscribe(lambda message: None) + await await_channel_state(channel, ChannelState.ATTACHED) + + assert len(attach_messages) == 1 + assert channel.state == ChannelState.ATTACHED + + +# UTS: realtime/unit/RTP6e/subscribe-no-attach-option-0 +@deviation +async def test_rtp6e_subscribe_no_attach_option(): + channel_name = f'test-RTP6e-{random_id()}' + attach_messages = [] + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name, attach_count=attach_messages, attached=False) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name, ChannelOptions(attach_on_subscribe=False)) + + assert channel.state == ChannelState.INITIALIZED + + await channel.presence.subscribe(lambda message: None) + + assert channel.state == ChannelState.INITIALIZED + assert len(attach_messages) == 0 + + +# UTS: realtime/unit/RTP7c/unsubscribe-all-listeners-0 +async def test_rtp7c_unsubscribe_all_listeners(): + channel_name = f'test-RTP7c-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + events_a = [] + events_b = [] + + def listener_a(message): + events_a.append(message) + + def listener_b(message): + events_b.append(message) + + await channel.presence.subscribe(listener_a) + await channel.presence.subscribe(listener_b) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + ])) + + await poll_until(lambda: events_a and events_b, description='both listeners called') + assert len(events_a) == 1 + assert len(events_b) == 1 + + channel.presence.unsubscribe() + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 2000), + ])) + + # The second event reaching the presence map is what marks it as delivered, + # so that the listener counts below are read after it, not before + await poll_until( + lambda: len(channel.presence.members.values()) == 2, + description='the second event applied to the presence map') + await settle() + + assert len(events_a) == 1 + assert len(events_b) == 1 + + +# UTS: realtime/unit/RTP7a/unsubscribe-specific-listener-0 +async def test_rtp7a_unsubscribe_specific_listener(): + channel_name = f'test-RTP7a-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + events_a = [] + events_b = [] + + def listener_a(message): + events_a.append(message) + + def listener_b(message): + events_b.append(message) + + await channel.presence.subscribe(listener_a) + await channel.presence.subscribe(listener_b) + + channel.presence.unsubscribe(listener_a) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + ])) + + await poll_until(lambda: events_b, description='the remaining listener called') + + assert len(events_a) == 0 + assert len(events_b) == 1 + + +# UTS: realtime/unit/RTP7b/unsubscribe-for-specific-action-0 +@deviation +async def test_rtp7b_unsubscribe_for_specific_action(): + channel_name = f'test-RTP7b-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + received = [] + + def listener(message): + received.append(message) + + await channel.presence.subscribe('enter', listener) + await channel.presence.subscribe('leave', listener) + + channel.presence.unsubscribe('enter', listener) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + presence_message(PresenceAction.LEAVE, 'alice', 'c1', 'c1:1:0', 2000), + ])) + + await poll_until(lambda: received, description='the leave event') + + assert len(received) == 1 + assert received[0].action == PresenceAction.LEAVE + + +# UTS: realtime/unit/RTP6/presence-events-update-map-0 +async def test_rtp6_presence_events_update_map(): + channel_name = f'test-RTP6-map-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + received = [] + + def on_event(message): + received.append(message) + + await channel.presence.subscribe(on_event) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000, data='hello'), + ])) + + await poll_until(lambda: received, description='the enter event') + + members = await channel.presence.get(wait_for_sync=False) + + assert len(members) == 1 + assert members[0].client_id == 'alice' + assert members[0].data == 'hello' + # RTP2d2: stored as PRESENT whatever action delivered it + assert members[0].action == PresenceAction.PRESENT + + +# UTS: realtime/unit/RTP6/multiple-presence-in-single-message-1 +async def test_rtp6_multiple_presence_in_single_message(): + channel_name = f'test-RTP6-batch-{random_id()}' + + mock_ws = MockWebSocket( + on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE), + ) + attaching_server(mock_ws, channel_name) + client = await connected_client(mock_ws) + channel = client.channels.get(channel_name) + + await channel.attach() + + received = [] + + def on_event(message): + received.append(message) + + await channel.presence.subscribe(on_event) + + mock_ws.send_to_client(presence_protocol_message(channel_name, [ + presence_message(PresenceAction.ENTER, 'alice', 'c1', 'c1:0:0', 1000), + presence_message(PresenceAction.ENTER, 'bob', 'c2', 'c2:0:0', 1000), + presence_message(PresenceAction.ENTER, 'carol', 'c3', 'c3:0:0', 1000), + ])) + + await poll_until(lambda: len(received) == 3, description='three presence events') + + assert len(received) == 3 + assert received[0].client_id == 'alice' + assert received[1].client_id == 'bob' + assert received[2].client_id == 'carol'